Compare commits

...

14 Commits

Author SHA1 Message Date
EJClaw
bc199de10b M7: 보드게임카페 게임 6종 합치기, 뱅 E2E 버튼 찾기 수정
- 달무티·보석 상인·벽 쌓기 길찾기·라스베가스·바퀴벌레 포커·구십구 브랜치 병합(등록 목록·E2E 계획 정리)
- 홈 '곧 나와요' 목록을 M8 게임으로 갱신
- 뱅 E2E: 손에 개틀링('다른 모두에게 뱅!')이 있으면 확인 버튼 찾기가 두 개에 걸리던 문제 → '님에게 뱅!'으로 좁힘
- 로드맵 M7 결과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:14:22 +09:00
EJClaw
8fb4219749 Merge branch 'm7/ninety-nine' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:13 +09:00
EJClaw
77a7492b85 Merge branch 'm7/cockroach-poker' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
7e83d061bc Merge branch 'm7/las-vegas' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
5aad13f8a5 Merge branch 'm7/quoridor' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
df280eb5b0 Merge branch 'm7/splendor' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
467d8ee4c2 Merge branch 'm7/dalmuti' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
c34497a445 M7: 구십구 규칙·화면·테스트
- 규칙 모듈 packages/games/src/ninety-nine: 전용 카드(1~8·10, ±9·±10·0, 조커)와
  트럼프(전통 99) 두 규칙, 모든 옵션 한국어 메타. 낼 카드가 없으면 같은 apply 안에서
  탈락/목숨 감소/새 판을 연쇄 처리, 시간 초과는 결과 합계가 가장 작은 카드를 자동으로 냄,
  연속 3번 시간 초과면 마감 3초, 나가면 탈락 처리. view는 남의 손패·더미를 숨기고
  카드별 결과 합계 미리보기와 선택지(+/−, A 1/11, 조커 범위)를 계산해 줌.
- 테스트: 문서 10절 시나리오 전부, 무작위 1,000판(보존·유출·onTimeout·결정성)과
  엔진 randomPlayout 18판.
- 화면 apps/web/src/games/ninety-nine: 큰 합계와 게이지 링, 방향 표시, 목숨 하트,
  카드 위 결과 미리보기, ± 두 버튼, 조커 숫자 다이얼, 키보드·스크린리더 지원.
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 1 + 휴대폰 2, 끝까지 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:55:36 +09:00
EJClaw
fa7ceea668 M7: 라스베가스 규칙·화면·테스트
- 규칙 모듈 packages/games/src/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부),
  중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산,
  하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리,
  시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권,
  hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산
- 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드
- 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐,
  굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시,
  최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가
- E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:53:47 +09:00
EJClaw
11ad2be147 M7: 보석 상인 규칙·화면·테스트
- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts),
  보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불),
  10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽,
  넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움),
  view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산
- 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성)
  + 엔진 randomPlayout
- 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미,
  보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:53:35 +09:00
EJClaw
1fc4d8c7fa M7: 쿼리도(벽 쌓기 길찾기) 규칙·화면·테스트
- 규칙 모듈 packages/games/src/quoridor (id 'quoridor', 표시 이름 '벽 쌓기 길찾기'):
  2인/4인(3인 거부, playerCountError), 직선·대각 점프(wallEdgePawn/wallOnly),
  벽 겹침·판 밖·남은 벽·BFS 길 막기 검사, 도착 즉시 승리, 4인 순위(남은 거리, 공동 순위),
  continueForRanking, 기권·시간패(4인은 말 제거·남은 벽 소멸), 자동 패스,
  무르기(2인 최대 2개 / 4인 다음 사람 전, 전원 동의)·무승부, perMove/fischer/none 시계,
  autoMove(최단 거리 칸, 동률 RNG, 연속 한도 넘으면 시간패).
  view는 차례인 사람에게만 legalPawnMoves·legalWalls(거리 변화량 포함)·myShortestPath.
- 테스트: 문서 §10 시나리오 전부, 1,000판 빠른 무작위 대국(불변식·정보 누출·onTimeout 합법),
  결정성·기록 재생, 엔진 randomPlayout 24판, 합법 벽 목록 성능(약 1.2ms).
- 화면 apps/web/src/games/quoridor: 내 출발 쪽이 아래로 오게 판 회전, 홈 교차점 스냅 →
  미리보기(불가하면 빨간색+이유) → 확인, 가로/세로 전환, 키보드(방향키·R·W·Enter),
  이동 가능 칸·최단 길 점선·목표 줄 색 띠·남은 벽 막대, 무르기/무승부/기권, 규칙 그림.
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON. E2E quoridor 플랜(PC 방장+휴대폰 친구).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:52:20 +09:00
EJClaw
dfe4ce1ca4 M7: 달무티 규칙·화면·테스트
- 규칙 모듈 packages/games/src/dalmuti: 80장(광대 2장) 덱, 첫 신분 뽑기(동점 재뽑기),
  신분 순서 분배, 혁명 확인 단계(혁명/대혁명), 세금 자동 이동과 돌려주기, 트릭 진행
  (광대 와일드·13, passLocksOut), 라운드 종료와 순위 점수/마지막 라운드 순위,
  시간 초과 자동 행동(리드: 가장 큰 숫자 전부, 따라 내기: 패스, 세금: 가장 큰 숫자),
  연속 3회 시간 초과 시 5초 마감, 나가도 탈락 없이 자동 진행, view 정보 숨김
- 테스트: 문서 10절 시나리오 전부 + 1,000 시드 무작위 대국(카드 보존, 유출 검사,
  onTimeout 합법, 결정성) + 엔진 randomPlayout
- 화면 apps/web/src/games/dalmuti: 자체 카드 그림, 숫자별 묶음 선택·장수 조절·광대 섞기,
  내기/패스, 바닥 기준 표시, 신분 자리, 혁명 확인·세금 교환 화면, 규칙·옵션
- 등록: GAMES, catalog, registry, GAME_ICON
- E2E: PC 방장 + 휴대폰 3명, 1라운드 끝까지 진행 후 2라운드 세금 교환 화면 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:49:44 +09:00
EJClaw
3e414e62ca M7: 바퀴벌레 포커 규칙·화면·테스트
- 규칙 모듈(packages/games/src/cockroach-poker): 64장(8종×8) 셔플·좌석순 분배, 2인 10장 비공개 제거,
  offer/call/peek/pass 흐름, seen 규칙, 2인 판정 전용(twoPlayerPass 하우스 룰은 1회 되돌려 주기),
  같은 종류 loseCount장(자동: 2인 5, 3~6인 4)·손패 0장 시작자·eightKindsRule 패배,
  시간 초과 자동 행동(§8.5)과 3연속 시간 초과 시 자리 비움(5초), 나가면 그 사람 패배,
  빠른 말(say, 라운드당 3번), view는 남의 손패·미확인자에게 건네는 카드·제거 카드를 숨김
- 테스트: §10 시나리오 전부, randomPlayout 200판 + 빠른 루프 800판(카드 보존·유출·onTimeout 합법·재생 결정성)
- 화면(apps/web/src/games/cockroach-poker): 자체 SVG 벌레 8종, 앞마당 종류별 배지(경고/위험),
  건네기 3단계(카드→사람→주장), 판정 시트(진짜!/거짓말!/몰래 보고 넘기기), 넘긴 순서·본 사람 표시
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON / E2E 플랜(PC 방장 + 휴대폰 2명, 끝까지 진행)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:46:30 +09:00
EJClaw
559fb8507c 로드맵 M6 문구 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:21:11 +09:00
32 changed files with 16137 additions and 4 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,816 @@
/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas';
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 ROLL_MS = 650;
const HOUSE = 'house';
/** Seat colours (dice), plus white for the neutral "house" dice. */
const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0'];
const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라'];
/** Play-bill colours per face value (deliberately toy-like). */
const BILL: Record<number, [string, string]> = {
1: ['#fde68a', '#78350f'],
2: ['#bbf7d0', '#14532d'],
3: ['#bae6fd', '#0c4a6e'],
4: ['#fbcfe8', '#831843'],
5: ['#ddd6fe', '#3b0764'],
6: ['#fed7aa', '#7c2d12'],
7: ['#a7f3d0', '#064e3b'],
8: ['#c7d2fe', '#1e1b4b'],
9: ['#fecaca', '#7f1d1d'],
};
const nick = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (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 seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888'));
const initial = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?'));
const CSS = `
.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; }
.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; }
.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border);
background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; }
button.lv-casino { cursor: pointer; }
button.lv-casino:hover { border-color: var(--primary); }
.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-casino.can { border-style: dashed; border-color: var(--primary); }
.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); }
.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); }
.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); }
.lv-ctop { display: flex; align-items: center; gap: 6px; }
.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; }
.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; }
.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; }
.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px;
font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; }
.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; }
.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; }
.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); }
.lv-nobill { color: var(--text-sub); font-size: .8rem; }
.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; }
.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; }
.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; }
.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; }
.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.lv-tag.first { background: var(--success); color: #fff; }
.lv-tag.tie { background: #facc15; color: #422006; }
.lv-tag.award { background: var(--primary); color: var(--primary-text); }
.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); }
@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } }
.lv-casino.hit { animation: lv-hit .55s ease-out; }
.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; }
.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px;
border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; }
.lv-group:disabled { cursor: default; opacity: 1; }
.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-group b { font-size: 1.15rem; }
.lv-group small { color: var(--text-sub); font-weight: 700; }
.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; }
.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; }
.lv-die svg { display: block; width: 100%; height: 100%; }
.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; }
@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } }
.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; }
.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; }
.lv-preview.first { background: var(--success-soft); color: var(--success); }
.lv-preview.tie { background: var(--warning-soft); color: var(--warning); }
.lv-preview.award, .lv-preview.none { background: var(--surface-2); }
.lv-roll { min-height: 60px; font-size: 1.15rem; }
.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.lv-settle h3 { margin: 0; font-size: 1rem; }
.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); }
.lv-prow:last-child { border-bottom: 0; }
.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; }
.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; }
.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; }
.lv-tieout .lv-dot { opacity: .55; }
@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; }
.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
.lv-final { padding: 12px; }
.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.lv-final td.notes { white-space: normal; text-align: left; }
@media (max-width: 1099px) {
.lv-lower { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 719px) {
.lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.lv-casino { min-height: 132px; padding: 6px; gap: 4px; }
.lv-wide { display: none; }
.lv-cnum { font-size: 1.25rem; }
.lv-cname { display: none; }
.lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; }
.lv-dot { width: 11px; height: 11px; }
.lv-help { gap: 6px; flex-wrap: nowrap; }
.lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; }
.lv-group { min-width: 76px; padding: 6px 8px; }
.lv-die { width: 22px; height: 22px; border-radius: 6px; }
.lv-die.lg { width: 32px; height: 32px; }
/* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */
.lv-lower { display: contents; }
.lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); }
}
@media (prefers-reduced-motion: reduce) {
.lv-casino, .lv-casino.sel { transition: none; transform: none; }
.lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; }
}
`;
// ---------------------------------------------------------------- small pieces
// prettier-ignore
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]],
};
/** Pip colour that reads on the die colour (white dice → dark pips). */
const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff');
function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) {
return (
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
<svg viewBox="0 0 100 100">
{PIPS[value]!.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
))}
</svg>
</span>
);
}
function Bill({ note, small }: { note: number | null; small?: boolean }) {
if (note === null)
return (
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
?
</span>
);
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
return (
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
{note}
</span>
);
}
function Dot({ color }: { color: string }) {
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
}
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
return (
<span className="lv-chip">
<Dot color={seatColor(v, owner)} />
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
</span>
);
}
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' };
function previewText(c: LasVegasChoice): string {
const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`;
switch (c.standing) {
case 'first':
return `${head} 지금은 1등이에요! (${c.note}점 지폐)`;
case 'award':
return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`;
case 'tie':
return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`;
default:
return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`;
}
}
// ---------------------------------------------------------------- board
function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps<LasVegasView>) {
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
const [sel, setSel] = useState<number | null>(null);
const [rolling, setRolling] = useState(false);
const [flick, setFlick] = useState<number[] | null>(null);
const [hit, setHit] = useState<{ value: number; key: number } | null>(null);
const [announce, setAnnounce] = useState('');
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const prevRounds = useRef(v.roundResults.length);
// New roll: tumble animation + screen-reader text.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.lastRoll) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
setSel(null);
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b);
setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`);
if (reducedMotion()) {
sounds.stone();
return;
}
setRolling(true);
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!rolling || !v.lastRoll) return;
const n = v.lastRoll.own.length + v.lastRoll.neutral.length;
const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setRolling(false);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps
// Turn changes: placement flash, round settlement and my-turn cue.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setSel(null);
const parts: string[] = [];
const lp = v.lastPlace;
if (lp) {
setHit({ value: lp.value, key: v.turnSeq });
const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`;
parts.push(
`${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`,
);
}
if (v.roundResults.length > prevRounds.current) {
prevRounds.current = v.roundResults.length;
const rr = v.roundResults[v.roundResults.length - 1]!;
const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length;
parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`);
haptic([50, 30, 50]);
}
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
if (parts.length) setAnnounce(parts.join(' '));
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const choices = v.phase === 'place' && !rolling ? v.choices : [];
const selChoice = choices.find((c) => c.value === sel) ?? null;
const canPick = myTurn && v.phase === 'place' && !pending && !rolling;
const pick = (value: number) => {
if (!canPick || !choices.some((c) => c.value === value)) return;
setSel(sel === value ? null : value);
};
const place = () => {
if (!canPick || sel === null) return;
act({ type: 'PLACE', value: sel });
setSel(null);
};
const roll = () => {
if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' });
};
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
const hand = me ? v.hand[me] : undefined;
const lastRR = v.roundResults[v.roundResults.length - 1];
return (
<div className="lv-wrap">
<style>{CSS}</style>
<div className="card lv-head">
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Dot color={seatColor(v, v.current)} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
</span>
)}
<span className="small muted">
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
</span>
</div>
{v.round === 1 && v.phase !== 'finished' && (
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
<span>🎲 같은 숫자는 한꺼번에</span>
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
</div>
)}
{v.result && lastRR && (
<div className="lv-lower">
<FinalTable v={v} me={me} players={players} />
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
</div>
)}
{!v.result && (
<section className="lv-casinos" aria-label="카지노 6곳">
{v.casinos.map((c) => {
const ch = choices.find((x) => x.value === c.value);
const isSel = sel === c.value;
const owners = Object.entries(c.dice)
.filter(([, n]) => (n ?? 0) > 0)
.sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0]));
const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${
owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음'
}${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`;
const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`;
const body = (
<>
<div className="lv-ctop">
<Die value={c.value} color="#fffdf8" />
<span className="lv-cnum">{c.value}</span>
<span className="lv-cname">번 카지노</span>
</div>
<div className="lv-bills">
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
</div>
<div className="lv-stacks">
{owners.map(([o, n]) => (
<div key={o} className="lv-stack" title={nick(players, o)}>
<span className="lv-pips">
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
<Dot key={i} color={seatColor(v, o)} />
))}
</span>
<span>{n! > 8 ? `${n}` : ''}</span>
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{initial(players, o)}
</span>
</div>
))}
{ch && isSel && (
<div className="lv-stack" style={{ opacity: 0.85 }}>
<span className="lv-pips">
{Array.from({ length: ch.own }, (_, i) => (
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: ch.neutral }, (_, i) => (
<Dot key={`n${i}`} color="#ffffff" />
))}
</span>
<span className="small">+{ch.own + ch.neutral}</span>
</div>
)}
</div>
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
</>
);
return ch && canPick ? (
<button
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
type="button"
className={cls}
aria-pressed={isSel}
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
onClick={() => pick(c.value)}
>
{body}
</button>
) : (
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
{body}
</div>
);
})}
</section>
)}
{!v.result && (
<div className="lv-lower">
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
{v.phase === 'finished' ? (
<p className="muted" style={{ textAlign: 'center' }}>
게임이 끝났어요
</p>
) : rolling && v.lastRoll ? (
<div className="lv-mdice" aria-hidden>
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
))}
</div>
) : v.phase === 'place' && v.current ? (
<>
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
{choices.map((c) => (
<button
key={c.value}
type="button"
className="lv-group"
data-standing={c.standing}
aria-pressed={canPick ? sel === c.value : undefined}
disabled={!canPick}
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
onClick={() => pick(c.value)}
>
<span className="lv-mdice">
{Array.from({ length: c.own }, (_, i) => (
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: c.neutral }, (_, i) => (
<Die key={`n${i}`} value={c.value} color="#ffffff" />
))}
</span>
<b>
{c.value} × {c.own + c.neutral}개
</b>
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
</button>
))}
</div>
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
{canPick ? (
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center' }}>
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
</p>
)}
</>
) : myTurn ? (
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
</p>
)}
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
</p>
)}
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
</p>
)}
</section>
{lastRR ? (
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
) : (
<section className="card lv-settle" aria-label="정산 안내">
<h3>라운드가 끝나면</h3>
<p className="small muted" style={{ margin: 0 }}>
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
</p>
</section>
)}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function Settlement({
round,
payouts,
v,
me,
players,
}: {
round: number;
payouts: LasVegasPayoutView[];
v: LasVegasView;
me: string | null;
players: Record<string, PublicUser>;
}) {
const fresh = v.phase !== 'finished' && round === v.round - 1;
return (
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
<h3>{round}라운드 정산</h3>
{payouts.map((p) => (
<div key={p.casino} className="lv-prow">
<strong>{p.casino}번</strong>
<div className="lv-pbody">
{p.removedTies.length > 0 && (
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
💥 동점 무효
{p.removedTies.map((o) => (
<Dot key={o} color={seatColor(v, o)} />
))}
</span>
)}
{p.awards.map((a, i) => (
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
<Bill note={a.note} small />
{a.note === null && <span className="muted">1장</span>}
</span>
))}
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
</div>
</div>
))}
</section>
);
}
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
const r = v.result!;
return (
<section className="card lv-final" aria-label="최종 결과">
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
<table>
<thead>
<tr>
<th scope="col">순위</th>
<th scope="col">이름</th>
{v.roundResults.map((rr) => (
<th key={rr.round} scope="col">
{rr.round}R
</th>
))}
<th scope="col">합계</th>
</tr>
</thead>
<tbody>
{r.ranking.map((row) => (
<tr key={row.player}>
<td>{row.rank}등</td>
<td style={{ textAlign: 'left' }}>
<OwnerChip v={v} players={players} owner={row.player} me={me} />
{row.conceded && <span className="muted small"> (기권)</span>}
</td>
{(r.roundTotals[row.player] ?? []).map((t, i) => (
<td key={i}>{t}</td>
))}
<td>
<strong>{row.total}점</strong>
<div className="small muted">{row.notes.length}장</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
const [ask, setAsk] = useState(false);
const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000;
const mine = me ? v.money[me] : undefined;
const playing = v.phase !== 'finished';
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.seats.map((p, i) => {
const turn = v.current === p;
const m = v.money[p]!;
const h = v.hand[p]!;
const out = v.conceded.includes(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 aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
<Die value={i + 1} color={COLORS[i]!} />
</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>}
{v.startPlayer === p && playing && (
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
선
</span>
)}
</div>
<div className="small muted">
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
</div>
</div>
<span
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
>
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
<div className="small muted">{m.count}장</div>
</span>
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>내 지폐</strong>
<span>
{mine.count}장 · {mine.total ?? 0}점
</span>
</div>
<div className="lv-bills">
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
</div>
{v.options.hideMoney && playing && (
<p className="small muted" style={{ margin: 0 }}>
다른 사람에게는 장 수만 보여요.
</p>
)}
</section>
)}
{playing && me && v.money[me] && !v.conceded.includes(me) && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'CONCEDE' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: LasVegasView, me: string | null): boolean {
return v.phase !== 'finished' && !!me && v.current === me;
}
function statusText(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요';
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ 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<LasVegasOptions>) {
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="라운드 수">
<Seg
label="라운드 수"
value={o.rounds}
options={[
[3, '3라운드'],
[4, '4라운드'],
[5, '5라운드'],
]}
onChange={(rounds) => set({ rounds })}
/>
</Field>
<Field label="중립(흰) 주사위">
<Seg
label="중립(흰) 주사위"
value={o.neutralDice}
options={[
['auto', '자동(2명일 때만)'],
['on', '켜기(2~4명)'],
['off', '끄기'],
]}
onChange={(neutralDice) => set({ neutralDice })}
/>
</Field>
<Field label="다른 사람이 받은 지폐 금액">
<Seg
label="다른 사람이 받은 지폐 금액"
value={o.hideMoney ? 'hide' : 'show'}
options={[
['hide', '숨기기(장 수만)'],
['show', '보여 주기'],
]}
onChange={(k) => set({ hideMoney: k === 'hide' })}
/>
</Field>
<Field label="굴리기·놓기 제한 시간">
<Seg
label="굴리기·놓기 제한 시간"
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[
['20', '20초'],
['30', '30초'],
['60', '60초'],
['none', '무제한'],
]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
/>
</Field>
<p className="small muted" style={{ margin: 0 }}>
흰 주사위 &quot;켜기&quot;는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
</p>
</div>
);
}
function optionsSummary(o: LasVegasOptions): string {
return [
`${o.rounds}라운드`,
o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기',
o.hideMoney ? '금액 숨김' : '금액 공개',
o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`,
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: LasVegasOptions }) {
const rounds = options?.rounds ?? 4;
return (
<div className="stack">
<style>{CSS}</style>
<p>
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
시작해요.
</p>
<p>
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
</p>
<p>
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
</p>
<div className="row" style={{ gap: 6 }}>
<Bill note={8} />
<Bill note={3} />
<Bill note={1} />
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
</div>
<p>
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 &quot;하우스&quot;라는
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
</p>
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
<p className="muted">
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
놓아요.
</p>
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
</div>
);
}
const ui: GameUI<LasVegasView, LasVegasOptions> = {
Board: LasVegasBoard,
Side: LasVegasSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,855 @@
/** 구십구 화면 (docs/games/ninety-nine.md §9). */
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import type { CardFace, CardKind, ChoiceView, HandCardView, LogEntry, NNOptions, NinetyNineView, SeatView } from '@bg/games/ninety-nine';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { PlayingCard } from '../../components/cards/PlayingCard';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
const TRUMP_RANK: Record<string, string> = { '10': 'T' };
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 resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
/** Short name, e.g. "±9", "조커", "♥K". */
function faceName(f: CardFace): string {
return f.suit ? `${SUIT_SYMBOL[f.suit]}${f.label}` : f.label;
}
/** Screen-reader name, e.g. "하트 K", "±9 카드". */
function ariaFace(f: CardFace): string {
if (f.suit) return `${SUIT_KO[f.suit]} ${f.label}`;
return f.label === '조커' ? '조커' : `${f.label} 카드`;
}
const fmt = (n: number) => String(n);
/** "−9" etc. use a real minus sign; "+9 → 95". */
const arrow = (c: ChoiceView) => `${c.label} → ${c.legal ? fmt(c.total) : c.total > 99 ? '99 초과' : '0 미만'}`;
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
}
/** Counts up/down to `target` (instant when reduced motion is preferred). */
function useCountUp(target: number): number {
const [shown, setShown] = useState(target);
const from = useRef(target);
useEffect(() => {
const start = from.current;
from.current = target;
if (start === target || prefersReducedMotion()) {
setShown(target);
return;
}
let raf = 0;
const t0 = performance.now();
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 420);
setShown(Math.round(start + (target - start) * (1 - (1 - k) * (1 - k))));
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [target]);
return shown;
}
/** Green → yellow → red as the total nears 99. */
const gaugeColor = (t: number) => `hsl(${Math.max(0, 130 - (t / 99) * 130)} 78% 46%)`;
const CSS = `
.nn { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
.nn-opps { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 6px; margin: 0; list-style: none; scrollbar-width: thin; }
.nn-seat { flex: none; min-width: 118px; max-width: 176px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px;
border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 3px; font-size: .9rem; transition: filter .4s ease, opacity .4s ease; }
.nn-seat.turn { border-color: var(--focus); }
.nn-seat.out { filter: grayscale(1); opacity: .6; }
.nn-seat .nm { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.nn-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nn-count i { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #7c2d12; border: 1.5px solid #fdba74; }
.nn-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; line-height: 1.6; color: #fff; }
.nn-hearts { display: inline-flex; gap: 1px; font-size: 1rem; line-height: 1; }
.nn-heart { color: #e11d48; display: inline-block; }
.nn-heart.lost { color: var(--text-sub); opacity: .55; animation: nn-break .7s ease; }
.nn-table { position: relative; border-radius: 24px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt);
border: 8px solid var(--wood); box-shadow: var(--shadow); padding: 14px 18px; display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: center; color: #fff; }
.nn-left, .nn-right { display: flex; flex-direction: column; gap: 8px; min-width: 0; text-shadow: 0 1px 2px rgba(0,0,0,.5); }
.nn-left { align-items: flex-start; }
.nn-right { align-items: flex-end; }
.nn-dial { position: relative; width: var(--dial); height: var(--dial); display: grid; place-items: center; }
.nn-dial svg { position: absolute; inset: 0; transform: rotate(-90deg); }
.nn-dial svg circle { transition: stroke-dashoffset .4s ease, stroke .4s ease; }
.nn-total { font-size: calc(var(--dial) * .42); font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; color: #fff; text-shadow: 0 2px 3px rgba(0,0,0,.35); }
.nn-total-cap { position: absolute; bottom: calc(var(--dial) * .2); font-size: .8rem; font-weight: 700; opacity: .9; }
.nn-dir { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; background: rgba(0,0,0,.3); border-radius: 999px; padding: 3px 12px 3px 6px; }
.nn-dir b { display: inline-block; font-size: 1.6rem; line-height: 1; transition: transform .45s ease; }
.nn-dir[data-dir='-1'] b { transform: scaleX(-1) rotate(-30deg); }
.nn-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.32); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .9rem; }
.nn-rule { font-weight: 800; color: #fde68a; }
.nn-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
.nn-recent li { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.28); border-radius: 10px; padding: 2px 8px; font-size: .88rem; max-width: 100%; }
.nn-recent li:last-child { background: rgba(0,0,0,.5); font-weight: 800; animation: nn-pop .3s ease; }
.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; }
.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; }
.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; }
.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; }
.nn-mini[data-red='true'] { color: #dc2626; }
.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
.nn-hand-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
.nn-hand { display: flex; gap: 10px; justify-content: center; padding-top: 30px; flex-wrap: wrap; row-gap: 34px; }
.nn-card { position: relative; flex: none; width: var(--cw); height: calc(var(--cw) * 1.4); padding: 0; border: 0; background: none; border-radius: 10px;
transition: transform .14s ease, opacity .14s ease; cursor: pointer; }
.nn-card[data-legal='false'] { opacity: .5; filter: grayscale(.5); }
.nn-card[data-legal='true'] { box-shadow: 0 0 0 3px var(--success); }
.nn-card[aria-pressed='true'] { transform: translateY(-10px); box-shadow: 0 0 0 4px var(--primary); z-index: 2; }
.nn-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; z-index: 3; }
.nn-prev { position: absolute; left: 50%; top: -27px; transform: translateX(-50%); white-space: nowrap; border-radius: 999px; padding: 1px 9px;
font-size: .82rem; font-weight: 800; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); pointer-events: none; }
.nn-prev.ok { background: var(--success-soft); color: var(--success); border-color: var(--success); }
.nn-prev.no { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
.nn-face { display: block; width: 100%; height: 100%; }
.nn-face svg, .nn-face > span:not(.nn-art) { width: 100% !important; height: 100% !important; display: block; }
.nn-art { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 10px;
background: #fffef9; border: 2px solid #9ca3af; color: #1f2937; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.nn-art::before { content: ''; position: absolute; inset: 6px; border-radius: 7px; border: 2px solid var(--band); opacity: .55; }
.nn-art .big { font-size: calc(var(--cw, 90px) * .44); font-weight: 900; line-height: 1; color: var(--band); letter-spacing: -.03em; }
.nn-art .sub { margin-top: 4px; font-size: calc(var(--cw, 90px) * .13); font-weight: 800; color: var(--band); opacity: .9; }
.nn-art .corner { position: absolute; top: 5px; left: 8px; font-size: calc(var(--cw, 90px) * .17); font-weight: 900; color: var(--band); }
.nn-art .corner.br { top: auto; left: auto; bottom: 5px; right: 8px; transform: rotate(180deg); }
.nn-art[data-k='add'] { --band: #1d4ed8; }
.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; }
.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); }
.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); }
.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; }
.nn-joker { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
.nn-joker .val { min-width: 84px; text-align: center; font-size: 2rem; font-weight: 900; font-variant-numeric: tabular-nums; border-radius: 12px;
border: 2px solid var(--border); padding: 2px 8px; background: var(--surface-2); color: var(--text); }
.nn-joker .val:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.nn-joker .step { width: 52px; min-width: 52px; padding: 0; font-size: 1.5rem; font-weight: 900; }
.nn-joker .quick { min-width: 60px; }
.nn-quick { display: inline-flex; gap: 8px; }
.nn-joker .btn[aria-pressed='true'] { border-color: var(--primary); background: var(--primary); color: var(--primary-text); }
.nn-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.nn-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 240px; overflow: auto; }
.nn-rules td, .nn-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
.nn-rules .nn-face { width: 34px; height: 48px; --cw: 34px; }
.nn-rules .nn-art::before { inset: 3px; }
@keyframes nn-pop { 0% { transform: scale(.7); } 70% { transform: scale(1.08); } 100% { transform: scale(1); } }
@keyframes nn-break { 0% { transform: scale(1); color: #e11d48; opacity: 1; } 40% { transform: scale(1.5) rotate(-15deg); } 100% { transform: scale(1); } }
@media (max-width: 719px) {
.nn-table { grid-template-columns: 1fr auto; grid-template-areas: 'dial left' 'right right'; padding: 10px; gap: 8px 10px; border-width: 6px; }
.nn-dial { grid-area: dial; }
.nn-left { grid-area: left; }
.nn-right { grid-area: right; align-items: stretch; }
.nn-recent { flex-direction: row; flex-wrap: wrap; justify-content: center; }
.nn-recent li:not(:nth-last-child(-n+3)) { display: none; }
.nn-seat { min-width: 100px; padding: 5px 6px; }
.nn-hand { gap: 8px; }
.nn-quick { flex-basis: 100%; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.nn-card, .nn-dir b, .nn-dial svg circle, .nn-seat { transition: none; }
}
`;
// ---------------------------------------------------------------- cards
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
function trumpId(f: CardFace): string {
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
}
function Face({ face, kind, jokerRange }: { face: CardFace; kind: CardKind; jokerRange?: { min: number; max: number } }) {
if (face.suit) {
return (
<span className="nn-face" aria-hidden>
<PlayingCard card={trumpId(face)} size="lg" style={{ width: '100%', height: '100%', filter: 'none' }} />
</span>
);
}
const sub = kind === 'joker' ? (jokerRange ? `${jokerRange.min}~${jokerRange.max}` : '원하는 수') : (KR_SUB[kind] ?? '');
const big = kind === 'joker' ? '★' : face.label;
return (
<span className="nn-face" aria-hidden>
<span className="nn-art" data-k={kind} data-big={kind === 'add' && face.label === '10'}>
<span className="corner">{kind === 'joker' ? '조커' : face.label}</span>
<span className="big">{big}</span>
{sub && <span className="sub">{sub}</span>}
<span className="corner br">{kind === 'joker' ? '조커' : face.label}</span>
</span>
</span>
);
}
function Mini({ face, kind }: { face: CardFace; kind: CardKind }) {
return (
<span className="nn-mini" data-k={kind} data-red={face.suit === 'H' || face.suit === 'D'} aria-hidden>
{faceName(face)}
</span>
);
}
// ---------------------------------------------------------------- seats
function Hearts({ s, start }: { s: SeatView; start: number }) {
if (start <= 0) return null;
return (
<span className="nn-hearts" aria-hidden>
{Array.from({ length: start }, (_, i) => (
<span key={i} className={`nn-heart${i >= s.lives ? ' lost' : ''}`}>
{i >= s.lives ? '♡' : '♥'}
</span>
))}
</span>
);
}
function seatLabel(s: SeatView, v: NinetyNineView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
if (!s.alive) parts.push(s.left ? '나감' : '탈락');
else parts.push(`카드 ${s.cards}장`);
if (v.livesStart > 0) parts.push(`목숨 ${s.lives}개`);
if (s.onBoard && s.alive) parts.push('목숨 없이 마지막 기회');
if (s.away) parts.push('자리 비움');
if (s.isTurn) parts.push('차례');
return parts.join(', ');
}
function SeatChip({ s, v, players, me, deadline }: { s: SeatView; v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
const u = userOf(players, s.id);
const rank = v.result?.ranking.find((r) => r.player === s.id)?.rank;
return (
<li className={`nn-seat${s.isTurn ? ' turn' : ''}${s.alive ? '' : ' out'}`} aria-label={seatLabel(s, v, players, me)}>
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
<Avatar user={u} size={24} />
<span className="nm">{u.nickname}</span>
{s.isTurn && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={s.id === me} size={28} />}
</div>
<div className="row" style={{ gap: 6 }} aria-hidden>
{s.alive ? (
<span className="nn-count">
<i />
{s.cards}
</span>
) : (
<span className="nn-badge" style={{ background: s.left ? 'var(--text-sub)' : 'var(--danger)' }}>
{s.left ? '나감' : '탈락'}
</span>
)}
<Hearts s={s} start={v.livesStart} />
{s.onBoard && s.alive && <span className="nn-badge" style={{ background: 'var(--warning)' }}>마지막 기회</span>}
{s.away && <span className="nn-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
{rank && <span className="nn-badge" style={{ background: rank === 1 ? 'var(--success)' : 'var(--text-sub)' }}>{rank}등</span>}
</div>
</li>
);
}
// ---------------------------------------------------------------- table
function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
const shown = useCountUp(v.total);
const dial = 'clamp(150px, 20vw, 196px)';
const r = 44;
const c = 2 * Math.PI * r;
const frac = Math.min(1, v.total / 99);
const cur = v.current;
return (
<div className="nn-table" role="region" aria-label="카드 판" style={{ '--dial': dial } as CSSProperties}>
<div className="nn-left">
<span className="nn-dir" data-dir={v.direction} role="img" aria-label={v.direction === 1 ? '진행 방향: 시계 방향' : '진행 방향: 반대 방향'}>
<b aria-hidden>↻</b>
{v.direction === 1 ? '시계 방향' : '반대 방향'}
</span>
{cur && (
<span className="nn-chip">
{cur === me ? '내 차례' : `${nick(players, cur)}님 차례`}
<TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={cur === me} size={40} />
</span>
)}
<span className="nn-chip" aria-label={`뽑을 카드 ${v.drawPileCount}장`}>
더미 {v.drawPileCount}장
</span>
{v.livesStart > 0 && <span className="nn-chip">{v.round}판째</span>}
</div>
<div className="nn-dial" role="img" aria-label={`지금 합계 ${v.total}`}>
<svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden>
<circle cx="50" cy="50" r={r} fill="rgba(0,0,0,.28)" stroke="rgba(255,255,255,.18)" strokeWidth="8" />
<circle cx="50" cy="50" r={r} fill="none" stroke={gaugeColor(v.total)} strokeWidth="8" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} />
</svg>
<span className="nn-total" aria-hidden>
{shown}
</span>
<span className="nn-total-cap" aria-hidden>
/ 99
</span>
</div>
<div className="nn-right">
<span className="nn-rule">99를 넘기면 안 돼요!</span>
{v.recent.length > 0 ? (
<ol className="nn-recent" aria-label="최근에 낸 카드">
{v.recent.slice(-4).map((e) => (
<li key={`${e.seq}-${e.p}`}>
<span className="who">{nick(players, e.p)}</span>
<Mini face={e.face} kind={e.kind} />
{e.choice && e.kind !== 'joker' ? <span>{e.choice}</span> : null}
<span aria-hidden>→</span>
<strong>{e.total}</strong>
</li>
))}
</ol>
) : (
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------- my hand
function cardAria(c: HandCardView, myTurn: boolean): string {
const name = ariaFace(c.face);
if (c.joker) return `${name}, ${c.joker.min}부터 ${c.joker.max} 사이 수로 만들어요`;
const parts = c.choices.map((x) => (c.choices.length > 1 ? `${x.label}면 ` : '내면 ') + (x.legal ? `${x.total}` : x.total > 99 ? `${x.total}이라 낼 수 없음` : '0 미만이라 낼 수 없음'));
return `${name}, ${parts.join(', ')}${myTurn ? '' : ' (내 차례가 아니에요)'}`;
}
function previewOf(c: HandCardView): { text: string; cls: string } {
if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' };
if (c.badge) return { text: c.badge, cls: 'no' };
const ok = c.choices.filter((x) => x.legal);
if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' };
return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' };
}
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
const { min, max } = c.joker!;
const [n, setN] = useState(() => Math.min(max, Math.max(min, total)));
const clamp = (x: number) => Math.min(max, Math.max(min, x));
const quick = [...new Set([min, 90, 99].filter((x) => x >= min && x <= max))];
const onKey = (e: KeyboardEvent<HTMLSpanElement>) => {
const d: Record<string, number> = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 };
if (e.key in d) setN(clamp(n + d[e.key]!));
else if (e.key === 'Home') setN(min);
else if (e.key === 'End') setN(max);
else if (e.key === 'Enter') onPlay(n);
else return;
e.preventDefault();
};
return (
<div className="stack" style={{ gap: 8 }}>
<div className="nn-joker" role="group" aria-label="조커로 만들 수 고르기">
<button type="button" className="btn btn-secondary step" aria-label="1 작게" disabled={n <= min} onClick={() => setN(clamp(n - 1))}>
−
</button>
<span className="val" role="spinbutton" tabIndex={0} aria-label="조커로 만들 수" aria-valuemin={min} aria-valuemax={max} aria-valuenow={n} onKeyDown={onKey}>
{n}
</span>
<button type="button" className="btn btn-secondary step" aria-label="1 크게" disabled={n >= max} onClick={() => setN(clamp(n + 1))}>
+
</button>
<span className="nn-quick" role="group" aria-label="빠른 선택">
{quick.map((q) => (
<button key={q} type="button" className="btn btn-secondary quick" aria-pressed={n === q} onClick={() => setN(q)}>
{q}
</button>
))}
</span>
</div>
<div className="nn-pick">
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => onPlay(n)}>
조커 내기 → {n}
</button>
</div>
</div>
);
}
function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => void; pending: boolean }) {
const my = v.my!;
const [sel, setSel] = useState<string | null>(null);
const [note, setNote] = useState('');
const myTurn = my.myTurn;
useEffect(() => {
setSel(null);
setNote('');
}, [v.turnNo]);
const chosen = my.hand.find((c) => c.id === sel) ?? null;
const send = (c: HandCardView, extra: Record<string, unknown> = {}) => {
if (pending) return;
act({ type: 'play', cardId: c.id, ...extra });
setSel(null);
};
const single = (c: HandCardView) => c.choices.length === 1 && !c.joker;
const tap = (c: HandCardView) => {
if (!myTurn) {
setNote('지금은 내 차례가 아니에요. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.');
setSel(sel === c.id ? null : c.id);
return;
}
if (!c.legal) {
setNote(`${ariaFace(c.face)}은(는) ${c.badge === '0 미만' ? '0보다 작아져서' : '99를 넘어서'} 낼 수 없어요.`);
setSel(null);
return;
}
setNote('');
if (sel === c.id) {
if (single(c)) send(c);
else setSel(null);
return;
}
setSel(c.id);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
setSel(null);
return;
}
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const btns = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('button.nn-card'));
const i = btns.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return;
e.preventDefault();
btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus();
};
const n = my.hand.length;
const cw = n <= 3 ? 'clamp(84px, 24vw, 104px)' : n === 4 ? 'clamp(72px, 20vw, 96px)' : 'clamp(62px, 16.5vw, 90px)';
const meSeat = v.seats.find((s) => s.id === v.me);
const legalCount = my.hand.filter((c) => c.legal).length;
return (
<div className="nn-hand-wrap">
<div className="nn-hand-head">
<strong>
내 카드 {n}장{myTurn ? ` · 낼 수 있는 카드 ${legalCount}장` : ''}
</strong>
{meSeat && v.livesStart > 0 && (
<span className="row small" style={{ gap: 6 }} aria-label={`내 목숨 ${meSeat.lives}개`}>
목숨 <Hearts s={meSeat} start={v.livesStart} />
</span>
)}
</div>
<div className="nn-hand" role="group" aria-label="내 카드. 좌우 방향키로 고르고 Enter로 고른 뒤 한 번 더 누르면 내요" onKeyDown={onKey} style={{ '--cw': cw } as CSSProperties}>
{my.hand.map((c) => {
const p = previewOf(c);
return (
<button
key={c.id}
type="button"
className="nn-card"
data-legal={myTurn ? c.legal : undefined}
aria-pressed={sel === c.id}
aria-label={`${cardAria(c, myTurn)}${sel === c.id && myTurn && single(c) ? '. 한 번 더 누르면 내요' : ''}`}
onClick={() => tap(c)}
>
<span className={`nn-prev ${p.cls}`} aria-hidden>
{p.text}
</span>
<Face face={c.face} kind={c.kind} jokerRange={c.joker ?? undefined} />
</button>
);
})}
{n === 0 && <span className="muted">손에 카드가 없어요.</span>}
</div>
{note && (
<p className="small" style={{ margin: 0 }} aria-live="polite">
{note}
</p>
)}
{chosen && myTurn && chosen.legal && (
<div className="stack" style={{ gap: 6 }}>
{chosen.joker ? (
<JokerDial key={chosen.id} c={chosen} total={v.total} pending={pending} onPlay={(jokerValue) => send(chosen, { jokerValue })} />
) : (
<div className="nn-pick" role="group" aria-label={`${ariaFace(chosen.face)} 내는 방법`}>
{chosen.choices.map((x) => (
<button
key={x.label}
type="button"
className="btn btn-primary"
disabled={!x.legal || pending}
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
>
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
</button>
))}
</div>
)}
</div>
)}
{myTurn && !chosen && <p className="small muted" style={{ margin: 0 }}>카드를 누르면 낼 때 합계가 보여요. 한 번 더 누르면 내요.</p>}
</div>
);
}
// ---------------------------------------------------------------- log
function logText(e: LogEntry, players: Record<string, PublicUser>): string {
const n = (id: string) => nick(players, id);
switch (e.t) {
case 'play': {
const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`;
return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`;
}
case 'noCard':
return `${n(e.p)}님은 낼 수 있는 카드가 없어요`;
case 'life':
return `${n(e.p)}님이 목숨 1개를 잃었어요(남은 목숨 ${e.left}개)`;
case 'out':
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
case 'round':
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
case 'reshuffle':
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
case 'timeout':
return `${n(e.p)}님 시간 초과 · 가장 작은 수가 되는 카드를 자동으로 냈어요`;
}
}
// ---------------------------------------------------------------- board
function Board({ view: v, me, players, act, pending, deadline }: BoardProps<NinetyNineView>) {
const [announce, setAnnounce] = useState('');
const mine = isMyTurn(v, me);
const prevMine = useRef(false);
const last = v.log[v.log.length - 1];
useEffect(() => {
if (mine && !prevMine.current) {
haptic(60);
sounds.myTurn();
setAnnounce(`내 차례예요. 지금 합계 ${v.total}`);
}
prevMine.current = mine;
}, [mine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (last && !mine) setAnnounce(logText(last, players));
}, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps
const myIdx = v.seats.findIndex((s) => s.id === me);
const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)];
const meSeat = v.seats.find((s) => s.id === me);
return (
<div className="nn">
<style>{CSS}</style>
<ul className="nn-opps" aria-label="다른 사람">
{others.map((s) => (
<SeatChip key={s.id} s={s} v={v} players={players} me={me} deadline={deadline} />
))}
</ul>
<Table v={v} players={players} me={me} deadline={deadline} />
{v.my && meSeat?.alive && v.phase === 'playing' && <MyHand v={v} act={act} pending={pending} />}
{meSeat && !meSeat.alive && v.phase === 'playing' && (
<div className="nn-panel">
<strong>{meSeat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function rulesLine(o: NNOptions): string {
const lives = resolvedLives(o);
const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락'];
if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로');
parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0');
if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`);
else parts.push(`A는 1 또는 ${o.aceHigh}`);
return parts.join(' · ');
}
function Side({ view: v, me, players, deadline }: BoardProps<NinetyNineView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
구십구 · {MODE_KO[v.mode]}
{v.livesStart > 0 ? ` · ${v.round}판` : ''}
{v.phase === 'finished' ? ' · 게임 끝' : ''}
</strong>
{v.current && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.alive ? 1 : 0.55 }} aria-label={seatLabel(s, v, players, me)}>
<span style={{ filter: s.alive ? 'none' : 'grayscale(1)', display: 'inline-flex' }}>
<Avatar user={userOf(players, s.id)} size={24} />
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{s.isTurn ? '▶ ' : ''}
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{!s.alive ? (s.left ? ' · 나감' : ' · 탈락') : s.away ? ' · 자리 비움' : ''}
</span>
<Hearts s={s} start={v.livesStart} />
{s.alive && (
<span className="nn-count" aria-hidden>
<i />
{s.cards}
</span>
)}
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.player}>
{r.rank}등 {nick(players, r.player)}
<span className="muted small">
{r.eliminatedAtRound === null ? ' · 끝까지 살아남음' : r.left ? ' · 나감' : v.livesStart > 0 ? ` · ${r.eliminatedAtRound}판에서 탈락` : ' · 탈락'}
{v.livesStart > 0 ? ` · 잃은 목숨 ${r.livesLost}개` : ''}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="nn-log">
{[...v.log].reverse().map((e, i) => (
<li key={`${e.seq}-${e.t}-${i}`} className="small">
{logText(e, players)}
</li>
))}
</ol>
</details>
)}
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>특수 카드</summary>
<SpecialTable o={v.options} />
</details>
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: NinetyNineView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.current === me;
}
function statusText(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (isMyTurn(v, me)) return `내 차례예요 · 지금 합계 ${v.total}`;
const meSeat = v.seats.find((s) => s.id === me);
const who = v.current ? `${nick(players, v.current)}님 차례예요 · 합계 ${v.total}` : '잠시만 기다려 주세요';
return meSeat && !meSeat.alive && !meSeat.left ? `탈락했어요 · ${who}` : who;
}
// ---------------------------------------------------------------- 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, help }: { label: string; children: ReactNode; help?: string }) {
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<NNOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<NNOptions>) => onChange({ ...o, ...patch });
const kr = o.mode === 'kr-product';
const lives = resolvedLives(o);
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="규칙" help={kr ? '1~8·10 숫자 카드와 ±9·±10·0·조커 전용 카드로 해요.' : '트럼프 카드로 해요. K는 99, 4는 방향 바꾸기, 9는 그대로, 10은 빼기 10.'}>
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
</Field>
<Field label="손패 장수">
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
</Field>
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
<Seg
label="목숨"
value={o.lives}
options={[['auto', kr ? '없음(기본)' : '3개(기본)'], [0, '없음'], [1, '1개'], [2, '2개'], [3, '3개'], [5, '5개']]}
onChange={(v) => set({ lives: v })}
/>
</Field>
<Field label="한 턴 제한 시간">
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
{kr && (
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
어린이 추천 설정(조커 0~99, 손패 4장)
</button>
)}
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드를 낸 뒤 1장 보충">
<Seg label="카드를 낸 뒤 1장 보충" value={o.refill} options={[[true, '보충해요'], [false, '보충 안 해요']]} onChange={(refill) => set({ refill })} />
</Field>
<Field label="목숨을 다 잃어도 한 번 더" help="켜면 목숨이 0개여도 남고, 한 번 더 지면 탈락해요.">
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
</Field>
<Field label="(목숨 없음) 탈락 뒤 합계">
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
</Field>
<Field label="빼서 0보다 작아질 때">
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
</Field>
{kr ? (
<>
<Field label="±9·±10·0 카드 장수(각각)">
<Seg label="특수 카드 장수" value={o.specialCounts} options={[[2, '2장'], [3, '3장'], [4, '4장'], [5, '5장'], [6, '6장']]} onChange={(specialCounts) => set({ specialCounts })} />
</Field>
<Field label="조커로 부를 수 있는 수">
<Seg label="조커로 부를 수 있는 수" value={o.jokerRange} options={[['60-99', '60~99'], ['0-99', '0~99(어린이용)']]} onChange={(jokerRange) => set({ jokerRange })} />
</Field>
</>
) : (
<>
<Field label="A의 큰 값">
<Seg label="A의 큰 값" value={o.aceHigh} options={[[11, '11'], [10, '10']]} onChange={(aceHigh) => set({ aceHigh })} />
</Field>
<Field label="덱 수">
<Seg label="덱 수" value={o.deckCount} options={[['auto', '자동(7명부터 2벌)'], [1, '1벌'], [2, '2벌']]} onChange={(deckCount) => set({ deckCount })} />
</Field>
</>
)}
</>
)}
</div>
);
}
function optionsSummary(o: NNOptions): string {
const lives = resolvedLives(o);
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
if (!o.refill) parts.push('보충 없음');
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
parts.push(`${o.turnSeconds}초`);
return parts.join(' · ');
}
// ---------------------------------------------------------------- rules
function SpecialTable({ o }: { o: NNOptions }) {
const kr = o.mode === 'kr-product';
const C = (label: string, kind: CardKind, suit?: CardFace['suit']) => <Face face={suit ? { label, suit } : { label }} kind={kind} />;
const floor = o.floorAtZero === 'forbid' ? '0보다 작아지게는 못 빼요.' : '0보다 작아지면 0이 돼요.';
const rows: [ReactNode, string, string][] = kr
? [
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
]
: [
[C('A', 'ace', 'S'), 'A', `1 또는 ${o.aceHigh}을 더해요(고를 수 있어요).`],
[C('4', 'reverse', 'H'), '4', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
[C('9', 'hold', 'C'), '9', '합계는 그대로 넘겨요.'],
[C('10', 'minus', 'D'), '10', `10을 빼요. ${floor}`],
[C('J', 'add', 'S'), 'J·Q', '10을 더해요.'],
[C('K', 'to99', 'H'), 'K', '합계를 바로 99로 만들어요.'],
];
return (
<table className="nn-rules small" style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
<tbody>
{rows.map(([pic, name, desc]) => (
<tr key={name}>
<td style={{ width: 44 }}>{pic}</td>
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
{name}
</th>
<td>{desc}</td>
</tr>
))}
</tbody>
</table>
);
}
function Rules({ options }: { options?: NNOptions }) {
const o: NNOptions = {
mode: 'kr-product',
handSize: 3,
refill: true,
lives: 'auto',
onTheBoard: false,
afterElimination: 'continue',
floorAtZero: 'forbid',
specialCounts: 4,
jokerRange: '60-99',
aceHigh: 11,
deckCount: 'auto',
turnSeconds: 15,
...options,
};
const lives = resolvedLives(o);
return (
<div className="stack">
<p>
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
</p>
<p>
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
</p>
<p>
<strong>낼 카드가 없으면</strong>:{' '}
{lives > 0
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
: o.afterElimination === 'reset'
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
마지막까지 남은 1명이 이겨요.
</p>
<SpecialTable o={o} />
<p className="small muted">
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
</p>
</div>
);
}
const ui: GameUI<NinetyNineView, NNOptions> = {
Board,
Side,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,936 @@
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { toast } from '../../store/toast';
import type { BoardProps, GameUI, OptionsProps } from '../types';
type O = 'h' | 'v';
interface Pt {
x: number;
y: number;
}
const N = 9;
const G = 0.2; // 홈(groove) 폭
const PAD = 0.78;
const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
const SEAT_COLOR: Record<Seat, string> = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' };
const COLOR_KO: Record<Seat, string> = { S: '빨강', W: '초록', N: '파랑', E: '주황' };
const SIDE_KO = { top: '맨 위', bottom: '맨 아래', left: '맨 왼쪽', right: '맨 오른쪽' } as const;
type Side = keyof typeof SIDE_KO;
const label = (p: Cell) => `${'abcdefghi'[p.c]}${p.r + 1}`;
const wallKey = (w: Wall) => `${w.o}:${w.c},${w.r}`;
/** 가로·세로는 보는 사람 화면 기준(서·동 자리는 판이 90° 돌아가 있음). */
const wallText = (w: Wall, swap = false) => `${(w.o === 'h') !== swap ? '가로' : '세로'} 벽 ${label(w)}`;
const swapFor = (seat: Seat | null) => seat === 'W' || seat === 'E';
const CSS = `
.qd-pawn { transition: transform 220ms ease-out; }
.qd-wall-new { animation: qd-drop 260ms ease-out both; transform-box: fill-box; transform-origin: center; }
.qd-turn-ring { animation: qd-pulse 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes qd-drop { 0% { transform: scale(1.35); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
@keyframes qd-pulse { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.35; } }
.qd-board * { pointer-events: none; }
.qd-board [data-cell], .qd-board [data-anchor] { pointer-events: auto; }
.qd-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.qd-modes .btn { min-height: 52px; padding-inline: 8px; white-space: nowrap; font-size: 1rem; }
.qd-modes .btn[aria-pressed='true'] { box-shadow: inset 0 0 0 3px var(--focus); }
.qd-tray { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
.qd-tray .btn { min-height: 48px; }
.qd-hint { flex: 1 1 100%; min-width: 0; font-weight: 700; }
.qd-tray .qd-confirm { flex: 2 1 0; }
.qd-tray .qd-rotate { flex: 1 1 0; }
.qd-hint.bad { color: var(--danger); }
.qd-bars { display: inline-flex; gap: 2px; align-items: flex-end; }
.qd-bars i { display: inline-block; width: 4px; height: 16px; border-radius: 2px; background: var(--warning); }
.qd-bars i.used { background: var(--border); opacity: 0.6; }
@media (prefers-reduced-motion: reduce) {
.qd-pawn { transition: none; }
.qd-wall-new, .qd-turn-ring { animation: none; }
}`;
/** 판 돌리기: 모델 좌표(x 오른쪽, y 위쪽, 0~9) ↔ 화면 좌표(y 아래쪽). 네 가지 모두 자기 자신이 역변환. */
function makeTf(seat: Seat | null) {
const s = seat ?? 'S';
const tf = (x: number, y: number): [number, number] =>
s === 'S' ? [x, N - y] : s === 'N' ? [N - x, y] : s === 'W' ? [N - y, N - x] : [y, x];
const swap = s === 'W' || s === 'E';
return {
swap,
cellToScreen: (p: Cell): Pt => {
const [x, y] = tf(p.c + 0.5, p.r + 0.5);
return { x: Math.floor(x), y: Math.floor(y) };
},
screenToCell: (p: Pt): Cell => {
const [x, y] = tf(p.x + 0.5, p.y + 0.5);
return { c: Math.floor(x), r: Math.floor(y) };
},
/** 화면 앵커(0~7)와 화면 방향. */
wallToScreen: (w: Wall): { a: Pt; o: O } => {
const [x, y] = tf(w.c + 1, w.r + 1);
return { a: { x: Math.round(x) - 1, y: Math.round(y) - 1 }, o: swap ? (w.o === 'h' ? 'v' : 'h') : w.o };
},
screenToWall: (a: Pt, o: O): Wall => {
const [x, y] = tf(a.x + 1, a.y + 1);
return { c: Math.round(x) - 1, r: Math.round(y) - 1, o: swap ? (o === 'h' ? 'v' : 'h') : o };
},
goalSide: (seat: Seat): Side => {
const g: Record<Seat, [number, number]> = { S: [4.5, 9], N: [4.5, 0], W: [9, 4.5], E: [0, 4.5] };
const [x, y] = tf(...g[seat]);
return y < 0.5 ? 'top' : y > 8.5 ? 'bottom' : x < 0.5 ? 'left' : 'right';
},
};
}
function overlapsAny(walls: Wall[], w: Wall): boolean {
const has = (o: O, c: number, r: number) => walls.some((x) => x.o === o && x.c === c && x.r === r);
if (w.o === 'h') return has('h', w.c - 1, w.r) || has('h', w.c, w.r) || has('h', w.c + 1, w.r) || has('v', w.c, w.r);
return has('v', w.c, w.r - 1) || has('v', w.c, w.r) || has('v', w.c, w.r + 1) || has('h', w.c, w.r);
}
function wallRect(a: Pt, o: O) {
return o === 'h' ? { x: a.x + 0.05, y: a.y + 1 - G / 2, width: 2 - 0.1, height: G } : { x: a.x + 1 - G / 2, y: a.y + 0.05, width: G, height: 2 - 0.1 };
}
function moveText(m: QuoridorMove, swap = false): string {
if (m.kind === 'move') return `${SEAT_KO[m.seat]} ${label(m.to)}${m.auto ? '(자동)' : ''}`;
if (m.kind === 'wall') return `${SEAT_KO[m.seat]} ${wallText(m.wall, swap)}`;
return `${SEAT_KO[m.seat]} 쉼`;
}
function PawnMark({ seat, cx, cy, r = 0.34, ghost }: { seat: Seat; cx: number; cy: number; r?: number; ghost?: boolean }) {
return (
<g opacity={ghost ? 0.5 : 1}>
{!ghost && <circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={0.25} />}
<circle cx={cx} cy={cy} r={r} fill={SEAT_COLOR[seat]} stroke="#fff" strokeWidth={0.05} />
<text x={cx} y={cy + 0.01} fontSize={r * 0.95} fontWeight={800} fill="#fff" textAnchor="middle" dominantBaseline="central">
{SEAT_KO[seat]}
</text>
</g>
);
}
type Mode = 'move' | 'wall';
function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps<QuoridorView>) {
const me = v.seats.find((x) => x.seat === v.mySeat) ?? null;
const myTurn = v.phase === 'playing' && !!me && v.turn === me.seat && v.legalPawnMoves !== null;
const t = useMemo(() => makeTf(v.mySeat), [v.mySeat]);
const [mode, setMode] = useState<Mode>('move');
const [orient, setOrient] = useState<O>('h');
const [wallAt, setWallAt] = useState<Pt | null>(null); // 화면 앵커(고정된 미리보기)
const [hoverAnchor, setHoverAnchor] = useState<Pt | null>(null);
const [hoverCell, setHoverCell] = useState<Pt | null>(null);
const [movePreview, setMovePreview] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: 4, y: 8 });
const [kbd, setKbd] = useState(false);
const [announce, setAnnounce] = useState('');
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const prevWalls = useRef(v.walls.length);
const [newWall, setNewWall] = useState<string | null>(null);
const legalCells = useMemo(() => {
const m = new Set<string>();
for (const c of v.legalPawnMoves ?? []) {
const p = t.cellToScreen(c);
m.add(`${p.x},${p.y}`);
}
return m;
}, [v.legalPawnMoves, t]);
const legalWalls = useMemo(() => {
const m = new Map<string, QuoridorLegalWall>();
for (const w of v.legalWalls ?? []) m.set(wallKey(w), w);
return m;
}, [v.legalWalls]);
// 새 수: 소리·진동·스크린리더 안내, 미리보기 지우기, 차례가 오면 커서를 내 말로.
useLayoutEffect(() => {
const prev = prevCount.current;
prevCount.current = v.moveCount;
setMovePreview(null);
setWallAt(null);
if (v.walls.length > prevWalls.current) setNewWall(wallKey(v.walls[v.walls.length - 1]!));
else setNewWall(null);
prevWalls.current = v.walls.length;
if (v.moveCount <= prev) return;
const added = v.moves.slice(prev);
const texts = added.map((m) => (m.kind === 'pass' ? `${SEAT_KO[m.seat]}은 움직일 곳이 없어 차례를 넘겼어요.` : `${moveText(m, t.swap)}.`));
for (const m of added) if (m.kind === 'pass' && m.seat === v.mySeat) toast('움직일 곳도 세울 벽도 없어서 차례를 넘겼어요');
setAnnounce(texts.join(' '));
if (added.some((m) => m.kind !== 'pass')) sounds.stone();
if (v.phase === 'playing' && v.mySeat === v.turn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (myTurn && me?.pos) setCursor(t.cellToScreen(me.pos));
if (!myTurn) setMode('move');
}, [myTurn]); // eslint-disable-line react-hooks/exhaustive-deps
const wallsLeft = me?.wallsLeft ?? 0;
const canWall = myTurn && wallsLeft > 0;
/** 화면 앵커·방향의 벽에 대한 판정: 합법이면 정보, 아니면 이유. */
const judge = (a: Pt, o: O): { wall: Wall; legal: QuoridorLegalWall | null; why: string | null } => {
const wall = t.screenToWall(a, o);
if (wallsLeft <= 0) return { wall, legal: null, why: '남은 벽이 없어요.' };
const legal = legalWalls.get(wallKey(wall)) ?? null;
if (legal) return { wall, legal, why: null };
return { wall, legal: null, why: overlapsAny(v.walls, wall) ? '다른 벽과 겹쳐요.' : '누군가의 길을 완전히 막을 수 없어요.' };
};
const doMove = (p: Pt) => {
act({ type: 'move', to: t.screenToCell(p) });
setMovePreview(null);
setHoverCell(null);
};
const doWall = (a: Pt) => {
const j = judge(a, orient);
if (j.why) {
haptic([30, 30, 30]);
setAnnounce(j.why);
toast(j.why);
return;
}
act({ type: 'placeWall', ...j.wall });
setWallAt(null);
setHoverAnchor(null);
};
const refuseCell = (p: Pt) => {
const c = t.screenToCell(p);
const occupied = v.seats.some((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
const msg = occupied ? '말이 있는 칸이에요.' : '그 칸으로는 갈 수 없어요. 밝게 표시된 칸으로 움직일 수 있어요.';
haptic([30, 30, 30]);
setAnnounce(msg);
toast(msg);
};
const tapCell = (p: Pt, mouse: boolean) => {
setCursor(p);
if (!myTurn) return;
if (!legalCells.has(`${p.x},${p.y}`)) {
refuseCell(p);
return;
}
if (mouse || !tapConfirm || (movePreview && movePreview.x === p.x && movePreview.y === p.y)) {
if (!pending) doMove(p);
return;
}
setMovePreview(p);
};
const tapAnchor = (a: Pt) => {
if (!canWall) return;
if (wallAt && wallAt.x === a.x && wallAt.y === a.y) {
setOrient((o) => (o === 'h' ? 'v' : 'h'));
return;
}
setWallAt(a);
setCursor(a);
};
const pointer = (e: React.PointerEvent<SVGSVGElement>): { x: number; y: number } | null => {
const svg = svgRef.current;
if (!svg) return null;
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
return { x: loc.x, y: loc.y };
};
const toCell = (p: { x: number; y: number }): Pt | null => {
const x = Math.floor(p.x);
const y = Math.floor(p.y);
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
};
/** 가장 가까운 교차점으로 스냅. */
const toAnchor = (p: { x: number; y: number }): Pt | null => {
if (p.x < -0.3 || p.y < -0.3 || p.x > N + 0.3 || p.y > N + 0.3) return null;
return { x: Math.min(7, Math.max(0, Math.round(p.x) - 1)), y: Math.min(7, Math.max(0, Math.round(p.y) - 1)) };
};
const setModeTo = (m: Mode) => {
setMode(m);
setMovePreview(null);
setWallAt(null);
if (m === 'wall' && me?.pos) {
const p = t.cellToScreen(me.pos);
// 내 말 바로 위쪽 교차점에서 시작(상대 쪽으로 세우기 쉽게).
setCursor({ x: Math.min(7, Math.max(0, p.x - 1)), y: Math.min(7, Math.max(0, p.y - 2)) });
} else if (m === 'move' && me?.pos) setCursor(t.cellToScreen(me.pos));
setAnnounce(m === 'wall' ? '벽 세우기: 방향키로 자리를 고르고 R로 방향을 바꾼 뒤 Enter로 세워요.' : '말 움직이기: 방향키로 칸을 고르고 Enter로 움직여요.');
};
const onKey = (e: React.KeyboardEvent) => {
setKbd(true);
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const max = mode === 'wall' ? 7 : 8;
if (d[e.key]) {
e.preventDefault();
const next = { x: Math.min(max, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(max, Math.max(0, cursor.y + d[e.key]![1])) };
setCursor(next);
if (mode === 'wall') {
setWallAt(next);
const j = judge(next, orient);
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
} else {
const c = t.screenToCell(next);
const who = v.seats.find((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
setAnnounce(`${label(c)} ${who ? `${SEAT_KO[who.seat]} 말` : legalCells.has(`${next.x},${next.y}`) ? '갈 수 있음' : '빈칸'}`);
}
} else if ((e.key === 'r' || e.key === 'R') && mode === 'wall') {
e.preventDefault();
const o = orient === 'h' ? 'v' : 'h';
setOrient(o);
setWallAt(cursor);
const j = judge(cursor, o);
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
} else if (e.key === 'w' || e.key === 'W') {
e.preventDefault();
if (canWall) setModeTo(mode === 'wall' ? 'move' : 'wall');
} else if (e.key === 'Escape') {
setWallAt(null);
setMovePreview(null);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!myTurn || pending) return;
if (mode === 'wall') doWall(cursor);
else if (legalCells.has(`${cursor.x},${cursor.y}`)) doMove(cursor);
else refuseCell(cursor);
}
};
// 미리보기 대상(고정 > 마우스 올림 > 키보드 커서).
const previewAnchor = mode === 'wall' && canWall ? (wallAt ?? hoverAnchor ?? (kbd ? cursor : null)) : null;
const preview = previewAnchor ? judge(previewAnchor, orient) : null;
const ghostCell = mode === 'move' && myTurn ? (movePreview ?? (hoverCell && legalCells.has(`${hoverCell.x},${hoverCell.y}`) ? hoverCell : null)) : null;
const path = myTurn && v.showPath && v.myShortestPath && me?.pos ? [me.pos, ...v.myShortestPath].map((c) => t.cellToScreen(c)) : null;
const goalSides = v.seats.filter((x) => x.status === 'playing' || (x.status === 'finished' && v.phase === 'finished')).map((x) => ({ seat: x.seat, side: t.goalSide(x.seat) }));
const nick = (id: string) => players[id]?.nickname ?? '알 수 없음';
const lastWall = v.lastMove?.kind === 'wall' ? wallKey(v.lastMove.wall) : null;
const lastTo = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.to) : null;
const lastFrom = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.from) : null;
const myGoal = me ? SIDE_KO[t.goalSide(me.seat)] : null;
const band = (side: Side, k: number) => {
const w = 0.16;
const off = -0.1 - w - k * (w + 0.04);
if (side === 'top') return { x: 0, y: off, width: N, height: w };
if (side === 'bottom') return { x: 0, y: N - off - w, width: N, height: w };
if (side === 'left') return { x: off, y: 0, width: w, height: N };
return { x: N - off - w, y: 0, width: w, height: N };
};
return (
<div className="board-wrap stack" style={{ gap: 10, maxWidth: 'min(100%, calc(100vh - 340px))', minWidth: 'min(100%, 300px)' }}>
<style>{CSS}</style>
<svg
ref={svgRef}
className="qd-board"
viewBox={`${-PAD} ${-PAD} ${N + 2 * PAD} ${N + 2 * PAD}`}
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
data-my-seat={v.mySeat ?? ''}
data-mode={mode}
data-path={path && v.myShortestPath ? v.myShortestPath.map(label).join(' ') : ''}
data-preview={preview ? `${preview.wall.o}:${label(preview.wall)}${preview.why ? ':bad' : ''}` : ''}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || !myTurn) return;
const p = pointer(e);
if (!p) return;
if (mode === 'wall') {
const a = toAnchor(p);
if (a?.x !== hoverAnchor?.x || a?.y !== hoverAnchor?.y) setHoverAnchor(a);
} else {
const c = toCell(p);
if (c?.x !== hoverCell?.x || c?.y !== hoverCell?.y) setHoverCell(c);
}
}}
onPointerLeave={() => {
setHoverAnchor(null);
setHoverCell(null);
}}
onPointerUp={(e) => {
setKbd(false);
const p = pointer(e);
if (!p) return;
if (mode === 'wall') {
const a = toAnchor(p);
if (a) tapAnchor(a);
} else {
const c = toCell(p);
if (c) tapCell(c, e.pointerType === 'mouse');
}
}}
tabIndex={0}
role="grid"
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
onKeyDown={onKey}
>
{goalSides.map((g, k) => {
const sameSide = goalSides.filter((x, j) => j < k && x.side === g.side).length;
return <rect key={`g${g.seat}`} {...band(g.side, sameSide)} rx={0.06} fill={SEAT_COLOR[g.seat]} opacity={0.9} />;
})}
{Array.from({ length: N }, (_, i) => {
const bottom = t.screenToCell({ x: i, y: N - 1 });
const left = t.screenToCell({ x: 0, y: i });
return (
<g key={`l${i}`} fill="var(--wood-line)" fontSize={0.28} fontWeight={700} textAnchor="middle" dominantBaseline="central" aria-hidden>
<text x={i + 0.5} y={N + PAD - 0.2}>
{t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]}
</text>
<text x={-PAD + 0.2} y={i + 0.5}>
{t.swap ? 'abcdefghi'[left.c] : left.r + 1}
</text>
</g>
);
})}
{Array.from({ length: N * N }, (_, i) => {
const x = i % N;
const y = Math.floor(i / N);
const legal = myTurn && mode === 'move' && legalCells.has(`${x},${y}`);
return (
<rect
key={`c${i}`}
x={x + G / 2}
y={y + G / 2}
width={1 - G}
height={1 - G}
rx={0.08}
fill={legal ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.32)'}
stroke={legal ? 'var(--primary)' : 'none'}
strokeWidth={legal ? 0.06 : 0}
data-cell={label(t.screenToCell({ x, y }))}
data-legal={legal ? '1' : undefined}
/>
);
})}
{lastFrom && <rect x={lastFrom.x + G / 2 + 0.05} y={lastFrom.y + G / 2 + 0.05} width={1 - G - 0.1} height={1 - G - 0.1} rx={0.08} fill="none" stroke="var(--wood-line)" strokeWidth={0.04} strokeDasharray="0.1 0.08" />}
{path && path.length > 1 && (
<polyline
points={path.map((p) => `${p.x + 0.5},${p.y + 0.5}`).join(' ')}
fill="none"
stroke={SEAT_COLOR[me!.seat]}
strokeWidth={0.07}
strokeDasharray="0.14 0.12"
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.75}
/>
)}
{myTurn &&
mode === 'move' &&
[...legalCells].map((k) => {
const [x, y] = k.split(',').map(Number) as [number, number];
return <circle key={`d${k}`} cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" opacity={0.8} />;
})}
{v.walls.map((w) => {
const s = t.wallToScreen(w);
const k = wallKey(w);
return (
<rect
key={k}
className={k === newWall ? 'qd-wall-new' : undefined}
{...wallRect(s.a, s.o)}
rx={0.05}
fill="#3b2412"
stroke={k === lastWall ? 'var(--focus)' : '#f3e2c0'}
strokeWidth={k === lastWall ? 0.05 : 0.025}
data-wall={`${w.o}:${label(w)}`}
/>
);
})}
{v.seats.map((x) => {
if (!x.pos) return null;
const p = t.cellToScreen(x.pos);
const turn = v.phase === 'playing' && v.turn === x.seat;
return (
<g key={x.seat} className="qd-pawn" style={{ transform: `translate(${p.x + 0.5}px, ${p.y + 0.5}px)` }} data-pawn={x.seat} data-at={label(x.pos)}>
{turn && <circle className="qd-turn-ring" cx={0} cy={0} r={0.45} fill="none" stroke={SEAT_COLOR[x.seat]} strokeWidth={0.06} />}
<PawnMark seat={x.seat} cx={0} cy={0} />
</g>
);
})}
{lastTo && <circle cx={lastTo.x + 0.5 + 0.27} cy={lastTo.y + 0.5 - 0.27} r={0.08} fill="var(--focus)" stroke="#fff" strokeWidth={0.02} />}
{ghostCell && me && <PawnMark seat={me.seat} cx={ghostCell.x + 0.5} cy={ghostCell.y + 0.5} ghost />}
{preview && previewAnchor && (
<rect
{...wallRect(previewAnchor, orient)}
rx={0.05}
fill={preview.why ? 'rgba(220, 38, 38, 0.6)' : SEAT_COLOR[me!.seat]}
opacity={preview.why ? 1 : 0.6}
stroke={preview.why ? '#7f1d1d' : '#fff'}
strokeWidth={0.04}
strokeDasharray={wallAt ? undefined : '0.12 0.08'}
/>
)}
{myTurn && mode === 'wall' && (
<g aria-hidden>
{Array.from({ length: 64 }, (_, i) => {
const a = { x: i % 8, y: Math.floor(i / 8) };
const w = t.screenToWall(a, 'h');
return <circle key={`a${i}`} cx={a.x + 1} cy={a.y + 1} r={0.09} fill="var(--wood-line)" opacity={0.35} data-anchor={label(w)} />;
})}
</g>
)}
{myTurn && kbd && mode === 'move' && (
<rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.1} fill="none" stroke="var(--focus)" strokeWidth={0.07} />
)}
{myTurn && kbd && mode === 'wall' && <circle cx={cursor.x + 1} cy={cursor.y + 1} r={0.16} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
</svg>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{me && v.phase === 'playing' && me.status === 'playing' && (
<div className="qd-modes" role="group" aria-label="할 일 고르기">
<button type="button" className={`btn ${mode === 'move' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'move'} disabled={!myTurn} onClick={() => setModeTo('move')}>
🚶 말 움직이기
</button>
<button type="button" className={`btn ${mode === 'wall' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'wall'} disabled={!canWall} onClick={() => setModeTo('wall')}>
🧱 벽 세우기 ({wallsLeft}개)
</button>
</div>
)}
{myTurn && mode === 'wall' && (
<div className="qd-tray">
<span className={`qd-hint small${preview?.why ? ' bad' : ''}`} role="status">
{!preview
? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.'
: preview.why
? `${wallText(preview.wall, t.swap)}: ${preview.why}`
: `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
</span>
<button type="button" className="btn btn-secondary qd-rotate" onClick={() => {
const o = orient === 'h' ? 'v' : 'h';
setOrient(o);
}} aria-label={`벽 방향 바꾸기, 지금 ${orient === 'h' ? '가로' : '세로'}`}>
↻ {orient === 'h' ? '가로' : '세로'}
</button>
<button type="button" className="btn btn-primary qd-confirm" disabled={!previewAnchor || !!preview?.why || pending} onClick={() => previewAnchor && doWall(previewAnchor)}>
벽 세우기 확인
</button>
</div>
)}
{myTurn && mode === 'move' && movePreview && (
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(movePreview)}>
{label(t.screenToCell(movePreview))}으로 이동
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMovePreview(null)}>
취소
</button>
</div>
)}
{me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
{me.status === 'playing'
? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.`
: me.status === 'finished'
? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.'
: '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}
</p>
)}
{!me && (
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
function WallBars({ left, total }: { left: number; total: number }) {
return (
<span className="qd-bars" aria-label={`남은 벽 ${left}개`} role="img">
{Array.from({ length: total }, (_, i) => (
<i key={i} className={i < left ? undefined : 'used'} />
))}
</span>
);
}
function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const u = players[x.playerId] ?? { id: x.playerId, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = v.phase === 'playing' && v.turn === x.seat;
const now = useServerNow(500);
const tc = v.timeControl;
let clock: React.ReactNode = null;
if (x.status === 'playing' && tc.kind === 'fischer') {
const ms = Math.max(0, v.remainingMs[x.seat] - (active ? now - v.turnStartedAt : 0));
const s = Math.ceil(ms / 1000);
clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: active && s <= 10 ? 'var(--danger)' : undefined }}>
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
}
const state =
x.status === 'finished'
? `${x.finishOrder}등 도착`
: x.status === 'resigned'
? '기권'
: x.status === 'timedOut'
? '시간패'
: `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`;
return (
<div
className="card row"
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? SEAT_COLOR[x.seat] : 'transparent'}`, opacity: x.status === 'resigned' || x.status === 'timedOut' ? 0.6 : 1 }}
>
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
<PawnMark seat={x.seat} cx={0} cy={0} r={0.45} />
</svg>
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{x.playerId === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{COLOR_KO[x.seat]} · {state}
</div>
</div>
<WallBars left={x.wallsLeft} total={v.wallsPerPlayer} />
<span className="small" style={{ fontWeight: 800, minWidth: '2ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{x.wallsLeft}
</span>
{clock}
</div>
);
}
function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps<QuoridorView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
const mine = v.seats.find((x) => x.seat === v.mySeat);
const live = v.phase === 'playing' && mine?.status === 'playing';
const incoming =
live && v.mySeat
? v.undo.pending && v.undo.pending.by !== v.mySeat && !v.undo.pending.accepted.includes(v.mySeat)
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== v.mySeat && !v.draw.accepted.includes(v.mySeat)
? 'draw'
: null
: null;
const waitingOthers = live && v.mySeat && ((v.undo.pending && (v.undo.pending.by === v.mySeat || v.undo.pending.accepted.includes(v.mySeat))) || (v.draw.pendingBy && (v.draw.pendingBy === v.mySeat || v.draw.accepted.includes(v.mySeat))));
const outgoing = !!v.mySeat && (v.undo.pending?.by === v.mySeat || v.draw.pendingBy === v.mySeat);
const undoLeft = v.mySeat ? v.undo.remaining[v.mySeat] : null;
const requester = v.undo.pending?.by ?? v.draw.pendingBy;
// 내 자리가 맨 아래, 나머지는 차례 순서대로 위에.
const order = mine ? [...v.seats.filter((x) => x.seat !== mine.seat), mine] : v.seats;
const four = v.seats.length === 4;
return (
<>
{order.map((x) => (
<PlayerRow key={x.seat} v={v} x={x} players={players} deadline={deadline} me={me} />
))}
{live && (
<div style={{ display: 'grid', gridTemplateColumns: v.undo.allowed && v.draw.allowed ? '1fr 1fr 1fr' : '1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!requester || undoLeft === 0 || !v.moves.some((m) => m.kind !== 'pass' && m.seat === v.mySeat)} onClick={() => act({ type: 'requestUndo' })} title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!requester} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
)}
{waitingOthers && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">다른 사람의 답을 기다리는 중…</span>
{outgoing && (
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
)}
</div>
)}
<MoveList view={v} />
{incoming && requester && (
<Sheet onClose={() => {}} label="다른 사람의 요청">
<h2>{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}</h2>
<p className="muted">
{incoming === 'undo'
? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}`
: `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
수락
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
거절
</button>
</div>
</Sheet>
)}
{confirm === 'resign' && (
<ConfirmSheet
title="기권할까요?"
body={four ? '기권하면 내 말이 판에서 빠지고, 남은 벽은 사라져요.' : '기권하면 이 판은 상대가 이겨요.'}
confirm="기권하기"
danger
onConfirm={() => act({ type: 'resign' })}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
</>
);
}
function MoveList({ view: v }: { view: QuoridorView }) {
const ref = useRef<HTMLOListElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moveCount]);
if (v.moveCount === 0) return null;
let n = 0;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)</summary>
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{v.moves.map((m, i) => (
<li key={i} className={`small${m.kind === 'pass' ? ' muted' : ''}`}>
{m.kind !== 'pass' && <span className="muted">{++n}. </span>}
<span style={{ color: SEAT_COLOR[m.seat], fontWeight: 800 }} aria-hidden>
●{' '}
</span>
{moveText(m, swapFor(v.mySeat))}
</li>
))}
</ol>
</details>
);
}
function statusText(v: QuoridorView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summaryKo ?? '게임이 끝났어요';
const mine = v.seats.find((x) => x.seat === v.mySeat);
if (mine && mine.status === 'playing') {
if (v.undo.pending && v.undo.pending.by !== mine.seat && !v.undo.pending.accepted.includes(mine.seat)) return '무르기 요청이 왔어요';
if (v.draw.pendingBy && v.draw.pendingBy !== mine.seat && !v.draw.accepted.includes(mine.seat)) return '무승부 제안이 왔어요';
}
if (mine && v.turn === mine.seat && mine.status === 'playing') {
return mine.wallsLeft > 0 ? '내 차례예요. 말을 움직이거나 벽을 세워요' : '내 차례예요. 남은 벽이 없어서 말을 움직여요';
}
const turnSeat = v.seats.find((x) => x.seat === v.turn)!;
const nick = players[turnSeat.playerId]?.nickname ?? SEAT_KO[v.turn];
if (mine?.status === 'finished') return `도착했어요! ${nick}님 차례예요`;
return mine ? `${nick}님(${COLOR_KO[v.turn]})이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={aria}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label: title, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{title}</strong>
{children}
</div>
);
}
const TIME_PRESETS: [string, string, Partial<QuoridorOptions['timeControl']>][] = [
['pm15', '한 수 15초', { kind: 'perMove', perMoveSec: 15 }],
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
['none', '시간 제한 없음', { kind: 'none' }],
];
function timeKey(tc: QuoridorOptions['timeControl']): string {
if (tc.kind === 'none') return 'none';
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
return `f${Math.round(tc.baseSec / 60)}`;
}
function timeLabel(tc: QuoridorOptions['timeControl']): string {
if (tc.kind === 'none') return '시간 제한 없음';
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
const m = Math.floor(tc.baseSec / 60);
const sec = tc.baseSec % 60;
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
}
function Options({ value: o, onChange }: OptionsProps<QuoridorOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<QuoridorOptions>) => onChange({ ...o, ...patch });
const onOff = (v: boolean) => (v ? 'on' : 'off');
const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl));
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="시간">
<Seg
label="시간"
value={timeKey(o.timeControl)}
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(known ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]])]}
onChange={(k) => {
const preset = TIME_PRESETS.find((p) => p[0] === k);
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
}}
/>
</Field>
<Field label="2명일 때 1인당 벽 수">
<Seg
label="2명일 때 1인당 벽 수"
value={o.wallsPerPlayer}
options={[...([10, 8, 6] as const).map((n) => [n, n === 10 ? '10개(기본)' : `${n}개(빠른 게임)`] as [number, string]), ...([10, 8, 6].includes(o.wallsPerPlayer) ? [] : [[o.wallsPerPlayer, `${o.wallsPerPlayer}개`] as [number, string]])]}
onChange={(wallsPerPlayer) => set({ wallsPerPlayer })}
/>
<span className="small muted">4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요.</span>
</Field>
<Field label="내 최단 길 힌트">
<Seg label="내 최단 길 힌트" value={onOff(o.showPath)} options={[['on', '점선으로 보여 주기'], ['off', '안 보여 주기']]} onChange={(k) => set({ showPath: k === 'on' })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="대각선으로 넘어갈 수 있는 때">
<Seg
label="대각선으로 넘어갈 수 있는 때"
value={o.diagonalJumpTrigger}
options={[
['wallEdgePawn', '말 뒤가 벽·판 끝·다른 말'],
['wallOnly', '말 뒤가 벽일 때만'],
]}
onChange={(diagonalJumpTrigger) => set({ diagonalJumpTrigger })}
/>
</Field>
<Field label="시작 자리">
<Seg label="시작 자리" value={o.seatOrder} options={[['random', '무작위'], ['joinOrder', '들어온 순서']]} onChange={(seatOrder) => set({ seatOrder })} />
</Field>
<Field label="4명일 때 1등이 나오면">
<Seg label="4명일 때 1등이 나오면" value={onOff(o.continueForRanking)} options={[['off', '바로 끝내기'], ['on', '계속해서 2·3등 가리기']]} onChange={(k) => set({ continueForRanking: k === 'on' })} />
</Field>
{o.timeControl.kind === 'perMove' && (
<Field label="시간이 다 되면">
<Seg label="시간이 다 되면" value={o.timeoutPolicy} options={[['autoMove', '최단 길로 한 칸 자동 이동'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
</Field>
)}
<Field label="무르기">
<Seg label="무르기" value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</Field>
<Field label="무승부 제안">
<Seg label="무승부 제안" value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: QuoridorOptions): string {
return [timeLabel(o.timeControl), `벽 ${o.wallsPerPlayer}개(2명)`, ...(o.showPath ? ['길 힌트'] : []), ...(o.diagonalJumpTrigger === 'wallOnly' ? ['대각은 벽 뒤만'] : []), ...(o.continueForRanking ? ['순위 끝까지'] : [])].join(' · ');
}
// ---------------------------------------------------------------- rules
/** 규칙 그림: 5×5 조각판. pawns: [열, 행(위=0), 자리], walls: [앵커 열, 앵커 행, 방향], marks: 갈 수 있는 칸. */
function Diagram({ pawns, walls = [], marks = [], caption }: { pawns: [number, number, Seat][]; walls?: [number, number, O, boolean?][]; marks?: [number, number][]; caption: string }) {
const n = 5;
return (
<figure style={{ margin: 0, textAlign: 'center' }}>
<svg viewBox={`-0.1 -0.1 ${n + 0.2} ${n + 0.2}`} style={{ width: '100%', maxWidth: 170, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 8 }} aria-hidden>
{Array.from({ length: n * n }, (_, i) => (
<rect key={i} x={(i % n) + G / 2} y={Math.floor(i / n) + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.35)" />
))}
{marks.map(([x, y]) => (
<g key={`m${x}${y}`}>
<rect x={x + G / 2} y={y + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.92)" stroke="var(--primary)" strokeWidth={0.06} />
<circle cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" />
</g>
))}
{walls.map(([x, y, o, red], i) => (
<rect key={`w${i}`} {...wallRect({ x, y }, o)} rx={0.05} fill={red ? 'rgba(220, 38, 38, 0.7)' : '#3b2412'} stroke={red ? '#7f1d1d' : '#f3e2c0'} strokeWidth={0.03} strokeDasharray={red ? '0.12 0.08' : undefined} />
))}
{pawns.map(([x, y, s]) => (
<PawnMark key={`p${x}${y}`} seat={s} cx={x + 0.5} cy={y + 0.5} />
))}
</svg>
<figcaption className="small muted" style={{ marginTop: 4 }}>
{caption}
</figcaption>
</figure>
);
}
function Rules({ options }: { options?: QuoridorOptions }) {
const wallOnly = options?.diagonalJumpTrigger === 'wallOnly';
return (
<div className="stack">
<p>
9×9 판에서 <strong>내 말을 반대편 끝줄까지 먼저 보내면</strong> 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.
</p>
<p>
내 차례에는 <strong>말을 한 칸 움직이거나</strong>, <strong>벽을 하나 세워요</strong>(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.
</p>
<p>
<strong>말 이동</strong>: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.
</p>
<p>
<strong>뛰어넘기</strong>: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} marks={[[2, 1], [1, 3], [3, 3], [2, 4]]} caption="① 앞의 말을 곧게 뛰어넘기" />
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h']]} marks={[[1, 2], [3, 2], [1, 3], [3, 3], [2, 4]]} caption="② 뒤가 벽이면 대각선으로" />
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h'], [1, 1, 'v']]} marks={[[3, 2], [1, 3], [3, 3], [2, 4]]} caption="③ 한쪽도 벽이면 다른 쪽만" />
</div>
<p>
<strong>벽 세우기</strong>: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 <strong>누구의 길도 완전히 막으면 안 돼요</strong>. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h'], [3, 0, 'v', true]]} caption="④ 길을 완전히 막는 벽은 안 돼요" />
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h']]} marks={[[3, 0]]} caption="⑤ 돌아갈 길이 남으면 괜찮아요" />
</div>
<p>
<strong>세우는 방법</strong>: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).
</p>
<p>
<strong>이기는 방법</strong>: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.
</p>
<p className="muted">팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.</p>
</div>
);
}
const ui: GameUI<QuoridorView, QuoridorOptions> = {
Board: QuoridorBoard,
Side: QuoridorSide,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.mySeat && v.seats.some((x) => x.seat === v.turn && x.playerId === me && x.status === 'playing'),
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -19,6 +19,12 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
bang: () => import('./bang'), bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'), 'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'), marble: () => import('./marble'),
dalmuti: () => import('./dalmuti'),
splendor: () => import('./splendor'),
quoridor: () => import('./quoridor'),
'las-vegas': () => import('./las-vegas'),
'cockroach-poker': () => import('./cockroach-poker'),
'ninety-nine': () => import('./ninety-nine'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,13 @@ export const GAME_ICON: Record<string, string> = {
marble: '🏙️', marble: '🏙️',
bang: '🤠', bang: '🤠',
'halli-galli': '🔔', 'halli-galli': '🔔',
dalmuti: '👑',
splendor: '💎',
quoridor: '🧱',
'las-vegas': '⚅',
'cockroach-poker': '🐞',
'ninety-nine': '💯',
}; };
/** 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

@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p)); const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'games/las-vegas', 'components/cards'].map((p) => join(import.meta.dir, p));
const FORBIDDEN: [RegExp, string][] = [ const FORBIDDEN: [RegExp, string][] = [
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'], [/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'], [/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],

View File

@@ -54,7 +54,7 @@
## M6. 뱅 + 실시간 반응 + 땅따먹기 ## M6. 뱅 + 실시간 반응 + 땅따먹기
- 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기. - 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기.
- **진행 결과(2026-10-05)**: 세 게임 + 할리갈리 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80, - **진행 결과(2026-10-05)**: 세 게임 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인). 게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인).
엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음). 엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음).
뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기. 뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기.
@@ -62,6 +62,12 @@
## M7. 보드게임카페 인기 게임 ## M7. 보드게임카페 인기 게임
- 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구. - 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구.
- **진행 결과(2026-10-05)**: 여섯 게임 규칙 + 화면 완료. 규칙 테스트 달무티 34·보석 상인 42(+무작위 대국 파일)·벽 쌓기 길찾기(쿼리도 방식) 41·
라스베가스 32·바퀴벌레 포커 31·구십구 28, 게임마다 무작위 플레이 1,000판(구성물 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법).
쿼리도는 상표 주의(§12)에 따라 화면 이름을 '벽 쌓기 길찾기'로 표시. 보석 상인 카드 수치는 `splendor/cards.ts`에 따로 두어 교체 가능.
E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인),
라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰).
사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12).
## M8. 파티 게임 ## M8. 파티 게임
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널). - 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).

View File

@@ -260,6 +260,387 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null; return null;
} }
/**
* 달무티: one UI step for `p` — confirm the hand, give tax cards back (suggested cards), or on its turn pick a
* number group (a "광대 필요" group first; leads mix a jester in via the toggle) and press "내기", passing now
* and then. The host uses the keyboard (focus + Enter) for the group. Returns what it did, or null.
*/
async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>): Promise<string | null> {
const enabled = async (l: ReturnType<Page['locator']>) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false));
const confirm = p.getByRole('button', { name: '손패 확인', exact: true });
if (await enabled(confirm)) {
await confirm.click({ timeout: 1500 });
return 'confirm';
}
const giveBack = p.getByRole('button', { name: /^돌려주기/ });
if (await enabled(giveBack)) {
if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) {
st[`taxshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) });
await p.emulateMedia({ colorScheme: 'dark' });
await p.waitForTimeout(400);
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) });
await p.emulateMedia({ colorScheme: 'light' });
}
await giveBack.click({ timeout: 1500 });
return 'return';
}
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return null;
const pass = p.getByRole('button', { name: '패스', exact: true });
const canPass = await enabled(pass);
const legal = p.locator('button.dm-group[data-legal="true"]');
const n = await legal.count();
if (n === 0 || (canPass && st.turns! % 4 === 3)) {
if (!canPass) return null;
await pass.click({ timeout: 1500 });
st.turns!++;
st.passes = (st.passes ?? 0) + 1;
return 'pass';
}
const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' });
const lead = status.includes('새로 시작');
const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]');
let target = legal.last();
if (await needs.count()) target = needs.last();
else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2);
if (isHost) {
await target.focus();
await p.keyboard.press('Enter');
st.hostKeys = (st.hostKeys ?? 0) + 1;
} else await target.click({ timeout: 1500 });
const toggle = p.locator('.dm-toggle');
if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 });
const play = p.getByRole('button', { name: /^내기 \(/ });
if (!(await enabled(play))) return null;
const summary = await p.locator('.dm-pick .small').innerText().catch(() => '');
if (!st[`pickshot-${tag(p)}`]) {
st[`pickshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) });
}
if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1;
await play.click({ timeout: 1500 });
st.turns!++;
st.plays = (st.plays ?? 0) + 1;
return 'play';
}
/**
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
* Returns the kind of action or null when it isn't this page's turn.
*/
async function splendorStep(p: Page, stats: Record<string, number>, turn: number): Promise<string | null> {
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.startsWith('내 차례예요')) return null;
const phone = (p.viewportSize()?.width ?? 0) < 600;
const press = async (l: ReturnType<Page['locator']>) => {
if (phone) await l.tap({ timeout: 2000 });
else if (turn % 2 === 0) {
// Keyboard path on PC every other turn: focus + Enter.
await l.focus({ timeout: 2000 });
await p.keyboard.press('Enter');
} else await l.click({ timeout: 2000 });
};
const panel = p.locator('.sp-panel');
if (status.includes('돌려놓아')) {
const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0);
for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first());
await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` }));
return 'discard';
}
if (status.includes('귀족 중 한 명')) {
await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first());
return 'noble';
}
if (status.includes('넘겨야')) {
await press(panel.getByRole('button', { name: '차례 넘기기' }));
const ok = p.getByRole('button', { name: '넘기기', exact: true });
if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click();
return 'pass';
}
const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold');
const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2;
// The phone player hoards gems early on (no buying) until it has had to give some back once.
const hoard = phone && !stats.discard && turn < 30;
if ((await affordable.count()) && !reserveTurn && !hoard) {
const card = affordable.first();
await press(card);
if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) {
stats.shotPay = 1;
await p.waitForTimeout(250);
await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone });
}
await press(panel.getByRole('button', { name: '🛒 사기' }));
return 'buy';
}
const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ });
if (reserveTurn) {
// Tier 1 open card on even turns, the tier 2 deck (blind) otherwise.
const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck');
await press(target);
if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
await press(panel.getByRole('button', { name: '닫기' }));
}
const tokens = p.locator('.sp-bank button.sp-token:not([disabled])');
const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0)));
const four = counts.findIndex((n) => n >= 4);
if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) {
await press(tokens.nth(four));
await press(tokens.nth(four));
await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' }));
return 'same';
}
const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3);
let picked = 0;
for (let i = 0; i < counts.length && picked < want; i++) {
if (counts[i]! > 0) {
await press(tokens.nth(i));
picked++;
}
}
const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ });
if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) {
await press(takeBtn);
return 'different';
}
// Nothing to take: reserve whatever is first, or buy anything.
await press(p.locator('.sp-market button.sp-card').first());
if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
return null;
}
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
if (tag(p) === 'phone') await target.tap();
else await target.click();
}
async function qdWaitTurn(p: Page) {
await p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 15_000 });
}
/** 벽 세우기 모드에서 앵커를 고르고 방향을 맞춘 뒤 미리보기 값("v:d8" / "h:e7:bad")을 돌려준다. */
async function qdPreviewWall(p: Page, anchor: string, o: 'h' | 'v'): Promise<string> {
await qdWaitTurn(p);
if ((await qdSvg(p).getAttribute('data-mode')) !== 'wall') await qdTap(p, p.getByRole('button', { name: /^🧱 벽 세우기/ }));
await qdTap(p, p.locator(`[data-anchor="${anchor}"]`));
for (let k = 0; k < 3; k++) {
const pv = (await qdSvg(p).getAttribute('data-preview')) ?? '';
if (pv.startsWith(`${o}:${anchor}`)) return pv;
await qdTap(p, p.getByRole('button', { name: /벽 방향 바꾸기/ }));
}
throw new Error(`wall preview did not become ${o}:${anchor}`);
}
async function qdConfirmWall(p: Page) {
const before = await p.locator('[data-wall]').count();
await qdTap(p, p.getByRole('button', { name: '벽 세우기 확인' }));
await p.waitForFunction((n) => document.querySelectorAll('[data-wall]').length > n, before, { timeout: 5000 });
}
/** 내 최단 길(data-path)에서 가장 멀리 가는 이동 가능 칸으로 한 수. 점프였으면 true. */
async function qdStep(p: Page, keyboard: boolean): Promise<boolean> {
await qdWaitTurn(p);
if ((await qdSvg(p).getAttribute('data-mode')) !== 'move') await qdTap(p, p.getByRole('button', { name: /말 움직이기/ }));
const seat = await qdSvg(p).getAttribute('data-my-seat');
const from = (await p.locator(`[data-pawn="${seat}"]`).getAttribute('data-at'))!;
const legal = await p.locator('[data-legal]').evaluateAll((els) => els.map((e) => e.getAttribute('data-cell')!));
const path = ((await qdSvg(p).getAttribute('data-path')) ?? '').split(' ');
let to = legal[0]!;
let best = -1;
for (const c of legal) if (path.indexOf(c) > best) [best, to] = [path.indexOf(c), c];
if (keyboard && to === path[0]) {
// 커서는 내 말에서 시작하고 화면 위쪽이 항상 내 목표 방향.
await keys(p, ['ArrowUp', 'Enter']);
} else {
await qdTap(p, p.locator(`[data-cell="${to}"]`));
const confirm = p.getByRole('button', { name: /으로 이동$/ });
if (await confirm.count()) await qdTap(p, confirm);
}
await p.waitForFunction((t) => document.querySelector(`[data-pawn] [data-at="${t}"], [data-at="${t}"]`) !== null, to, { timeout: 5000 });
const d = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)) + Math.abs(Number(from.slice(1)) - Number(to.slice(1)));
return d === 2;
}
/**
* 라스베가스: one UI step for `p` — press "주사위 굴리기", or pick a number group (every other pick prefers a group
* marked "동점 주의" so duplicate-tie cancellations really happen) and confirm "N번 카지노에 M개 놓기".
* The PC host's first pick uses the keyboard, its second one taps the casino card instead of the group.
*/
async function lasVegasStep(
p: Page,
phone: boolean,
st: { rolls: number; places: number; keyboard: boolean; casinoPick: boolean; shots: Set<string> },
): Promise<boolean> {
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap({ timeout: 1500 }) : l.click({ timeout: 1500 }));
const roll = p.getByRole('button', { name: /^🎲 주사위 굴리기/ });
if ((await roll.count()) && (await roll.isEnabled().catch(() => false))) {
if (!(await press(roll).then(() => true, () => false))) return false;
st.rolls++;
return true;
}
const groups = p.locator('.lv-group:not([disabled])');
if (!(await groups.count())) return false;
try {
const tie = p.locator('.lv-group:not([disabled])[data-standing="tie"]');
const target = st.places % 2 === 1 && (await tie.count()) ? tie.first() : groups.first();
const confirm = p.getByRole('button', { name: /번 카지노에 \d+개 놓기$/ });
if (!phone && !st.keyboard) {
// Keyboard only: Enter on a number group, then Enter on the confirm button.
await target.focus();
await p.keyboard.press('Enter');
await confirm.waitFor({ timeout: 1500 });
await confirm.focus();
await p.keyboard.press('Enter');
st.keyboard = true;
} else if (!phone && !st.casinoPick) {
// Choose by tapping the highlighted casino card instead of the group button.
await p.locator('button.lv-casino').first().click({ timeout: 1500 });
await confirm.waitFor({ timeout: 1500 });
st.casinoPick = true;
await confirm.click({ timeout: 1500 });
} else {
await press(target);
await confirm.waitFor({ timeout: 1500 });
const tag = phone ? 'phone' : 'pc';
if (st.places >= 6 && !st.shots.has(tag)) {
// Mid-game: a number group chosen, casino highlighted with its preview tag, light + dark.
st.shots.add(tag);
if (phone) await p.evaluate(() => window.scrollTo(0, 0));
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}.png`) });
if (phone) await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-full.png`), fullPage: true });
await p.emulateMedia({ colorScheme: 'dark' });
await p.waitForTimeout(250);
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-dark.png`) });
await p.emulateMedia({ colorScheme: 'light' });
}
await press(confirm);
}
st.places++;
await p.waitForFunction(() => !document.querySelector('.lv-group[aria-pressed="true"]'), null, { timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* 바퀴벌레 포커: one UI step for whoever must act on `p` — offer (card → person → claim → confirm), pass after
* peeking (person → claim → confirm) or answer an offer (진짜!/거짓말!, sometimes 몰래 보고 넘기기).
*/
async function cockroachStep(p: Page, stats: Record<string, number>): Promise<string | null> {
const status = await p.locator('.turn-status').innerText().catch(() => '');
if (!status.includes('내 차례예요')) return null;
const panel = p.locator('.cp-panel').first();
const offering = (await panel.getByText('카드를 건네 주세요', { exact: true }).count()) > 0;
if (offering || (await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요', { exact: true }).count())) {
const give = panel.getByRole('button', { name: /(건네기|넘기기)$/ });
const n = (stats.offer ?? 0) + (stats.pass ?? 0);
if (offering) {
const cards = panel.locator('.cp-hand button.cp-pick');
await cards.nth(n % (await cards.count())).click();
}
const targets = panel.locator('button.cp-target:not([disabled])');
await targets.nth(n % (await targets.count())).click();
await panel.locator('button.cp-claim').nth((n * 3) % 8).click();
if (!stats[`shot-give-${tag(p)}`]) {
stats[`shot-give-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `cockroach-poker-give-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
}
await give.click({ timeout: 2000 });
stats[offering ? 'offer' : 'pass'] = (stats[offering ? 'offer' : 'pass'] ?? 0) + 1;
return offering ? 'offer' : 'pass';
}
const yes = panel.getByRole('button', { name: /^진짜!/ });
if (await yes.count()) {
if (!stats[`shot-respond-${tag(p)}`]) {
stats[`shot-respond-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `cockroach-poker-respond-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
}
const peek = panel.getByRole('button', { name: '👁 몰래 보고 넘기기' });
const calls = (stats.true ?? 0) + (stats.false ?? 0);
if ((await peek.isEnabled().catch(() => false)) && calls % 3 === 1) {
await peek.click();
await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요').waitFor({ timeout: 3000 });
stats.peek = (stats.peek ?? 0) + 1;
return 'peek';
}
const which = calls % 2 === 0 ? 'true' : 'false';
await panel.getByRole('button', { name: which === 'true' ? /^진짜!/ : /^거짓말!/ }).click();
stats[which] = (stats[which] ?? 0) + 1;
return which;
}
return null;
}
const nnShots = new Set<string>();
/** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */
async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<boolean> {
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return false;
const total = Number(/합계 (\d+)/.exec(status)?.[1] ?? 0);
const phone = tag(p) === 'phone';
const press = async (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
const cards = p.locator('button.nn-card[data-legal="true"]');
const n = await cards.count();
if (!n) return false;
const info: { i: number; label: string; prev: number }[] = [];
for (let i = 0; i < n; i++) {
const label = (await cards.nth(i).getAttribute('aria-label')) ?? '';
const prev = Number(/→ (\d+)/.exec(await cards.nth(i).locator('.nn-prev').innerText())?.[1] ?? -1);
info.push({ i, label, prev });
}
const joker = info.find((c) => c.label.startsWith('조커'));
const pm = info.find((c) => c.label.startsWith('±'));
const zero = info.find((c) => c.label.startsWith('0 카드'));
const nums = info.filter((c) => c.prev >= 0 && !c.label.startsWith('0 카드')).sort((a, b) => a.prev - b.prev);
if (joker && (total >= 40 || !nums.length)) {
await press(cards.nth(joker.i));
await press(p.getByRole('button', { name: '99', exact: true }));
if (!nnShots.has(`joker-${tag(p)}`)) {
nnShots.add(`joker-${tag(p)}`);
await p.screenshot({ path: join(SHOTS, `ninety-nine-joker-${tag(p)}.png`) });
}
await press(p.getByRole('button', { name: '1 작게' }));
await press(p.getByRole('button', { name: /^조커 내기 → 98$/ }));
stats.joker = (stats.joker ?? 0) + 1;
} else if (pm) {
await press(cards.nth(pm.i));
if (!nnShots.has(`pm-${tag(p)}`)) {
nnShots.add(`pm-${tag(p)}`);
await p.screenshot({ path: join(SHOTS, `ninety-nine-plusminus-${tag(p)}.png`) });
}
const plus = p.locator('.nn-pick button', { hasText: /^\+/ });
const minus = p.locator('.nn-pick button', { hasText: /^−/ });
const usePlus = (await plus.isEnabled()) && (total < 70 || !(await minus.isEnabled()));
await press(usePlus ? plus : minus);
stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1;
} else if (zero && (total >= 60 || !nums.length)) {
await press(cards.nth(zero.i));
await press(cards.nth(zero.i));
stats.reverse = (stats.reverse ?? 0) + 1;
} else {
// Small cards while the total is low (longer game, more cards seen), then the biggest one.
const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!;
const card = cards.nth(pick.i);
if (phone) {
await card.tap();
await card.tap();
} else {
await card.focus();
await p.keyboard.press('Enter');
await p.keyboard.press('Enter');
}
stats.number = (stats.number ?? 0) + 1;
}
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status, { timeout: 5000 }).catch(() => {});
return true;
}
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
rummikub: { rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -689,7 +1070,7 @@ const PLANS: Record<string, Plan> = {
const target = p.locator('button.bg-seat[data-target="ok"]').first(); const target = p.locator('button.bg-seat[data-target="ok"]').first();
await target.waitFor({ timeout: 2000 }); await target.waitFor({ timeout: 2000 });
await target.click(); await target.click();
const fire = p.getByRole('button', { name: /에게 뱅!$/ }); const fire = p.getByRole('button', { name: /님에게 뱅!$/ });
await fire.click(); await fire.click();
stats.bangs++; stats.bangs++;
if (!shot) { if (!shot) {
@@ -879,6 +1260,352 @@ const PLANS: Record<string, Plan> = {
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
}, },
}, },
dalmuti: {
// PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with
// jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen.
players: 4,
lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click();
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/9~12 빼기/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const st: Record<string, number> = { turns: 0 };
let actions = 0;
let midShot = false;
let round2 = false;
const until = Date.now() + 300_000;
const playingAgain = () => host.locator('.dm-table').count();
while (Date.now() < until) {
let acted = false;
for (const [i, p] of pages.entries()) {
if (await dalmutiStep(p, i === 0, st)) {
acted = true;
actions++;
await p.waitForTimeout(120);
break;
}
}
if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) {
midShot = true;
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' });
}
if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) {
round2 = true;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true });
}
if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) {
st.taxDone = 0;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true });
}
// Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen.
if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break;
if (!acted) await host.waitForTimeout(120);
}
if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`);
if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`);
if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') });
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
},
},
splendor: {
// PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a
// colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too),
// return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark.
lobby: async (host) => {
await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^10점 · 한 차례 60초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let turns = 0;
let shot = false;
const until = Date.now() + 480_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted: string | null = null;
for (const p of pages) {
const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
acted = await splendorStep(p, stats, turns);
if (acted) {
stats[acted] = (stats[acted] ?? 0) + 1;
turns++;
// Wait until the server answered (status or log changed).
await p
.waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 })
.catch(() => {});
break;
}
}
if (turns >= 14 && !shot) {
shot = true;
await host.waitForTimeout(700);
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') });
if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`);
console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`);
},
},
quoridor: {
// PC 방장 + 휴대폰 친구(2인, 자리는 무작위). 남: 세로벽 d8 → 북: 세로벽 e8(북이 e8·e9 상자 안) → 남: 가로벽 e7은
// 북의 길을 완전히 막으므로 빨간 미리보기·이유와 함께 거부 → 다른 가로벽 a3. 그 뒤 둘 다 e열 최단 길로 달려
// 마주치면 뛰어넘고, 먼저 끝줄에 닿은 쪽이 이긴다(첫 이동은 키보드 ↑+Enter).
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
await qdSvg(host).waitFor();
await qdSvg(guest).waitFor();
const south = (await qdSvg(host).getAttribute('data-my-seat')) === 'S' ? host : guest;
const north = south === host ? guest : host;
if ((await qdSvg(north).getAttribute('data-my-seat')) !== 'N') throw new Error('seats are not S/N');
await qdPreviewWall(south, 'd8', 'v');
await qdConfirmWall(south);
await qdPreviewWall(north, 'e8', 'v');
await north.waitForTimeout(200);
await north.screenshot({ path: join(SHOTS, `quoridor-wall-preview-${tag(north)}.png`) });
await qdConfirmWall(north);
const bad = await qdPreviewWall(south, 'e7', 'h');
if (!bad.endsWith(':bad')) throw new Error(`blocking wall was not refused (${bad})`);
await south.getByText('누군가의 길을 완전히 막을 수 없어요').first().waitFor();
if (await south.getByRole('button', { name: '벽 세우기 확인' }).isEnabled()) throw new Error('confirm enabled for an illegal wall');
await south.waitForTimeout(300);
await south.screenshot({ path: join(SHOTS, `quoridor-illegal-wall-${tag(south)}.png`) });
if ((await south.locator('[data-wall]').count()) !== 2) throw new Error('illegal wall was placed');
await qdPreviewWall(south, 'a3', 'h');
await qdConfirmWall(south);
let jumps = 0;
let steps = 0;
let shot = false;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
const first = new Set<Page>();
for (let turn = 0; turn < 40 && !(await over()); turn++) {
const mover = turn % 2 === 0 ? north : south;
const useKeys = mover === host && !first.has(mover);
first.add(mover);
if (await qdStep(mover, useKeys)) jumps++;
steps++;
if (steps === 6 && !shot) {
shot = true;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
await host.waitForTimeout(150);
}
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'quoridor-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'quoridor-result-phone.png') });
const summary = await host.getByRole('dialog', { name: '게임 결과' }).innerText();
if (!/승리 — \d+수 만에 도착/.test(summary)) throw new Error(`unexpected result: ${summary.slice(0, 80)}`);
if (jumps < 1) throw new Error('no jump happened');
console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`);
},
},
'las-vegas': {
// PC host + phone guest (2 players → 4 white dice each), 3 rounds via the lobby: real rolls and placements
// through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear.
lobby: async (host) => {
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const st = { rolls: 0, places: 0, keyboard: false, casinoPick: false, shots: new Set<string>() };
let tieSeen = false;
let settleShot = false;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
const until = Date.now() + 300_000;
while (Date.now() < until && !(await over())) {
let acted = false;
for (const [i, p] of pages.entries()) {
if (await lasVegasStep(p, i > 0, st)) {
acted = true;
break;
}
}
if (!settleShot && (await host.locator('section[aria-label="1라운드 정산"]').count())) {
settleShot = true;
await host.waitForTimeout(700);
await host.screenshot({ path: join(SHOTS, 'las-vegas-settle-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'las-vegas-settle-phone.png'), fullPage: true });
}
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`game did not finish (${JSON.stringify({ ...st, shots: [...st.shots] })})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'las-vegas-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'las-vegas-result-phone.png') });
// Close the result sheet to look at the final table on the board.
await host.keyboard.press('Escape');
await host.locator('section[aria-label="최종 결과"]').waitFor();
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc.png') });
await host.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(250);
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
if (!(await host.locator('section[aria-label="3라운드 정산"]').count())) throw new Error('no settlement for round 3');
if (!tieSeen) throw new Error('no duplicate-tie cancellation appeared in any settlement');
if (st.rolls < 10 || st.places < 10 || !st.keyboard || !st.casinoPick) throw new Error(`too few real turns: ${JSON.stringify({ ...st, shots: [...st.shots] })}`);
console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`);
},
},
'cockroach-poker': {
// PC host + 2 phones, "같은 벌레 3장" for a shorter game. Real turns through the UI until someone loses:
// offer a face-down card with a claim, the target calls 진짜!/거짓말! or peeks and passes; the card goes face-up.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
const stats: Record<string, number> = {};
let actions = 0;
let shot = false;
const until = Date.now() + 300_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
const did = await cockroachStep(p, stats);
if (did) {
acted = true;
actions++;
// Wait for the server to move on before looking again.
await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요') || !!document.querySelector('.cp-panel button.cp-target'), null, { timeout: 4000 }).catch(() => {});
await p.waitForTimeout(120);
break;
}
}
if (!shot && (stats.true ?? 0) + (stats.false ?? 0) >= 4 && (await host.locator('.cp-chip').count()) >= 3) {
shot = true;
await host.waitForTimeout(800);
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-dark.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-result-phone.png') });
const calls = (stats.true ?? 0) + (stats.false ?? 0);
if (!stats.offer || calls < 3) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
console.log(` cockroach-poker: finished after ${actions} UI actions ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([k]) => !k.startsWith('shot'))))}`);
},
},
'ninety-nine': {
// PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the
// game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction,
// otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click();
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
const stats: Record<string, number> = {};
let turns = 0;
let shot = false;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
const until = Date.now() + 240_000;
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
if (await ninetyNineStep(p, stats)) {
acted = true;
turns++;
break;
}
}
if (!shot && turns >= 5) {
shot = true;
await host.waitForTimeout(500);
await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${turns} turns: ${JSON.stringify(stats)})`);
const outs = await host.locator('.nn-log li', { hasText: '탈락했어요' }).count();
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') });
const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0);
if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`);
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
},
},
}; };
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

View File

@@ -0,0 +1,604 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
KINDS,
allCardIds,
autoAction,
cockroachPoker as game,
countKinds,
handSizes,
kindOf,
type CockroachAction,
type CockroachOptions,
type CockroachState,
type CockroachView,
type Kind,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000;
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
function setup(opts: Partial<CockroachOptions> = {}, players = ['A', 'B', 'C', 'D'], seed = 1): CockroachState {
return game.setup({ players, options: game.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/**
* A game in progress with the given hands / fronts (as kinds; ids are assigned in order) and `starter` to offer.
* Unused cards are dropped from play (tests only care about the listed ones).
*/
function rigged(hands: Record<string, Kind[]>, opts: Partial<CockroachOptions> = {}, fronts: Record<string, Kind[]> = {}, starter?: string): CockroachState {
const players = Object.keys(hands);
const s = setup(opts, players);
const next: Record<Kind, number> = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
const take = (k: Kind) => `${k}-${next[k]++}`;
for (const p of players) {
s.hands[p] = hands[p]!.map(take);
s.fronts[p] = (fronts[p] ?? []).map(take);
}
s.removed = [];
s.round = { no: 1, starter: starter ?? players[0]!, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] };
return s;
}
function actE(s: CockroachState, p: string, a: CockroachAction): { state: CockroachState; events: GameEvent[] } {
const v = game.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return game.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: CockroachState, p: string, a: CockroachAction) => actE(s, p, a).state;
const reject = (s: CockroachState, p: string, a: CockroachAction) => {
const v = game.validate(s, p, a);
return v.ok ? null : v.reason;
};
const cardOf = (s: CockroachState, p: string, k: Kind) => s.hands[p]!.find((id) => kindOf(id) === k)!;
const offer = (s: CockroachState, from: string, k: Kind, to: string, claim: Kind) => act(s, from, { type: 'offer', cardId: cardOf(s, from, k), to, claim });
const frontKinds = (s: CockroachState, p: string) => s.fronts[p]!.map(kindOf);
/** Every card exists exactly once across hands, fronts, the removed pile and the passed card. */
function checkInvariants(st: unknown, total = 64): void {
const s = st as CockroachState;
const all = [...Object.values(s.hands).flat(), ...Object.values(s.fronts).flat(), ...s.removed, ...(s.round.card ? [s.round.card] : [])];
if (all.length !== total || new Set(all).size !== total) throw new Error(`cards not conserved: ${all.length}/${total}`);
if (s.final === null) {
for (const p of s.order) {
const c = countKinds(s.fronts[p]!);
if (KINDS.some((k) => c[k] >= s.loseCount)) throw new Error(`${p} should have lost`);
}
const r = s.round;
if ((r.card === null) !== (r.holder === null)) throw new Error('card/holder mismatch');
if (r.holder !== null && !r.seen.includes(r.starter)) throw new Error('starter not in seen');
}
}
/** Structural hidden-info check for one view (beyond the id-substring leak test). */
function checkViewShape(s: CockroachState, v: CockroachView, viewer: string | null): void {
const over = s.final !== null;
for (const p of s.order) expect(v.handCounts[p]).toBe(s.hands[p]!.length);
if (viewer === null) expect(v.myHand).toBeNull();
const json = JSON.stringify(v);
if (!over) {
expect(v.revealed).toBeNull();
for (const id of s.removed) if (json.includes(JSON.stringify(id))) throw new Error(`removed card ${id} leaks`);
for (const p of s.order) if (p !== viewer) for (const id of s.hands[p]!) if (json.includes(JSON.stringify(id))) throw new Error(`hand card ${id} of ${p} leaks to ${viewer}`);
const knows = viewer !== null && s.round.seen.includes(viewer);
if (s.round.card) {
if (knows) expect(v.card).toEqual({ known: true, id: s.round.card, kind: kindOf(s.round.card) });
else {
expect(v.card).toEqual({ known: false });
if (json.includes(JSON.stringify(s.round.card))) throw new Error(`passed card leaks to ${viewer}`);
}
}
}
}
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('인원별 손패 장수가 표와 일치(첫 플레이어부터), 2인은 10장 제거 후 27장씩, 카드 총합 보존', () => {
const table: Record<number, number[]> = { 2: [27, 27], 3: [22, 21, 21], 4: [16, 16, 16, 16], 5: [13, 13, 13, 13, 12], 6: [11, 11, 11, 11, 10, 10] };
for (let n = 2; n <= 6; n++) {
expect(handSizes(n)).toEqual(table[n]!);
for (let seed = 1; seed <= 10; seed++) {
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const s = setup({}, players, seed);
const fi = players.indexOf(s.round.starter);
const sizes = players.map((_, k) => s.hands[players[(fi + k) % n]!]!.length);
expect(sizes).toEqual(table[n]!);
expect(s.removed.length).toBe(n === 2 ? 10 : 0);
expect(s.loseCount).toBe(n === 2 ? 5 : 4);
checkInvariants(s);
for (const p of players) expect(s.fronts[p]).toEqual([]);
}
}
expect(allCardIds().length).toBe(64);
for (const k of KINDS) expect(allCardIds().filter((id) => kindOf(id) === k).length).toBe(8);
});
test('같은 시드 = 같은 셋업; 첫 시작자는 시드에 따라 달라짐', () => {
expect(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))).toBe(JSON.stringify(setup({}, ['A', 'B', 'C'], 5)));
const starters = new Set(Array.from({ length: 30 }, (_, i) => setup({}, ['A', 'B', 'C'], i + 1).round.starter));
expect(starters.size).toBe(3);
});
test('loseCount 옵션: 자동(2인 5, 3~6인 4) 또는 지정값', () => {
expect(setup({ loseCount: 3 }, ['A', 'B']).loseCount).toBe(3);
expect(setup({ loseCount: 5 }, ['A', 'B', 'C']).loseCount).toBe(5);
expect(setup({}, ['A', 'B', 'C', 'D', 'E', 'F']).loseCount).toBe(4);
});
});
// ---------------------------------------------------------------- rules
describe('판정 (§5.2, §5.3)', () => {
test('진실 주장에 "진짜" → 전달자 앞마당; 받은 사람이 다음 시작자', () => {
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
const out = actE(s, 'B', { type: 'call', call: 'true' });
s = out.state;
expect(frontKinds(s, 'A')).toEqual(['rat']);
expect(frontKinds(s, 'B')).toEqual([]);
expect(s.round.starter).toBe('A');
expect(s.round.no).toBe(2);
expect(s.lastReveal).toMatchObject({ kind: 'rat', claim: 'rat', call: 'true', caller: 'B', correct: true, takenBy: 'A' });
expect(out.events.find((e) => e.type === 'reveal')).toMatchObject({ kind: 'rat', correct: true, takenBy: 'A' });
checkInvariants(s, 4);
});
test('거짓 주장에 "거짓" → 전달자; 거짓 주장에 "진짜" → 수신자; 진실 주장에 "거짓" → 수신자', () => {
let s = rigged({ A: ['rat', 'rat'], B: ['bat', 'bat'], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'fly');
s = act(s, 'B', { type: 'call', call: 'false' });
expect(frontKinds(s, 'A')).toEqual(['rat']);
expect(s.round.starter).toBe('A');
s = offer(s, 'A', 'rat', 'C', 'fly');
s = act(s, 'C', { type: 'call', call: 'true' });
expect(frontKinds(s, 'C')).toEqual(['rat']);
expect(s.round.starter).toBe('C');
s = offer(s, 'C', 'toad', 'B', 'toad');
s = act(s, 'B', { type: 'call', call: 'false' });
expect(frontKinds(s, 'B')).toEqual(['toad']);
expect(s.round.starter).toBe('B');
});
test('§5.4 예시: A가 거미를 "파리"로 B에게 → B가 보고 C에게 "거미"(진실) → C "거짓" → C 앞마당, C가 다음 시작자', () => {
let s = rigged({ A: ['spider', 'rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
s = offer(s, 'A', 'spider', 'B', 'fly');
s = act(s, 'B', { type: 'peek' });
expect(s.round.seen).toEqual(['A', 'B']);
expect(game.view(s, 'B').card).toEqual({ known: true, id: 'spider-0', kind: 'spider' });
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'spider' });
expect([s.round.holder, s.round.lastPasser, s.round.claim]).toEqual(['C', 'B', 'spider']);
const alt = s;
s = act(s, 'C', { type: 'call', call: 'false' });
expect(frontKinds(s, 'C')).toEqual(['spider']);
expect(s.round.starter).toBe('C');
// C could instead peek and pass only to D; D then has nobody left and must call.
let t = act(alt, 'C', { type: 'peek' });
expect(game.view(t, 'C').my!.passTargets).toEqual(['D']);
expect(reject(t, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
t = act(t, 'C', { type: 'pass', to: 'D', claim: 'bat' });
expect(reject(t, 'D', { type: 'peek' })).toContain('넘길 사람이 없어요');
expect(game.view(t, 'D').my).toMatchObject({ canCall: true, canPeek: false });
expect(game.view(t, 'D').my!.peekBlocked).toContain('넘길 사람이 없어요');
t = act(t, 'D', { type: 'call', call: 'false' }); // claim "bat", card spider → lie → D correct → C takes it
expect(frontKinds(t, 'C')).toEqual(['spider']);
});
});
describe('떠넘기기 거부 (§5.2, §8.2)', () => {
test('seen에 있는 사람(시작자 포함)에게 pass 거부, 본인에게도 거부', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
s = act(s, 'B', { type: 'peek' });
expect(reject(s, 'B', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
expect(reject(s, 'B', { type: 'pass', to: 'B', claim: 'rat' })).toBe('나에게는 넘길 수 없어요.');
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' });
s = act(s, 'C', { type: 'peek' });
expect(reject(s, 'C', { type: 'pass', to: 'B', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
expect(reject(s, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
expect(reject(s, 'C', { type: 'pass', to: 'D', claim: 'rat' })).toBeNull();
});
test('나머지 전원이 본 상태의 holder는 peek 거부, call만 가능', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
s = act(s, 'B', { type: 'peek' });
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'fly' });
expect(reject(s, 'C', { type: 'peek' })).toBe('모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.');
expect(game.legalActions!(s, 'C')).toEqual([
{ type: 'call', call: 'true' },
{ type: 'call', call: 'false' },
]);
});
test('peek 후 call 거부; peek 전 pass 거부; 두 번 peek 거부', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
expect(reject(s, 'B', { type: 'pass', to: 'C', claim: 'rat' })).toBe('먼저 카드를 몰래 봐야 넘길 수 있어요.');
s = act(s, 'B', { type: 'peek' });
expect(reject(s, 'B', { type: 'call', call: 'true' })).toBe('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.');
expect(reject(s, 'B', { type: 'peek' })).toBe('이미 카드를 봤어요.');
expect(game.legalActions!(s, 'B').every((a) => a.type === 'pass')).toBe(true);
});
test('차례가 아닌 사람·잘못된 offer 거부', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
expect(reject(s, 'B', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('내 손에 없는 카드예요.');
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'A', claim: 'bat' })).toBe('나에게는 건넬 수 없어요.');
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'Z', claim: 'bat' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'A', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.');
expect(reject(s, 'Z', { type: 'peek' })).toBe('이 게임의 플레이어가 아니에요.');
s = offer(s, 'A', 'rat', 'B', 'rat');
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'C', claim: 'bat' })).toBe('지금은 카드를 건넬 때가 아니에요.');
expect(reject(s, 'C', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.');
expect(reject(s, 'C', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
});
});
describe('2인 규칙 (§5.2, §6)', () => {
test('peek 거부(항상 판정), 같은 종류 5장에서 패배, 4장에서는 계속', () => {
let s = rigged({ A: ['rat', 'rat', 'bat'], B: ['fly', 'fly'] }, {}, { A: ['rat', 'rat', 'rat'] });
expect(s.loseCount).toBe(5);
s = offer(s, 'A', 'rat', 'B', 'rat');
expect(reject(s, 'B', { type: 'peek' })).toBe('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.');
expect(game.view(s, 'B').my).toMatchObject({ canPeek: false, canCall: true });
s = act(s, 'B', { type: 'call', call: 'true' }); // A takes 4th rat → continues
expect(frontKinds(s, 'A').filter((k) => k === 'rat').length).toBe(4);
expect(s.final).toBeNull();
s = offer(s, 'A', 'rat', 'B', 'bat');
s = act(s, 'B', { type: 'call', call: 'false' }); // 5th rat → A loses
expect(s.final).toMatchObject({ loser: 'A', reason: 'four', losingKind: 'rat' });
expect(game.result(s)).toMatchObject({ ranking: [['B'], ['A']], scores: { A: 0, B: 1 }, reason: 'normal' });
});
test('twoPlayerPass: 한 번만 보고 되돌려 줄 수 있고, 되돌려 받은 사람은 판정만', () => {
let s = rigged({ A: ['rat', 'bat'], B: ['fly'] }, { twoPlayerPass: true });
s = offer(s, 'A', 'rat', 'B', 'bat');
expect(game.view(s, 'B').my!.canPeek).toBe(true);
s = act(s, 'B', { type: 'peek' });
expect(game.view(s, 'B').my!.passTargets).toEqual(['A']);
s = act(s, 'B', { type: 'pass', to: 'A', claim: 'rat' });
expect(reject(s, 'A', { type: 'peek' })).toContain('되돌려 받은 카드');
s = act(s, 'A', { type: 'call', call: 'true' });
expect(frontKinds(s, 'B')).toEqual(['rat']);
expect(s.round.starter).toBe('B');
});
});
describe('패배 조건 (§6)', () => {
test('같은 종류 4장 도달 즉시 종료, 다른 종류 4장(합계)은 무관', () => {
let s = rigged({ A: ['rat', 'rat'], B: ['fly'], C: ['toad'] }, {}, { B: ['bat', 'fly', 'toad'], C: ['rat', 'rat', 'rat'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes rat: 4 different kinds
expect(s.final).toBeNull();
expect(s.fronts.B!.length).toBe(4);
s = offer(s, 'B', 'fly', 'C', 'rat');
const out = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes fly: still 3 rats + 1 fly
expect(out.state.final).toBeNull();
s = offer(out.state, 'C', 'toad', 'A', 'toad');
s = act(s, 'A', { type: 'call', call: 'false' }); // wrong → A takes toad
s = offer(s, 'A', 'rat', 'C', 'bat');
const end = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes 4th rat
expect(end.state.final).toMatchObject({ loser: 'C', reason: 'four', losingKind: 'rat' });
expect(end.events.some((e) => e.type === 'gameEnded')).toBe(true);
expect(game.activePlayers(end.state)).toEqual([]);
expect(game.deadline!(end.state)).toBeNull();
const res = game.result(end.state)!;
expect(res.ranking).toEqual([['A', 'B'], ['C']]);
expect(res.summary).toContain('쥐 4장');
// Detailed placings: A (max 1, total 2) before B (max 1, total 4); loser last.
expect(end.state.final!.placings).toEqual([['A'], ['B'], ['C']]);
expect(reject(end.state, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' })).toBe('게임이 이미 끝났어요.');
});
test('loseCount=3 옵션', () => {
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { loseCount: 3 }, { B: ['rat', 'rat'] });
s = offer(s, 'A', 'rat', 'B', 'fly');
s = act(s, 'B', { type: 'call', call: 'true' });
expect(s.final).toMatchObject({ loser: 'B', reason: 'four', losingKind: 'rat' });
});
test('다음 시작자의 손패가 0장 → 즉시 패배(reason emptyHand)', () => {
let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes it and must start with 0 cards
expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' });
expect(game.result(s)!.ranking[1]).toEqual(['B']);
});
test('손패 0장인 사람에게 건네기 허용, 그가 떠넘기기/판정 정상 동작', () => {
let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'], D: ['bat'] });
expect(game.view(s, 'A').my!.offerTargets).toEqual(['B', 'C', 'D']);
s = offer(s, 'A', 'rat', 'B', 'bat');
s = act(s, 'B', { type: 'peek' });
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' });
s = act(s, 'C', { type: 'call', call: 'true' }); // true claim → C right → B takes it, then B starts with 0 cards
expect(frontKinds(s, 'B')).toEqual(['rat']);
expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' });
// Calling correctly with an empty hand is fine: the passer takes the card.
let t = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] });
t = offer(t, 'A', 'rat', 'B', 'rat');
t = act(t, 'B', { type: 'call', call: 'true' });
expect(t.final).toBeNull();
expect(t.round.starter).toBe('A');
});
test('eightKindsRule: 8종이 모이면 패배; 끄면 계속', () => {
const fr = { B: ['cockroach', 'bat', 'fly', 'toad', 'scorpion', 'spider', 'stinkbug'] as Kind[] };
for (const on of [true, false]) {
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { eightKindsRule: on }, fr);
s = offer(s, 'A', 'rat', 'B', 'fly');
s = act(s, 'B', { type: 'call', call: 'true' });
if (on) expect(s.final).toMatchObject({ loser: 'B', reason: 'eight' });
else expect(s.final).toBeNull();
}
});
});
// ---------------------------------------------------------------- timers
describe('시간 초과 (§8.5)', () => {
test('시작자 무응답 → 자동 offer(다음 좌석, 손패 1장, 무작위 주장)', () => {
const s = rigged({ A: ['rat', 'fly', 'bat'], B: ['bat'], C: ['toad'] });
expect(game.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
const out = actE(s, 'A', { type: 'timeout' });
const t = out.state;
expect(t.round.holder).toBe('B');
expect(t.round.claims[0]).toMatchObject({ from: 'A', to: 'B', auto: true });
expect(t.hands.A!.length).toBe(2);
expect(out.events.map((e) => e.type)).toEqual(['timeout', 'offer']);
expect(t.timeoutsInRow.A).toBe(1);
});
test('holder 무응답 → 자동 call; peek 후 무응답 → 자동 pass(seen 밖 첫 좌석, 실제 종류로)', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
s = offer(s, 'A', 'rat', 'B', 'fly');
const called = act(s, 'B', { type: 'timeout' });
expect(called.lastReveal).toMatchObject({ caller: 'B', auto: true });
expect(called.round.no).toBe(2);
s = act(s, 'B', { type: 'peek' });
const passed = actE(s, 'B', { type: 'timeout' });
expect(passed.state.round).toMatchObject({ holder: 'C', lastPasser: 'B', claim: 'rat', peeked: false });
expect(passed.state.round.claims[1]).toMatchObject({ from: 'B', to: 'C', claim: 'rat', auto: true });
// C peeks; first unseen seat clockwise from C is D.
let t = act(passed.state, 'C', { type: 'peek' });
t = act(t, 'C', { type: 'timeout' });
expect(t.round.holder).toBe('D');
expect(autoAction(t, 'D', rng()).type).toBe('call');
});
test('3번 연속 시간 초과 → 자리 비움(마감 5초), 직접 행동하면 해제', () => {
const t0 = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { turnSeconds: 30 });
let t = act({ ...t0, timeoutsInRow: { A: 1, B: 0, C: 0 } }, 'A', { type: 'timeout' });
expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 2]);
const third = actE({ ...t0, timeoutsInRow: { A: 2, B: 0, C: 0 } }, 'A', { type: 'timeout' });
expect(third.state.away.A).toBe(true);
expect(third.events.map((e) => e.type)).toEqual(['timeout', 'away', 'offer']);
t = third.state;
expect(game.view(t, null).away.A).toBe(true);
const w = { ...t0, away: { A: true, B: false, C: false } };
expect(game.deadline!(w)! - w.turnStartedAt).toBe(5000);
expect(game.view(w, 'A').turnMs).toBe(5000);
const back = act(w, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' });
expect(back.away.A).toBe(false);
expect(back.timeoutsInRow.A).toBe(0);
expect(game.deadline!(back)! - back.turnStartedAt).toBe(30_000);
});
test('모두 무응답이어도 게임이 끝남; 매 상태에서 onTimeout이 합법', () => {
for (let seed = 1; seed <= 40; seed++) {
const n = 2 + (seed % 5);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
let s = setup({ twoPlayerPass: seed % 2 === 0 }, players, seed);
const r = SeededRng.fromSeed(seedN(seed));
let step = 0;
for (; step < 2000 && !s.final; step++) {
const p = game.timeoutPlayers!(s)[0]!;
expect(game.validate(s, p, game.onTimeout(s, p))).toEqual({ ok: true });
s = game.apply(s, p, game.onTimeout(s, p), { rng: r, now: step }).state;
checkInvariants(s);
}
expect(s.final).not.toBeNull();
}
});
});
describe('나가기·빠른 말', () => {
test('나가면 그 사람이 패배하고 나머지 모두 승리(reason resign)', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
s = offer(s, 'A', 'rat', 'B', 'rat');
expect(game.onLeave!(s, 'C')).toEqual({ type: 'leave' });
expect(game.onLeave!(s, 'Z')).toBeNull();
s = act(s, 'C', { type: 'leave' });
expect(s.final).toMatchObject({ loser: 'C', reason: 'left' });
expect(game.result(s)).toMatchObject({ ranking: [['A', 'B'], ['C']], reason: 'resign' });
expect(game.onLeave!(s, 'A')).toBeNull();
expect(game.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
});
test('빠른 말: 라운드당 3번, 옵션을 끄면 거부, 차례 시계는 그대로', () => {
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] });
const started = s.turnStartedAt;
for (let i = 0; i < 3; i++) s = act(s, 'C', { type: 'say', phrase: 'doubt' });
expect(s.turnStartedAt).toBe(started);
expect(reject(s, 'C', { type: 'say', phrase: 'doubt' })).toContain('3번까지');
expect(game.view(s, 'C').said.length).toBe(3);
expect(game.view(s, 'C').my!.canSay).toBe(false);
s = offer(s, 'A', 'rat', 'B', 'rat');
s = act(s, 'B', { type: 'call', call: 'true' }); // next round resets the count
expect(reject(s, 'C', { type: 'say', phrase: 'really' })).toBeNull();
const off = rigged({ A: ['rat'], B: ['bat'] }, { quickChat: false });
expect(reject(off, 'A', { type: 'say', phrase: 'really' })).toBe('이 방은 빠른 말을 쓰지 않아요.');
});
});
// ---------------------------------------------------------------- info
describe('비공개 정보 (§8.3)', () => {
test('미확인 holder·관전자·다른 플레이어 view에 건네는 카드가 없고, 본 사람에게만 보임', () => {
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'], D: ['fly'] });
s = offer(s, 'A', 'rat', 'B', 'fly');
expect(game.view(s, 'A').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' });
for (const v of ['B', 'C', 'D', null]) {
expect(game.view(s, v).card).toEqual({ known: false });
expect(JSON.stringify(game.view(s, v))).not.toContain('"rat-0"');
}
s = act(s, 'B', { type: 'peek' });
expect(game.view(s, 'B').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' });
expect(game.view(s, 'C').card).toEqual({ known: false });
expect(game.view(s, null).round.seen).toEqual(['A', 'B']);
expect(game.view(s, 'C').myHand).toEqual([{ kind: 'toad', ids: ['toad-0'] }]);
expect(JSON.stringify(game.view(s, 'C'))).not.toContain('"fly-0"');
expect(game.view(s, null).myHand).toBeNull();
expect(game.view(s, null).my).toBeNull();
});
test('2인 제거 카드는 진행 중 비공개, 종료 후 전체 손패와 함께 공개', () => {
let s = setup({}, ['A', 'B'], 3);
for (const v of ['A', 'B', null]) {
const j = JSON.stringify(game.view(s, v));
for (const id of s.removed) expect(j).not.toContain(JSON.stringify(id));
expect(game.view(s, v).removedCount).toBe(10);
}
s = act(s, 'A', { type: 'leave' });
const v = game.view(s, null);
expect(v.revealed!.removed).toEqual(s.removed);
expect(v.revealed!.hands.B).toEqual(s.hands.B!);
expect(game.secretsOf!(s, 'A')).toEqual([]);
});
test('claimHistory=off: 주장 기록은 숨기고 현재 주장만', () => {
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }, { claimHistory: false });
s = offer(s, 'A', 'rat', 'B', 'fly');
const v = game.view(s, 'C');
expect(v.round.claims).toEqual([]);
expect(v.round.claim).toBe('fly');
expect(game.view(rigged({ A: ['rat'], B: ['bat'] }), 'A').round.claims).toEqual([]);
});
test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => {
for (let seed = 1; seed <= 150; seed++) {
const n = 2 + (seed % 5);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const r = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 77));
let s = setup({ twoPlayerPass: seed % 3 === 0, claimHistory: seed % 4 !== 0 }, players, seed);
for (let step = 0; step < 600 && !s.final; step++) {
for (const viewer of [...players, null]) checkViewShape(s, game.view(s, viewer), viewer);
const actor = game.activePlayers(s)[0]!;
const legal = game.legalActions!(s, actor);
const a = chooser.int(5) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const out = game.apply(s, actor, a, { rng: r, now: step * 1000 });
if (!out.state.final) {
const hidden = [...Object.values(out.state.hands).flat(), ...out.state.removed, ...(out.state.round.card ? [out.state.round.card] : [])];
for (const e of out.events) {
const ej = JSON.stringify(e);
for (const id of hidden) if (ej.includes(JSON.stringify(id))) throw new Error(`event ${e.type} leaks ${id}`);
}
}
s = out.state;
}
expect(s.final).not.toBeNull();
}
}, 30_000);
});
// ---------------------------------------------------------------- schema
describe('옵션·스키마', () => {
test('모든 옵션에 한국어 메타; 기본값이 문서와 같음', () => {
const shape = (game.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(game.defaultOptions).toEqual({ loseCount: 'auto', turnSeconds: 30, claimHistory: true, quickChat: true, eightKindsRule: false, twoPlayerPass: false });
expect(game.optionsSchema.safeParse({ loseCount: 6 }).success).toBe(false);
expect(game.optionsSchema.safeParse({ turnSeconds: 20 }).success).toBe(false);
});
test('클라이언트 행동만 스키마에 있음', () => {
expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }).success).toBe(true);
expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'dragon' }).success).toBe(false);
expect(game.actionSchema.safeParse({ type: 'call', call: true }).success).toBe(false);
expect(game.actionSchema.safeParse({ type: 'peek' }).success).toBe(true);
expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
});
});
// ---------------------------------------------------------------- properties
function cfg(i: number): { players: string[]; options: CockroachOptions } {
return {
players: Array.from({ length: 2 + (i % 5) }, (_, k) => `P${k}`),
options: game.optionsSchema.parse({
loseCount: (['auto', 3, 4, 5] as const)[i % 4],
claimHistory: i % 3 !== 0,
eightKindsRule: i % 7 === 0,
twoPlayerPass: i % 2 === 0,
turnSeconds: ([15, 30, 60] as const)[i % 3],
}),
};
}
function invariant(st: unknown): void {
checkInvariants(st);
const s = st as CockroachState;
for (const p of game.timeoutPlayers!(s)) {
const v = game.validate(s, p, game.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
const c = game.validate(s, p, autoAction(s, p, rng()));
if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`);
}
}
describe('무작위 대국 (docs/09 §7)', () => {
test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => {
for (let i = 1; i <= 200; i++) {
const { players, options } = cfg(i);
const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 });
expect(r.finished).toBe(true);
const res = game.result(r.finalState as CockroachState)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
expect(res.ranking[1]!.length).toBe(1);
if (i % 40 === 1) {
const b = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, maxApplyMs: 50 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState));
expect(b.trace).toEqual(r.trace);
}
}
}, 60_000);
test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => {
let maxSteps = 0;
for (let i = 201; i <= 1000; i++) {
const { players, options } = cfg(i);
const r = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 5000));
let s = game.setup({ players, options, rng: r, now: 0 });
const log: { actor: string; action: CockroachAction }[] = [];
let step = 0;
for (; step < 2000 && !s.final; step++) {
const actor = game.activePlayers(s)[0]!;
const legal = game.legalActions!(s, actor);
const a = chooser.int(10) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const v = game.validate(s, actor, a);
if (!v.ok) throw new Error(`seed ${i}: ${v.reason}`);
s = game.apply(s, actor, a, { rng: r, now: step * 1000 }).state;
log.push({ actor, action: a });
checkInvariants(s);
}
maxSteps = Math.max(maxSteps, step);
expect(s.final).not.toBeNull();
expect(JSON.parse(JSON.stringify(s))).toEqual(s);
if (i % 50 === 0) {
// Replay: same seed + same action log → identical state.
const r2 = SeededRng.fromSeed(seedN(i));
let t = game.setup({ players, options, rng: r2, now: 0 });
log.forEach(({ actor, action }, k) => (t = game.apply(t, actor, action, { rng: r2, now: k * 1000 }).state));
expect(JSON.stringify(t)).toBe(JSON.stringify(s));
}
}
expect(maxSteps).toBeLessThan(2000);
}, 60_000);
});

View File

@@ -0,0 +1,650 @@
/**
* 바퀴벌레 포커 (docs/games/cockroach-poker.md).
*
* Secret-id design: card ids ('rat-3', ...) encode the kind, so an id only ever appears in a view when the
* viewer may know that card (own hand, face-up fronts, the passed card for players in `seen`, everything
* after the game ends). Other hands are sent as counts; the passed card as `{ known: false }`.
*/
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
export const KINDS = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug'] as const;
export type Kind = (typeof KINDS)[number];
export const KIND_KO: Record<Kind, string> = {
cockroach: '바퀴벌레',
bat: '박쥐',
fly: '파리',
toad: '두꺼비',
rat: '쥐',
scorpion: '전갈',
spider: '거미',
stinkbug: '노린재',
};
export const PER_KIND = 8;
export const REMOVED_2P = 10;
export const PHRASES = ['really', 'doubt', 'hmm', 'trust'] as const;
export type Phrase = (typeof PHRASES)[number];
export const PHRASE_KO: Record<Phrase, string> = {
really: '정말이야!',
doubt: '의심스러운데?',
hmm: '음… 고민되네',
trust: '믿어 볼게',
};
/** Quick-chat phrases per player per round (keeps the log small). */
export const SAY_PER_ROUND = 3;
const AWAY_AFTER = 3;
const AWAY_MS = 5000;
const SAID_KEEP = 6;
// ---------------------------------------------------------------- options
export const cockroachOptionsSchema = z.object({
loseCount: z
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
.default('auto')
.meta({ title: '같은 벌레가 몇 장 모이면 패배', labels: { auto: '자동(2명 5장, 3~6명 4장)', 3: '3장(짧은 게임)', 4: '4장', 5: '5장' } }),
turnSeconds: z
.union([z.literal(15), z.literal(30), z.literal(60)])
.default(30)
.meta({ title: '건네기·판정 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초' } }),
claimHistory: z.boolean().default(true).meta({ title: '이번 라운드 주장 기록 보이기' }),
quickChat: z.boolean().default(true).meta({ title: '빠른 말 버튼("정말이야!", "의심스러운데?")' }),
eightKindsRule: z.boolean().default(false).meta({ title: '앞마당에 8종을 모두 모아도 패배(하우스 룰)' }),
twoPlayerPass: z.boolean().default(false).meta({ title: '2명일 때도 몰래 보고 되돌려 주기 허용(하우스 룰)' }),
});
export type CockroachOptions = z.infer<typeof cockroachOptionsSchema>;
// ---------------------------------------------------------------- actions
const kindSchema = z.enum(KINDS);
export const cockroachActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('offer'), cardId: z.string().max(32), to: z.string().max(64), claim: kindSchema }),
z.object({ type: z.literal('call'), call: z.enum(['true', 'false']) }),
z.object({ type: z.literal('peek') }),
z.object({ type: z.literal('pass'), to: z.string().max(64), claim: kindSchema }),
z.object({ type: z.literal('say'), phrase: z.enum(PHRASES) }),
]);
export type CockroachClientAction = z.infer<typeof cockroachActionSchema>;
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
export type CockroachAction = CockroachClientAction | { type: 'timeout' } | { type: 'leave' };
type TurnAction = Extract<CockroachClientAction, { type: 'offer' | 'call' | 'peek' | 'pass' }>;
// ---------------------------------------------------------------- state
export type LossReason = 'four' | 'emptyHand' | 'eight' | 'left';
export interface ClaimEntry {
from: PlayerId;
to: PlayerId;
claim: Kind;
auto: boolean;
}
export interface CockroachRound {
no: number;
starter: PlayerId;
/** Card being passed (null while the starter is choosing). */
card: string | null;
/** Current receiver who must decide (null while the starter is choosing). */
holder: PlayerId | null;
lastPasser: PlayerId | null;
claim: Kind | null;
/** Players who know the passed card: the starter plus every holder who peeked. */
seen: PlayerId[];
/** The holder has looked at the card and must pass it on. */
peeked: boolean;
claims: ClaimEntry[];
}
export interface LastReveal {
card: string;
kind: Kind;
claim: Kind;
call: 'true' | 'false';
caller: PlayerId;
passer: PlayerId;
correct: boolean;
takenBy: PlayerId;
auto: boolean;
round: number;
}
export interface CockroachFinal {
loser: PlayerId;
reason: LossReason;
losingKind: Kind | null;
/** Detailed placings (§6): winners by fewest same-kind cards, then fewest front cards; loser last. */
placings: PlayerId[][];
fronts: Record<PlayerId, Record<Kind, number>>;
summary: string;
}
export interface Said {
seq: number;
player: PlayerId;
phrase: Phrase;
}
export interface CockroachState {
options: CockroachOptions;
loseCount: number;
order: PlayerId[];
hands: Record<PlayerId, string[]>;
fronts: Record<PlayerId, string[]>;
/** 2-player game: 10 cards removed unseen (secret until the end). */
removed: string[];
round: CockroachRound;
lastReveal: LastReveal | null;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
said: Said[];
sayCount: Record<PlayerId, number>;
turnStartedAt: number;
seq: number;
final: CockroachFinal | null;
}
// ---------------------------------------------------------------- view
export type DangerLevel = 'ok' | 'warn' | 'danger';
export interface FrontView {
counts: Record<Kind, number>;
/** Kinds with at least one card, most first (ties in KINDS order). */
kinds: Kind[];
level: Record<Kind, DangerLevel>;
total: number;
/** Distinct kinds (for the 8-kinds house rule). */
distinct: number;
}
export interface CockroachView {
options: CockroachOptions;
loseCount: number;
me: PlayerId | null;
order: PlayerId[];
phase: 'offer' | 'respond' | 'finished';
handCounts: Record<PlayerId, number>;
fronts: Record<PlayerId, FrontView>;
/** Viewer's hand ids grouped by kind (null for spectators). */
myHand: { kind: Kind; ids: string[] }[] | null;
round: {
no: number;
starter: PlayerId;
holder: PlayerId | null;
lastPasser: PlayerId | null;
claim: Kind | null;
seen: PlayerId[];
peeked: boolean;
/** Empty when claimHistory is off. */
claims: ClaimEntry[];
passes: number;
};
/** The passed card: id/kind only for players who have seen it. */
card: { known: true; id: string; kind: Kind } | { known: false } | null;
/** What the viewer can do right now (null for spectators / when it is not the viewer's move). */
my: {
canOffer: boolean;
offerTargets: PlayerId[];
canCall: boolean;
canPeek: boolean;
peekBlocked: string | null;
canPass: boolean;
passTargets: PlayerId[];
canSay: boolean;
} | null;
lastReveal: LastReveal | null;
away: Record<PlayerId, boolean>;
said: Said[];
removedCount: number;
turnStartedAt: number;
turnMs: number;
seq: number;
final: CockroachFinal | null;
/** After the game: every hand and the removed cards. */
revealed: { hands: Record<PlayerId, string[]>; removed: string[] } | null;
}
// ---------------------------------------------------------------- cards
export const kindOf = (id: string): Kind => {
const k = id.slice(0, id.lastIndexOf('-')) as Kind;
if (!KINDS.includes(k)) throw new Error(`cockroach-poker: bad card id ${id}`);
return k;
};
export const allCardIds = (): string[] => KINDS.flatMap((k) => Array.from({ length: PER_KIND }, (_, i) => `${k}-${i}`));
const isKind = (k: unknown): k is Kind => typeof k === 'string' && (KINDS as readonly string[]).includes(k);
export function loseCountFor(o: CockroachOptions, players: number): number {
return o.loseCount === 'auto' ? (players === 2 ? 5 : 4) : o.loseCount;
}
/** Hand sizes dealt one at a time from the first player (§4 table). */
export function handSizes(players: number): number[] {
const total = players === 2 ? KINDS.length * PER_KIND - REMOVED_2P : KINDS.length * PER_KIND;
return Array.from({ length: players }, (_, i) => Math.floor(total / players) + (i < total % players ? 1 : 0));
}
export function countKinds(ids: readonly string[]): Record<Kind, number> {
const out = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
for (const id of ids) out[kindOf(id)] += 1;
return out;
}
// ---------------------------------------------------------------- helpers
const finished = (s: CockroachState) => s.final !== null;
/** Seats clockwise starting after `p`. */
function after(s: CockroachState, p: PlayerId): PlayerId[] {
const i = s.order.indexOf(p);
return s.order.map((_, k) => s.order[(i + 1 + k) % s.order.length]!).filter((x) => x !== p);
}
function waiting(s: CockroachState): PlayerId[] {
if (finished(s)) return [];
return [s.round.holder ?? s.round.starter];
}
function passTargets(s: CockroachState, holder: PlayerId): PlayerId[] {
const r = s.round;
if (s.order.length === 2) return s.options.twoPlayerPass && r.claims.length === 1 ? after(s, holder) : [];
return after(s, holder).filter((p) => !r.seen.includes(p));
}
/** Korean reason why the current holder may not peek, or null when peeking is allowed. */
function peekBlock(s: CockroachState): string | null {
const r = s.round;
if (r.peeked) return '이미 카드를 봤어요.';
if (s.order.length === 2) {
if (!s.options.twoPlayerPass) return '2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.';
if (r.claims.length > 1) return '되돌려 받은 카드는 다시 넘길 수 없어요. 진짜인지 거짓인지 골라 주세요.';
return null;
}
return passTargets(s, r.holder!).length === 0 ? '모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.' : null;
}
function turnMs(s: CockroachState): number {
const p = waiting(s)[0];
return p && s.away[p] ? AWAY_MS : s.options.turnSeconds * 1000;
}
function newRound(s: CockroachState, starter: PlayerId): void {
s.round = { no: s.round.no + 1, starter, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] };
s.sayCount = Object.fromEntries(s.order.map((p) => [p, 0]));
}
function placings(s: CockroachState, loser: PlayerId): PlayerId[][] {
const key = (p: PlayerId) => {
const c = countKinds(s.fronts[p]!);
return [Math.max(...Object.values(c)), s.fronts[p]!.length] as const;
};
const winners = s.order.filter((p) => p !== loser).sort((a, b) => {
const [ma, ta] = key(a);
const [mb, tb] = key(b);
return ma - mb || ta - tb;
});
const groups: PlayerId[][] = [];
for (const p of winners) {
const last = groups[groups.length - 1];
if (last && key(last[0]!)[0] === key(p)[0] && key(last[0]!)[1] === key(p)[1]) last.push(p);
else groups.push([p]);
}
groups.push([loser]);
return groups;
}
function finish(s: CockroachState, loser: PlayerId, reason: LossReason, losingKind: Kind | null, events: GameEvent[]): void {
let summary: string;
switch (reason) {
case 'four':
summary = `앞마당에 ${KIND_KO[losingKind!]} ${s.loseCount}장이 모였어요! 그 사람이 지고, 나머지 모두 이겼어요`;
break;
case 'eight':
summary = '앞마당에 벌레 8종이 모두 모였어요! 그 사람이 지고, 나머지 모두 이겼어요';
break;
case 'emptyHand':
summary = '카드를 건넬 차례인데 손에 카드가 없어서 졌어요. 나머지 모두 이겼어요';
break;
case 'left':
summary = '한 사람이 게임을 나가서 끝났어요. 나머지 모두 이겼어요';
break;
}
s.final = {
loser,
reason,
losingKind,
placings: placings(s, loser),
fronts: Object.fromEntries(s.order.map((p) => [p, countKinds(s.fronts[p]!)])),
summary,
};
events.push({ type: 'gameEnded', loser, reason, ...(losingKind ? { kind: losingKind } : {}) });
}
/** The concrete move a timeout stands for (§8.5). Uses the RNG for the starter / unpeeked holder. */
export function autoAction(s: CockroachState, player: PlayerId, rng: Rng): TurnAction {
const r = s.round;
if (r.holder === null) {
const hand = s.hands[player]!;
return { type: 'offer', cardId: hand[rng.int(hand.length)]!, to: after(s, player)[0]!, claim: KINDS[rng.int(KINDS.length)]! };
}
if (!r.peeked) return { type: 'call', call: rng.int(2) === 0 ? 'true' : 'false' };
return { type: 'pass', to: passTargets(s, player)[0]!, claim: kindOf(r.card!) };
}
function validateTurn(s: CockroachState, actor: PlayerId, a: TurnAction): Validation {
const r = s.round;
switch (a.type) {
case 'offer': {
if (r.holder !== null) return fail('지금은 카드를 건넬 때가 아니에요.');
if (r.starter !== actor) return fail('지금은 내 차례가 아니에요.');
if (!s.hands[actor]!.includes(a.cardId)) return fail('내 손에 없는 카드예요.');
if (a.to === actor) return fail('나에게는 건넬 수 없어요.');
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
}
case 'call':
if (r.holder !== actor) return fail('지금은 내가 판정할 차례가 아니에요.');
if (r.peeked) return fail('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.');
return a.call === 'true' || a.call === 'false' ? ok : fail('진짜인지 거짓인지 골라 주세요.');
case 'peek': {
if (r.holder !== actor) return fail('지금은 내가 카드를 받을 차례가 아니에요.');
const why = peekBlock(s);
return why ? fail(why) : ok;
}
case 'pass': {
if (r.holder !== actor) return fail('지금은 내가 카드를 넘길 차례가 아니에요.');
if (!r.peeked) return fail('먼저 카드를 몰래 봐야 넘길 수 있어요.');
if (a.to === actor) return fail('나에게는 넘길 수 없어요.');
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
if (!passTargets(s, actor).includes(a.to)) return fail('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
}
}
}
function applyTurn(s: CockroachState, actor: PlayerId, a: TurnAction, events: GameEvent[], auto: boolean): void {
const r = s.round;
switch (a.type) {
case 'offer': {
const hand = s.hands[actor]!;
hand.splice(hand.indexOf(a.cardId), 1);
r.card = a.cardId;
r.holder = a.to;
r.lastPasser = actor;
r.claim = a.claim;
r.seen = [actor];
r.peeked = false;
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
events.push({ type: 'offer', from: actor, to: a.to, claim: a.claim, auto });
break;
}
case 'peek':
if (!r.seen.includes(actor)) r.seen.push(actor);
r.peeked = true;
events.push({ type: 'peek', player: actor });
break;
case 'pass':
r.lastPasser = actor;
r.holder = a.to;
r.claim = a.claim;
r.peeked = false;
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
events.push({ type: 'pass', from: actor, to: a.to, claim: a.claim, auto });
break;
case 'call': {
const card = r.card!;
const kind = kindOf(card);
const truth = kind === r.claim;
const correct = (a.call === 'true') === truth;
const takenBy = correct ? r.lastPasser! : actor;
s.fronts[takenBy]!.push(card);
r.card = null;
r.holder = null;
s.lastReveal = { card, kind, claim: r.claim!, call: a.call, caller: actor, passer: r.lastPasser!, correct, takenBy, auto, round: r.no };
events.push({ type: 'reveal', card, kind, claim: r.claim, call: a.call, caller: actor, correct, takenBy, auto });
const counts = countKinds(s.fronts[takenBy]!);
if (counts[kind] >= s.loseCount) finish(s, takenBy, 'four', kind, events);
else if (s.options.eightKindsRule && KINDS.every((k) => counts[k] > 0)) finish(s, takenBy, 'eight', null, events);
else {
newRound(s, takenBy);
if (s.hands[takenBy]!.length === 0) finish(s, takenBy, 'emptyHand', null, events);
else events.push({ type: 'round', starter: takenBy });
}
break;
}
}
}
// ---------------------------------------------------------------- definition
export const cockroachPoker: GameDefinition<CockroachState, CockroachAction, CockroachView, CockroachOptions> = {
id: 'cockroach-poker',
nameKo: '바퀴벌레 포커',
minPlayers: 2,
maxPlayers: 6,
stateVersion: 1,
defaultOptions: cockroachOptionsSchema.parse({}),
optionsSchema: cockroachOptionsSchema as unknown as z.ZodType<CockroachOptions>,
actionSchema: cockroachActionSchema as unknown as z.ZodType<CockroachAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 6) throw new Error(`cockroach-poker: ${players.length} players not allowed`);
const deck = rng.shuffle(allCardIds());
const removed = players.length === 2 ? deck.splice(0, REMOVED_2P) : [];
const first = players[rng.int(players.length)]!;
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
const hands = per<string[]>(() => []);
// Deal one at a time clockwise from the first player, so the first player gets the most (§4).
const fi = players.indexOf(first);
deck.forEach((id, i) => hands[players[(fi + i) % players.length]!]!.push(id));
return {
options,
loseCount: loseCountFor(options, players.length),
order: [...players],
hands,
fronts: per(() => []),
removed,
round: { no: 1, starter: first, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] },
lastReveal: null,
timeoutsInRow: per(() => 0),
away: per(() => false),
said: [],
sayCount: per(() => 0),
turnStartedAt: now,
seq: 0,
final: null,
};
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (finished(s)) return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'leave':
return ok;
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'say':
if (!s.options.quickChat) return fail('이 방은 빠른 말을 쓰지 않아요.');
if (!PHRASES.includes(a.phrase)) return fail('보낼 말을 골라 주세요.');
return (s.sayCount[actor] ?? 0) >= SAY_PER_ROUND ? fail(`빠른 말은 한 라운드에 ${SAY_PER_ROUND}번까지 할 수 있어요.`) : ok;
default:
return validateTurn(s, actor, a);
}
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
s.seq += 1;
switch (a.type) {
case 'leave':
events.push({ type: 'left', player: actor });
finish(s, actor, 'left', null, events);
break;
case 'say':
s.sayCount[actor] = (s.sayCount[actor] ?? 0) + 1;
s.said = [...s.said, { seq: s.seq, player: actor, phrase: a.phrase }].slice(-SAID_KEEP);
events.push({ type: 'say', player: actor, phrase: a.phrase });
// Chatting does not restart the turn clock.
return { state: s, events };
case 'timeout': {
const concrete = autoAction(s, actor, rng);
const v = validateTurn(s, actor, concrete);
if (!v.ok) throw new Error(`cockroach-poker: auto action rejected: ${v.reason}`);
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeout', player: actor });
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
s.away[actor] = true;
events.push({ type: 'away', player: actor });
}
applyTurn(s, actor, concrete, events, true);
break;
}
default:
s.timeoutsInRow[actor] = 0;
s.away[actor] = false;
applyTurn(s, actor, a, events, false);
}
s.turnStartedAt = now;
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
const over = finished(s);
const r = s.round;
const fronts: Record<PlayerId, FrontView> = {};
for (const p of s.order) {
const counts = countKinds(s.fronts[p]!);
const kinds = KINDS.filter((k) => counts[k] > 0).sort((a, b) => counts[b] - counts[a] || KINDS.indexOf(a) - KINDS.indexOf(b));
const level = Object.fromEntries(
KINDS.map((k) => [k, counts[k] >= s.loseCount - 1 ? 'danger' : counts[k] >= Math.max(2, s.loseCount - 2) ? 'warn' : 'ok']),
) as Record<Kind, DangerLevel>;
fronts[p] = { counts, kinds, level, total: s.fronts[p]!.length, distinct: kinds.length };
}
let myHand: CockroachView['myHand'] = null;
if (me) {
const ids = s.hands[me]!;
myHand = KINDS.map((kind) => ({ kind, ids: ids.filter((id) => kindOf(id) === kind).sort() })).filter((g) => g.ids.length > 0);
}
let card: CockroachView['card'] = null;
if (r.card && !over) card = me && r.seen.includes(me) ? { known: true, id: r.card, kind: kindOf(r.card) } : { known: false };
let my: CockroachView['my'] = null;
if (me && !over) {
const offering = r.holder === null && r.starter === me;
const holding = r.holder === me;
const why = holding ? peekBlock(s) : null;
my = {
canOffer: offering,
offerTargets: offering ? after(s, me) : [],
canCall: holding && !r.peeked,
canPeek: holding && why === null,
peekBlocked: holding && !r.peeked ? why : null,
canPass: holding && r.peeked,
passTargets: holding && r.peeked ? passTargets(s, me) : [],
canSay: s.options.quickChat && (s.sayCount[me] ?? 0) < SAY_PER_ROUND,
};
}
return {
options: s.options,
loseCount: s.loseCount,
me,
order: s.order,
phase: over ? 'finished' : r.holder === null ? 'offer' : 'respond',
handCounts: Object.fromEntries(s.order.map((p) => [p, s.hands[p]!.length])),
fronts,
myHand,
round: {
no: r.no,
starter: r.starter,
holder: r.holder,
lastPasser: r.lastPasser,
claim: r.claim,
seen: [...r.seen],
peeked: r.peeked,
claims: s.options.claimHistory ? r.claims.map((c) => ({ ...c })) : [],
passes: Math.max(0, r.claims.length - 1),
},
card,
my,
lastReveal: s.lastReveal,
away: s.away,
said: s.said,
removedCount: s.removed.length,
turnStartedAt: s.turnStartedAt,
turnMs: turnMs(s),
seq: s.seq,
final: s.final,
revealed: over ? { hands: s.hands, removed: s.removed } : null,
};
},
activePlayers: waiting,
deadline(s) {
return finished(s) ? null : s.turnStartedAt + turnMs(s);
},
timeoutPlayers: waiting,
onTimeout() {
return { type: 'timeout' };
},
/** Leaving forfeits: the leaver becomes the one loser and everyone else wins. */
onLeave(s, player) {
if (!s.order.includes(player) || finished(s)) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
// Everyone but the loser wins together (§6); the detailed placings stay in the view.
return {
ranking: [s.order.filter((p) => p !== f.loser), [f.loser]],
scores: Object.fromEntries(s.order.map((p) => [p, p === f.loser ? 0 : 1])),
summary: f.summary,
reason: f.reason === 'left' ? 'resign' : 'normal',
};
},
/** Game moves only (quick chat is left out); same-kind cards are interchangeable, so one id per kind. */
legalActions(s, p) {
if (!s.order.includes(p) || finished(s)) return [];
const r = s.round;
const out: CockroachAction[] = [];
if (r.holder === null) {
if (r.starter !== p) return [];
const seenKinds = new Set<Kind>();
for (const id of s.hands[p]!) {
const k = kindOf(id);
if (seenKinds.has(k)) continue;
seenKinds.add(k);
for (const to of after(s, p)) for (const claim of KINDS) out.push({ type: 'offer', cardId: id, to, claim });
}
return out;
}
if (r.holder !== p) return [];
if (r.peeked) {
for (const to of passTargets(s, p)) for (const claim of KINDS) out.push({ type: 'pass', to, claim });
return out;
}
out.push({ type: 'call', call: 'true' }, { type: 'call', call: 'false' });
if (peekBlock(s) === null) out.push({ type: 'peek' });
return out;
},
/** `player`'s hand, the passed card while only they have seen it, and the removed cards (secret to all). */
secretsOf(s, player) {
if (finished(s)) return [];
const out = [...(s.hands[player] ?? []), ...s.removed];
const r = s.round;
if (r.card && r.seen.length === 1 && r.seen[0] === player) out.push(r.card);
return out;
},
};

View File

@@ -0,0 +1,678 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
FULL_DECK,
allCardIds,
dalmuti,
deckFor,
drawFirstRanks,
finalOf,
type DCard,
type DalmutiAction,
type DalmutiOptions,
type DalmutiState,
type DalmutiView,
} from './index';
// ---------------------------------------------------------------- helpers
const BY_ID = new Map(FULL_DECK.map((c) => [c.id, c]));
const card = (id: string): DCard => {
const c = BY_ID.get(id);
if (!c) throw new Error(`no card ${id}`);
return c;
};
/** k-th copy of number n ("c7-2"). */
const C = (n: number, k = 1) => `c${n}-${k}`;
const J1 = 'j1';
const J2 = 'j2';
const P5 = ['A', 'B', 'C', 'D', 'E'];
const P4 = ['A', 'B', 'C', 'D'];
let clock = 1_000_000;
function make(opts: Partial<DalmutiOptions> = {}, players = P5, seed = 1): DalmutiState {
const options = dalmuti.optionsSchema.parse(opts);
return dalmuti.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/** Seats `order`, gives hands (rest of the deck → discard) and starts a trick (optionally with a top set). */
function rig(
s: DalmutiState,
r: { order?: string[]; hands: Record<string, string[]>; current?: string; top?: { by: string; ids: string[] } },
): DalmutiState {
if (r.order) s.order = [...r.order];
for (const p of s.players) s.hands[p] = (r.hands[p] ?? []).map(card);
const used = new Set([...Object.values(r.hands).flat(), ...(r.top?.ids ?? [])]);
s.discard = deckFor(s.options.removeHighCards)
.filter((c) => !used.has(c.id))
.map((c) => c.id);
s.phase = { kind: 'playing' };
s.finishOrder = [];
s.taxLog = [];
const lead = r.current ?? s.order[0]!;
s.trick = { leader: r.top?.by ?? lead, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
if (r.top) {
const cards = r.top.ids.map(card);
const nums = cards.filter((c) => c.n !== 13);
const top = { cardIds: r.top.ids, count: cards.length, rank: nums.length ? nums[0]!.n : 13, by: r.top.by };
s.trick.top = top;
s.trick.pile = [top];
}
s.current = lead;
return s;
}
const act = (s: DalmutiState, p: string, a: DalmutiAction): DalmutiState => {
const v = dalmuti.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return dalmuti.apply(s, p, a, { rng: SeededRng.fromSeed([3, 3, 3, 3]), now: (clock += 1000) }).state;
};
const play = (s: DalmutiState, p: string, ids: string[]) => act(s, p, { type: 'play', cardIds: ids });
const pass = (s: DalmutiState, p: string) => act(s, p, { type: 'pass' });
const reject = (s: DalmutiState, p: string, a: DalmutiAction): string | null => {
const v = dalmuti.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.id);
const nums = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.n);
const confirmAll = (s: DalmutiState, except: Record<string, DalmutiAction> = {}) => {
for (const p of s.players) s = act(s, p, except[p] ?? { type: 'confirmHand' });
return s;
};
/** A revolutionCheck state with given hands (5 players, order A..E unless given). */
function atRevolutionCheck(hands: Record<string, string[]>, opts: Partial<DalmutiOptions> = {}, order = P5): DalmutiState {
const s = rig(make(opts), { order, hands });
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
return s;
}
/** Splits the full deck so that the given cards land where wanted and the rest fill hands round-robin. */
function handsWith(order: string[], fixed: Record<string, string[]>): Record<string, string[]> {
const taken = new Set(Object.values(fixed).flat());
const rest = FULL_DECK.map((c) => c.id).filter((id) => !taken.has(id));
const hands: Record<string, string[]> = Object.fromEntries(order.map((p) => [p, [...(fixed[p] ?? [])]]));
const per = Math.floor(80 / order.length);
for (const p of order) while (hands[p]!.length < per) hands[p]!.push(rest.shift()!);
for (let i = 0; rest.length; i++) hands[order[i % order.length]!]!.push(rest.shift()!);
return hands;
}
// ---------------------------------------------------------------- deck / setup
describe('덱과 셋업 (§3, §4)', () => {
test('80 cards: n copies of n, 2 jesters; removeHighCards → 38', () => {
expect(FULL_DECK).toHaveLength(80);
for (let n = 1; n <= 12; n++) expect(FULL_DECK.filter((c) => c.n === n)).toHaveLength(n);
expect(FULL_DECK.filter((c) => c.n === 13)).toHaveLength(2);
expect(new Set(FULL_DECK.map((c) => c.id)).size).toBe(80);
expect(deckFor(true)).toHaveLength(38);
expect(deckFor(true).some((c) => c.n >= 9 && c.n <= 12)).toBe(false);
});
test('6 players: 14, 14, 13, 13, 13, 13 in rank order; all deals add up', () => {
const s = make({}, ['A', 'B', 'C', 'D', 'E', 'F'], 3);
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([14, 14, 13, 13, 13, 13]);
const sizes: Record<number, number[]> = { 4: [20, 20, 20, 20], 5: [16, 16, 16, 16, 16], 7: [12, 12, 12, 11, 11, 11, 11], 8: [10, 10, 10, 10, 10, 10, 10, 10] };
for (const [n, want] of Object.entries(sizes)) {
const t = make({}, Array.from({ length: Number(n) }, (_, i) => `P${i}`), 4);
expect(t.order.map((p) => t.hands[p]!.length)).toEqual(want);
expect(allCardIds(t).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
}
const r = make({ removeHighCards: true }, Array.from({ length: 8 }, (_, i) => `P${i}`), 4);
expect(r.order.map((p) => r.hands[p]!.length)).toEqual([5, 5, 5, 5, 5, 5, 4, 4]);
});
test('player range 4..8', () => {
expect(dalmuti.minPlayers).toBe(4);
expect(dalmuti.maxPlayers).toBe(8);
expect(() => make({}, ['A', 'B', 'C'])).toThrow();
expect(() => make({}, 'ABCDEFGHI'.split(''))).toThrow();
});
test('first rank draw: same seed → same result; ties re-draw until the order is fixed (lower first)', () => {
expect(JSON.stringify(make({}, P5, 9))).toBe(JSON.stringify(make({}, P5, 9)));
let sawTie = 0;
for (let seed = 1; seed <= 400; seed++) {
const players = Array.from({ length: 8 }, (_, i) => `P${i}`);
const { order, draws } = drawFirstRanks(players, FULL_DECK as DCard[], SeededRng.fromSeed(seedN(seed)));
expect([...order].sort()).toEqual([...players].sort());
if (Object.values(draws).some((d) => d.length > 1)) sawTie++;
// Lexicographic on the draw sequence: tied players share a prefix and re-drew together.
for (let i = 0; i + 1 < order.length; i++) {
const a = draws[order[i]!]!;
const b = draws[order[i + 1]!]!;
const k = a.findIndex((x, j) => x !== b[j]);
expect(k).toBeGreaterThanOrEqual(0);
expect(a[k]!).toBeLessThan(b[k]!);
}
}
expect(sawTie).toBeGreaterThan(10);
const s = make({}, P5, 9);
expect(s.firstDraw).not.toBeNull();
expect(make({ firstRanking: 'random' }, P5, 9).firstDraw).toBeNull();
});
test('revolution on → everyone confirms first; off → tax right away', () => {
expect(make().phase.kind).toBe('revolutionCheck');
const s = make({ revolution: false });
expect(s.phase.kind).toBe('taxReturn');
const o = s.order as string[];
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
[o[4]!, o[0]!, 2],
[o[3]!, o[1]!, 1],
]);
});
});
// ---------------------------------------------------------------- tax
describe('세금 (§4-5)', () => {
const bottomHand = [J1, J2, C(3, 1), C(3, 2), C(7, 1), C(9, 1), C(12, 1)];
const hands = () => handsWith(P5, { E: bottomHand, D: [C(2, 1), C(5, 1), C(11, 1)] });
test('jesterTaxable off: 대농노 [광대, 광대, 3, 3, 7…] gives 3, 3; 소농노 gives its lowest number', () => {
let s = atRevolutionCheck(hands(), { jesterTaxable: false });
const dBefore = s.hands.D!.map((c) => c.n).filter((n) => n !== 13);
s = confirmAll(s);
expect(s.phase.kind).toBe('taxReturn');
expect(s.taxLog[0]).toEqual({ kind: 'tax', from: 'E', to: 'A', cardIds: [C(3, 1), C(3, 2)] });
expect(s.taxLog[1]!.from).toBe('D');
expect(s.taxLog[1]!.to).toBe('B');
expect(card(s.taxLog[1]!.cardIds[0]!).n).toBe(Math.min(...dBefore));
expect(ids(s, 'E')).toContain(J1);
expect(ids(s, 'A')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
});
test('jesterTaxable on: the two jesters go', () => {
const s = confirmAll(atRevolutionCheck(hands(), { jesterTaxable: true }));
expect(s.taxLog[0]!.cardIds).toEqual([J1, J2]);
});
test('returning: 대달무티 may give back what it just got; wrong count refused; both done → A leads', () => {
let s = confirmAll(atRevolutionCheck(hands()));
const got = s.taxLog[0]!.cardIds;
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!] })).toBe('돌려줄 카드 2장을 골라 주세요.');
expect(reject(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!, ids(s, 'B')[1]!] })).toBe('돌려줄 카드 1장을 골라 주세요.');
expect(reject(s, 'C', { type: 'returnTax', cardIds: [ids(s, 'C')[0]!] })).toBe('돌려줄 카드가 없어요.');
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!, ids(s, 'E')[0]!] })).toBe('손에 없는 카드예요.');
s = act(s, 'A', { type: 'returnTax', cardIds: [...got] });
expect(ids(s, 'E')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
expect(s.phase.kind).toBe('taxReturn');
expect(reject(s, 'A', { type: 'returnTax', cardIds: [...got] })).toBe('이미 카드를 돌려줬어요.');
s = act(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!] });
expect(s.phase.kind).toBe('playing');
expect(s.current).toBe('A');
expect(s.hands.A!.length).toBe(16);
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
});
test('4 players: 소달무티 ↔ 소농노 and 대달무티 ↔ 대농노 only', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P4, {}), {}, P4));
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
['D', 'A', 2],
['C', 'B', 1],
]);
});
});
// ---------------------------------------------------------------- revolution
describe('혁명 (§4-4)', () => {
test('a commoner with both jesters declares → no tax, same ranks, 대달무티 leads', () => {
const s0 = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
expect(reject(s0, 'A', { type: 'confirmHand', declareRevolution: true })).toBe('광대 2장이 있어야 혁명할 수 있어요.');
const s = confirmAll(s0, { C: { type: 'confirmHand', declareRevolution: true } });
expect(s.revolution).toBe('revolution');
expect(s.revolutionBy).toBe('C');
expect(s.taxLog).toEqual([]);
expect(s.order).toEqual(P5);
expect(s.phase.kind).toBe('playing');
expect(s.current).toBe('A');
});
test('대농노 declares → great revolution: order reversed, the new 대달무티 leads, no tax', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2] })), { E: { type: 'confirmHand', declareRevolution: true } });
expect(s.revolution).toBe('great');
expect(s.order).toEqual(['E', 'D', 'C', 'B', 'A']);
expect(s.current).toBe('E');
expect(s.taxLog).toEqual([]);
const v = dalmuti.view(s, 'A');
expect(v.seats[0]!).toMatchObject({ id: 'E', title: '대달무티' });
expect(v.seats[4]!).toMatchObject({ id: 'A', title: '대농노' });
});
test('not declaring is allowed: holder confirms → normal tax', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { B: [J1, J2] })));
expect(s.revolution).toBe('none');
expect(s.phase.kind).toBe('taxReturn');
});
test('no info leak: the check phase looks the same with or without a holder; canDeclareRevolution only in the holder view', () => {
const withHolder = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
const without = atRevolutionCheck(handsWith(P5, { C: [J1], D: [J2] }));
for (const s of [withHolder, without]) {
expect(s.phase.kind).toBe('revolutionCheck');
for (const p of ['A', 'B', 'D', 'E']) {
const v = JSON.stringify(dalmuti.view(s, p));
expect(v).not.toContain('canDeclareRevolution');
}
expect(JSON.stringify(dalmuti.view(s, null))).not.toContain('canDeclareRevolution');
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
}
expect(dalmuti.view(withHolder, 'C').my!.canDeclareRevolution).toBe(true);
expect(JSON.stringify(dalmuti.view(without, 'C'))).not.toContain('canDeclareRevolution');
// Other views of both states differ only in the hidden hand contents (same shape, same keys).
const keys = (v: DalmutiView) => JSON.stringify({ ...v, my: v.my ? Object.keys(v.my) : null });
expect(keys(dalmuti.view(withHolder, 'A')).replace(/"hand":\[.*?\]/, '')).toBe(keys(dalmuti.view(without, 'A')).replace(/"hand":\[.*?\]/, ''));
});
});
// ---------------------------------------------------------------- playing
describe('트릭 진행 (§5.1)', () => {
test('following 9×3: 8×2 refused (count), 9×3 refused (same number), 4+4+광대 ok, 광대×2+1 ok (1×3)', () => {
const s = rig(make(), {
order: P5,
hands: { A: [C(8, 1), C(8, 2), C(9, 4), C(9, 5), C(9, 6), C(4, 1), C(4, 2), J1, C(1, 1), J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] },
top: { by: 'E', ids: [C(9, 1), C(9, 2), C(9, 3)] },
current: 'A',
});
expect(reject(s, 'A', { type: 'play', cardIds: [C(8, 1), C(8, 2)] })).toBe('앞사람과 같은 장수(3장)로 내야 해요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 4), C(9, 5), C(9, 6)] })).toBe('더 낮은 숫자만 낼 수 있어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(8, 1), J1] })).toBe('같은 숫자끼리만 낼 수 있어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(4, 2), J1] })).toBeNull();
expect(reject(s, 'A', { type: 'play', cardIds: [J1, J2, C(1, 1)] })).toBeNull();
const after = play(s, 'A', [J1, J2, C(1, 1)]);
expect(after.trick.top).toMatchObject({ count: 3, rank: 1, by: 'A' });
// The view offers the same choices.
const ch = dalmuti.view(s, 'A').my!.choices;
expect(ch.find((c) => c.rank === 4)).toEqual({ rank: 4, have: 2, counts: [3], needsJester: true });
expect(ch.find((c) => c.rank === 1)).toEqual({ rank: 1, have: 1, counts: [3], needsJester: true });
expect(ch.find((c) => c.rank === 8)).toEqual({ rank: 8, have: 2, counts: [3], needsJester: true });
expect(ch.some((c) => c.rank === 9 || c.rank === 13)).toBe(false);
});
test('jester alone: on 12×1 refused (13 is worse); as a lead allowed (13×1), then any single number beats it', () => {
let s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, top: { by: 'E', ids: [C(12, 1)] }, current: 'A' });
expect(reject(s, 'A', { type: 'play', cardIds: [J1] })).toBe('더 낮은 숫자만 낼 수 있어요.');
s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, current: 'A' });
s = play(s, 'A', [J1]);
expect(s.trick.top).toMatchObject({ count: 1, rank: 13 });
expect(reject(s, 'B', { type: 'play', cardIds: [C(12, 3)] })).toBeNull();
});
test('a lead cannot pass; leads may be any size of one number', () => {
const s = rig(make(), { order: P5, hands: { A: [C(7, 1), C(7, 2), C(7, 3), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(8, 1)], E: [C(10, 1)] }, current: 'A' });
expect(reject(s, 'A', { type: 'pass' })).toBe('새로 시작할 때는 패스할 수 없어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(7, 1), C(7, 2), C(7, 3), J1] })).toBeNull();
expect(dalmuti.view(s, 'A').my!.choices.find((c) => c.rank === 7)!.counts).toEqual([1, 2, 3, 4]);
});
test('§5.3 example: everyone but D passes → D leads the next trick', () => {
let s = rig(make(), {
order: P5,
hands: { A: [C(7, 1), C(7, 2), C(3, 1)], B: [C(5, 1), C(5, 2), C(9, 1)], C: [C(10, 1), C(11, 1)], D: [C(2, 1), J1, C(8, 1)], E: [C(12, 1), C(6, 1)] },
});
s = play(s, 'A', [C(7, 1), C(7, 2)]);
s = play(s, 'B', [C(5, 1), C(5, 2)]);
s = pass(s, 'C');
s = play(s, 'D', [C(2, 1), J1]);
for (const p of ['E', 'A', 'B']) s = pass(s, p);
expect(s.current).toBe('C');
expect(s.trick.top!.by).toBe('D');
s = pass(s, 'C');
expect(s.trick.top).toBeNull();
expect(s.trick.leader).toBe('D');
expect(s.current).toBe('D');
expect(s.discard).toEqual(expect.arrayContaining([C(7, 1), C(5, 1), C(2, 1), J1]));
});
test('going out with the top set → the next player with cards leads', () => {
let s = rig(make(), { order: P5, hands: { A: [C(3, 1)], B: [C(5, 1), C(5, 2)], C: [C(10, 1)], D: [C(8, 1)], E: [C(12, 1), C(12, 2)] }, current: 'E' });
s = play(s, 'E', [C(12, 1)]);
s = play(s, 'A', [C(3, 1)]); // A is out on the top set
expect(s.finishOrder).toEqual(['A']);
for (const p of ['B', 'C', 'D']) s = pass(s, p);
expect(s.current).toBe('E');
s = pass(s, 'E');
expect(s.trick.top).toBeNull();
expect(s.current).toBe('B');
});
test('passLocksOut off: a passer may play when the turn comes back; on: skipped for the rest of the trick', () => {
const hands = { A: [C(10, 1), C(1, 1)], B: [C(9, 1), C(2, 1)], C: [C(8, 1), C(12, 1)], D: [C(12, 2), C(12, 3)], E: [C(12, 4), C(12, 5)] };
const run = (passLocksOut: boolean) => {
let s = rig(make({ passLocksOut }), { order: P5, hands });
s = play(s, 'A', [C(10, 1)]);
s = pass(s, 'B');
s = play(s, 'C', [C(8, 1)]);
for (const p of ['D', 'E', 'A']) s = pass(s, p);
return s;
};
const off = run(false);
expect(off.current).toBe('B');
expect(reject(off, 'B', { type: 'play', cardIds: [C(2, 1)] })).toBeNull();
const on = run(true);
expect(on.trick.top).toBeNull();
expect(on.current).toBe('C');
// Mid-trick, a locked-out player is skipped in the turn order.
let m = rig(make({ passLocksOut: true }), { order: P5, hands });
m = play(m, 'A', [C(10, 1)]);
m = pass(m, 'B');
m = play(m, 'C', [C(8, 1)]);
m = pass(m, 'D');
m = pass(m, 'E');
m = play(m, 'A', [C(1, 1)]);
expect(m.current).toBe('C');
expect(dalmuti.view(m, null).seats.find((x) => x.id === 'B')!.lockedOut).toBe(true);
});
});
// ---------------------------------------------------------------- rounds / scoring
describe('라운드 종료와 점수 (§5.2, §6)', () => {
test('one player left with cards → round over; that player is the next 대농노; next order = finish order', () => {
let s = rig(make({ rounds: 3 }), { order: P5, hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(12, 1), C(12, 2)] }, current: 'D' });
s = play(s, 'D', [C(6, 1)]);
s = pass(s, 'E');
s = play(s, 'A', [C(3, 1)]); // A out (1st)
s = pass(s, 'B'); // B: 4 > 3
s = pass(s, 'C');
// D is out already; E passes → trick over, A is out → next with cards after A = B leads.
s = pass(s, 'E');
expect(s.current).toBe('B');
s = play(s, 'B', [C(4, 1)]); // B out (3rd? no: D 1st, A 2nd, B 3rd)
expect(s.finishOrder).toEqual(['D', 'A', 'B']);
s = pass(s, 'C');
s = pass(s, 'E');
expect(s.current).toBe('C');
s = play(s, 'C', [C(5, 1)]); // C out → only E left → round over
expect(s.round).toBe(2);
expect(s.roundResults[0]!.order).toEqual(['D', 'A', 'B', 'C', 'E']);
expect(s.roundResults[0]!.points).toEqual({ D: 4, A: 3, B: 2, C: 1, E: 0 });
expect(s.order).toEqual(['D', 'A', 'B', 'C', 'E']);
expect(s.phase.kind).toBe('revolutionCheck');
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([16, 16, 16, 16, 16]);
expect(dalmuti.view(s, 'E').seats.find((x) => x.id === 'E')!.title).toBe('대농노');
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
});
test('5 players, 3 rounds: rank points add up; ties go to the better last-round rank; last-round mode', () => {
const rr = (round: number, order: string[]) => ({ round, order, revolution: 'none' as const, points: Object.fromEntries(order.map((p, i) => [p, 4 - i])) });
const roundResults = [rr(1, ['A', 'B', 'C', 'D', 'E']), rr(2, ['C', 'A', 'B', 'E', 'D']), rr(3, ['B', 'C', 'E', 'A', 'D'])];
const totals: Record<string, number> = {};
for (const r of roundResults) for (const [p, pt] of Object.entries(r.points)) totals[p] = (totals[p] ?? 0) + pt;
expect(totals).toEqual({ A: 8, B: 9, C: 9, D: 1, E: 3 });
const base = { roundResults, totals, players: P5, options: dalmuti.optionsSchema.parse({}) };
const f = finalOf(base);
// B and C tie on 9 → B was 1st in the last round, C 2nd.
expect(f.ranking).toEqual([['B'], ['C'], ['A'], ['E'], ['D']]);
expect(f.players.find((p) => p.player === 'A')!.roundRanks).toEqual([1, 2, 4]);
expect(f.summary).toContain('같은 점수는 마지막 라운드 순위');
expect(f.summary).toContain('3라운드');
const last = finalOf({ ...base, options: dalmuti.optionsSchema.parse({ scoring: 'last-round' }) });
expect(last.ranking).toEqual([['B'], ['C'], ['E'], ['A'], ['D']]);
});
test('the game ends after `rounds` rounds with a result', () => {
let s = rig(make({ rounds: 1 }), { order: P4.concat('E'), hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(7, 1)] }, current: 'E' });
s = play(s, 'E', [C(7, 1)]);
s = play(s, 'A', [C(3, 1)]);
for (const p of ['B', 'C', 'D']) s = pass(s, p);
s = play(s, 'B', [C(4, 1)]);
s = pass(s, 'C');
s = pass(s, 'D');
s = play(s, 'C', [C(5, 1)]);
expect(s.phase.kind).toBe('finished');
const res = dalmuti.result(s)!;
expect(res.ranking).toEqual([['E'], ['A'], ['B'], ['C'], ['D']]);
expect(res.scores).toEqual({ E: 4, A: 3, B: 2, C: 1, D: 0 });
expect(reject(s, 'D', { type: 'pass' })).toBe('게임이 이미 끝났어요.');
});
});
// ---------------------------------------------------------------- timeouts / leaving
describe('시간 초과와 나가기 (§8.5)', () => {
test('lead timeout plays every card of the highest number; follow timeout passes; jesters only → all jesters', () => {
let s = rig(make(), { order: P5, hands: { A: [C(2, 1), C(11, 1), C(11, 2), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
expect(dalmuti.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
s = act(s, 'A', { type: 'timeout' });
expect(s.trick.top).toMatchObject({ by: 'A', count: 2, rank: 11 });
expect(ids(s, 'A')).toEqual([C(2, 1), J1]);
s = act(s, 'B', { type: 'timeout' });
expect(s.trick.passesSinceTop).toEqual(['B']);
const j = act(rig(make(), { order: P5, hands: { A: [J1, J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }), 'A', { type: 'timeout' });
expect(j.trick.top).toMatchObject({ count: 2, rank: 13 });
});
test('tax return timeout gives back the highest number cards; revolutionCheck timeout never declares', () => {
let s = atRevolutionCheck(handsWith(P5, { A: [C(12, 1), C(12, 2), J1, J2] }));
for (const p of P5) s = act(s, p, { type: 'timeout' });
expect(s.revolution).toBe('none');
expect(s.phase.kind).toBe('taxReturn');
const hiA = Math.max(...nums(s, 'A').filter((n) => n !== 13));
s = act(s, 'A', { type: 'timeout' });
const back = s.taxLog.find((t) => t.kind === 'return' && t.from === 'A')!;
expect(back.cardIds.map((id) => card(id).n)).toEqual([hiA, hiA]);
expect(back.cardIds).not.toContain(J1);
});
test('3 timeouts in a row → away, deadline 5 s; acting again clears it', () => {
let s = rig(make(), { order: P5, hands: { A: [C(1, 1), C(9, 1), C(9, 2), C(9, 3)], B: [C(12, 1), C(12, 2)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
s.timeoutsInRow.B = 2;
s = play(s, 'A', [C(9, 1)]);
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
s = act(s, 'B', { type: 'timeout' });
expect(s.away.B).toBe(true);
for (const p of ['C', 'D', 'E']) s = pass(s, p);
s = play(s, 'A', [C(9, 2)]);
expect(s.current).toBe('B');
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 5000);
expect(dalmuti.timeoutNotBefore!(s)).toBe(s.turnStartedAt + 1200);
s = pass(s, 'B');
expect(s.away.B).toBe(false);
});
test('leaving does not eliminate: the seat stays, marked away, and keeps being auto-played', () => {
let s = rig(make(), { order: P5, hands: { A: [C(9, 1)], B: [C(12, 1), C(5, 1)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
expect(dalmuti.onLeave!(s, 'B')).toEqual({ type: 'leave' });
s = act(s, 'B', { type: 'leave' });
expect(s.left.B).toBe(true);
expect(s.away.B).toBe(true);
expect(s.order).toContain('B');
expect(dalmuti.onLeave!(s, 'B')).toBeNull();
expect(dalmuti.view(s, null).seats.find((x) => x.id === 'B')!.left).toBe(true);
});
});
// ---------------------------------------------------------------- validation / leaks / options
describe('검증과 정보 유출 (§8.3, §10)', () => {
test('not my turn / card not in hand / resent packet are refused and change nothing', () => {
let s = rig(make(), { order: P5, hands: { A: [C(9, 1), C(9, 2)], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
const snap = JSON.stringify(s);
expect(reject(s, 'B', { type: 'play', cardIds: [C(5, 1)] })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(5, 1)] })).toBe('손에 없는 카드예요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1), C(9, 1)] })).toBe('같은 카드를 두 번 고를 수 없어요.');
expect(reject(s, 'Z', { type: 'pass' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
expect(JSON.stringify(s)).toBe(snap);
s = play(s, 'A', [C(9, 1)]);
const snap2 = JSON.stringify(s);
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1)] })).toBe('지금은 내 차례가 아니에요.');
expect(JSON.stringify(s)).toBe(snap2);
});
test('spectators and other players see counts only; tax card contents only for the two involved', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2, C(3, 1), C(3, 2)] })));
const taxIds = s.taxLog.flatMap((t) => t.cardIds);
for (const viewer of [null, 'C']) {
const v = dalmuti.view(s, viewer);
const json = JSON.stringify(v);
for (const p of P5) if (p !== viewer) for (const id of ids(s, p)) expect(json).not.toContain(`"${id}"`);
for (const id of taxIds) expect(json).not.toContain(`"${id}"`);
expect(v.tax.every((t) => t.cards === null)).toBe(true);
expect(v.tax.map((t) => t.count)).toEqual([2, 1]);
}
const ve = dalmuti.view(s, 'E');
expect(ve.tax[0]!.cards).toEqual([3, 3]);
expect(ve.tax[1]!.cards).toBeNull();
const va = dalmuti.view(s, 'A');
expect(va.tax[0]!.cards).toEqual([3, 3]);
expect(va.my!.receivedIds).toEqual([C(3, 1), C(3, 2)]);
expect(va.my!.owe).toEqual({ to: 'E', count: 2 });
expect(va.my!.suggestedReturn.map((id) => card(id).n)).toEqual(worst2(s, 'A'));
// After A gives the 3s back, A's view must not keep their ids.
const t = act(s, 'A', { type: 'returnTax', cardIds: [C(3, 1), C(3, 2)] });
expect(JSON.stringify(dalmuti.view(t, 'A'))).not.toContain(`"${C(3, 1)}"`);
expect(dalmuti.view(t, 'E').tax[2]!.cards).toEqual([3, 3]);
});
test('options: defaults, Korean metadata on every option', () => {
const o = dalmuti.defaultOptions;
expect(o).toEqual({ rounds: 3, scoring: 'rank-points', firstRanking: 'draw', revolution: true, jesterTaxable: false, passLocksOut: false, removeHighCards: false, turnSeconds: 20 });
const shape = (dalmuti.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, f] of Object.entries(shape)) {
expect(f.meta()?.title, k).toMatch(/[가-힣]/);
}
});
test('client actions only (system actions are not in the schema)', () => {
expect(dalmuti.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(dalmuti.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(dalmuti.actionSchema.safeParse({ type: 'pass' }).success).toBe(true);
expect(dalmuti.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false);
});
});
function worst2(s: DalmutiState, p: string): number[] {
const n = nums(s, p).filter((x) => x !== 13).sort((a, b) => b - a);
return n.slice(0, 2);
}
// ---------------------------------------------------------------- random playouts
function invariants(s: DalmutiState): void {
const all = allCardIds(s);
const deck = deckFor(s.options.removeHighCards);
if (all.length !== deck.length || new Set(all).size !== deck.length) throw new Error(`card count ${all.length} (unique ${new Set(all).size}) ≠ ${deck.length}`);
if (s.phase.kind === 'playing' && s.hands[s.current]!.length === 0) throw new Error('current player has no cards');
if (s.phase.kind !== 'finished' && s.order.length !== s.players.length) throw new Error('order lost a player');
}
function checkLeaks(s: DalmutiState, players: PlayerId[], viewers: (PlayerId | null)[] = [null, ...players]): void {
for (const viewer of viewers) {
const v = dalmuti.view(s, viewer);
const json = JSON.stringify(v);
for (const p of players) {
if (p === viewer) continue;
for (const c of s.hands[p]!) if (json.includes(`"${c.id}"`)) throw new Error(`view for ${viewer} leaks ${c.id} of ${p}`);
}
for (const t of v.tax) if (t.cards && viewer !== t.from && viewer !== t.to) throw new Error(`tax contents leak to ${viewer}`);
const holder = viewer !== null && s.phase.kind === 'revolutionCheck' && s.hands[viewer]!.filter((c) => c.n === 13).length === 2;
if (!holder && json.includes('canDeclareRevolution')) throw new Error(`canDeclareRevolution in the view for ${viewer}`);
}
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: DalmutiState;
}
/** Same chooser and selection as `randomPlayout`, with lighter per-step checks so 1,000 games stay fast. */
function fastPlayout(players: PlayerId[], options: DalmutiOptions, 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 = dalmuti.setup({ players, options, rng: r, now });
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 15 === 0) checkLeaks(s, players, [null, players[(step / 15) % players.length]!]);
if (step % 60 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of dalmuti.timeoutPlayers!(s)) {
const v = dalmuti.validate(s, p, dalmuti.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (dalmuti.result(s)) {
checkLeaks(s, players);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const actives = dalmuti.activePlayers(s);
if (!actives.length) throw new Error('no active players');
const actor = actives[chooser.int(actives.length)]!;
// Some turns go through the timer path, like real away players.
const legal = chooser.int(12) === 0 ? [] : dalmuti.legalActions!(s, actor);
const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : dalmuti.onTimeout(s, actor);
const v = dalmuti.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const before = step % 25 === 0 ? JSON.stringify(s) : null;
const out = dalmuti.apply(s, actor, action, { rng: r, now });
if (before !== null && JSON.stringify(s) !== before) throw new Error('apply mutated the previous state');
if (JSON.stringify(out.events).match(/"c\d+-\d+"|"j[12]"/)) throw new Error('event carries a card id');
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
const PRESETS: Partial<DalmutiOptions>[] = [
{ rounds: 1 },
{ rounds: 3 },
{ rounds: 1, passLocksOut: true, jesterTaxable: true },
{ rounds: 3, revolution: false, firstRanking: 'random', scoring: 'last-round' },
{ rounds: 1, removeHighCards: true },
{ rounds: 5, removeHighCards: true, passLocksOut: true },
];
describe('random playouts (1,000 seeds)', () => {
test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => {
let finished = 0;
let revolutions = 0;
let great = 0;
for (let i = 1; i <= 1000; i++) {
const n = 4 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dalmuti.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
const a = fastPlayout(players, options, seedN(i), 20_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`);
finished++;
const res = dalmuti.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
expect(a.final.roundResults).toHaveLength(options.rounds);
revolutions += a.final.roundResults.filter((x) => x.revolution === 'revolution').length;
great += a.final.roundResults.filter((x) => x.revolution === 'great').length;
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(revolutions).toBeGreaterThan(0);
expect(great).toBeGreaterThan(0);
}, 60_000);
test.each([
[4, {}],
[5, { rounds: 1, passLocksOut: true }],
[8, { rounds: 1, revolution: false, removeHighCards: true }],
] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %i players', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dalmuti.optionsSchema.parse(opts);
const conserve = (st: unknown) => invariants(st as DalmutiState);
for (let i = 1; i <= 2; i++) {
const a = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,749 @@
/** 달무티 (docs/games/dalmuti.md). Pure and deterministic; state is JSON. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
JESTER,
cardOrder,
dalmutiActionSchema,
dalmutiOptionsSchema,
deckFor,
type Choice,
type DCard,
type DalmutiAction,
type DalmutiFinal,
type DalmutiOptions,
type DalmutiState,
type DalmutiView,
type LogEntry,
type Play,
type PlayAction,
type PlayView,
type Title,
} from './types';
export * from './types';
const REVOLUTION_MS = 20_000;
const TAX_MS = 30_000;
const AWAY_MS = 5000;
const AWAY_AFTER = 3;
/** Auto moves for away / departed players wait this long so others can follow the table. */
const MIN_TURN_MS = 1200;
const LOG_MAX = 40;
const LOG_VIEW = 20;
const MAX_REDRAWS = 50;
type Def = GameDefinition<DalmutiState, DalmutiAction, DalmutiView, DalmutiOptions>;
interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
// ---------------------------------------------------------------- helpers
/** Copy for `apply`: containers are copied; cards, plays, log entries and round results are never mutated. */
function cloneState(s: DalmutiState): DalmutiState {
const ph = s.phase;
const phase: DalmutiState['phase'] =
ph.kind === 'revolutionCheck'
? { kind: ph.kind, confirmed: { ...ph.confirmed } }
: ph.kind === 'taxReturn'
? { kind: ph.kind, owed: Object.fromEntries(Object.entries(ph.owed).map(([p, o]) => [p, { ...o }])) }
: { ...ph };
return {
...s,
order: [...s.order],
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
discard: [...s.discard],
phase,
taxLog: [...s.taxLog],
trick: { ...s.trick, passesSinceTop: [...s.trick.passesSinceTop], lockedOut: [...s.trick.lockedOut], pile: [...s.trick.pile] },
finishOrder: [...s.finishOrder],
roundResults: [...s.roundResults],
totals: { ...s.totals },
timeoutsInRow: { ...s.timeoutsInRow },
away: { ...s.away },
left: { ...s.left },
log: [...s.log],
};
}
export const titleOf = (rank: number, n: number): Title =>
rank === 0 ? '대달무티' : rank === 1 ? '소달무티' : rank === n - 1 ? '대농노' : rank === n - 2 ? '소농노' : '평민';
const byCard = (a: DCard, b: DCard) => cardOrder(a.id) - cardOrder(b.id);
export const sortHand = (h: DCard[]): DCard[] => [...h].sort(byCard);
const isJ = (c: DCard) => c.n === JESTER;
const withCards = (s: DalmutiState): PlayerId[] => s.order.filter((p) => s.hands[p]!.length > 0);
export const deckSize = (o: Pick<DalmutiOptions, 'removeHighCards'>): number => deckFor(o.removeHighCards).length;
/** Every card id of the deck, exactly once, across hands, the current trick and the discard (test invariant). */
export const allCardIds = (s: DalmutiState): string[] => [
...s.players.flatMap((p) => s.hands[p]!.map((c) => c.id)),
...s.trick.pile.flatMap((pl) => pl.cardIds),
...s.discard,
];
function log(s: DalmutiState, e: Record<string, unknown> & { t: LogEntry['t'] }): void {
s.log.push({ seq: s.seq, ...e } as LogEntry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
/** Rank of a set: the number of its number cards, 13 for jesters alone; null if numbers differ. */
export function setRank(cards: DCard[]): number | null {
const nums = cards.filter((c) => !isJ(c));
if (nums.length === 0) return JESTER;
return nums.every((c) => c.n === nums[0]!.n) ? nums[0]!.n : null;
}
/** Next player after `from` in turn order who still has cards and is not locked out. */
function nextActor(s: DalmutiState, from: PlayerId): PlayerId {
const n = s.order.length;
const i0 = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const p = s.order[(i0 + k) % n]!;
if (s.hands[p]!.length > 0 && !s.trick.lockedOut.includes(p)) return p;
}
return from;
}
/** Next player after `from` (exclusive) who still has cards. */
function nextWithCards(s: DalmutiState, from: PlayerId): PlayerId {
const n = s.order.length;
const i0 = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const p = s.order[(i0 + k) % n]!;
if (s.hands[p]!.length > 0) return p;
}
return from;
}
function newTurn(s: DalmutiState, p: PlayerId): void {
s.current = p;
s.turnNo += 1;
}
function startTrick(s: DalmutiState, leader: PlayerId, ctx: Ctx): void {
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
s.trick = { leader, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
ctx.events.push({ type: 'trickStart', leader });
newTurn(s, leader);
}
// ---------------------------------------------------------------- setup / rounds
/** §4-1: everyone draws one card; ties re-draw among themselves (fresh shuffle each pass). */
export function drawFirstRanks(players: PlayerId[], deck: DCard[], rng: Rng): { order: PlayerId[]; draws: Record<PlayerId, number[]> } {
const draws: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p, [] as number[]]));
let passes = 0;
const resolve = (group: PlayerId[]): PlayerId[] => {
if (group.length <= 1) return group;
if (passes++ >= MAX_REDRAWS) return group;
const shuffled = rng.shuffle(deck);
group.forEach((p, i) => draws[p]!.push(shuffled[i]!.n));
const drawn = new Map(group.map((p, i) => [p, shuffled[i]!.n]));
const nums = [...new Set(drawn.values())].sort((a, b) => a - b);
// Buckets first: re-draws inside one bucket must not change another bucket's membership.
const buckets = nums.map((n) => group.filter((p) => drawn.get(p) === n));
return buckets.flatMap((b) => resolve(b));
};
return { order: resolve([...players]), draws };
}
/** §4-3: shuffle and deal one at a time from the top rank; earlier ranks get the extra cards. */
function deal(s: DalmutiState, ctx: Ctx): void {
const deck = ctx.rng.shuffle(deckFor(s.options.removeHighCards));
s.hands = Object.fromEntries(s.players.map((p) => [p, [] as DCard[]]));
deck.forEach((c, i) => s.hands[s.order[i % s.order.length]!]!.push(c));
for (const p of s.players) s.hands[p] = sortHand(s.hands[p]!);
s.discard = [];
s.taxLog = [];
s.finishOrder = [];
s.revolution = 'none';
s.revolutionBy = null;
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
s.current = s.order[0]!;
ctx.events.push({ type: 'dealt', round: s.round, order: [...s.order], counts: s.order.map((p) => s.hands[p]!.length) });
if (s.options.revolution) {
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
s.turnNo += 1;
} else collectTax(s, ctx);
}
/** Best cards for tax (§4-5): lowest numbers by card order; jesters first only with `jesterTaxable`. */
export function taxCards(hand: DCard[], count: number, jesterTaxable: boolean): DCard[] {
const sorted = sortHand(hand);
const pool = jesterTaxable ? [...sorted.filter(isJ), ...sorted.filter((c) => !isJ(c))] : [...sorted.filter((c) => !isJ(c)), ...sorted.filter(isJ)];
return pool.slice(0, count);
}
/** Default return / timeout lead: highest number cards first, jesters last. */
export function worstCards(hand: DCard[], count: number): DCard[] {
const sorted = sortHand(hand);
return [...sorted.filter((c) => !isJ(c)).reverse(), ...sorted.filter(isJ)].slice(0, count);
}
function moveCards(s: DalmutiState, from: PlayerId, to: PlayerId, ids: string[]): void {
const moving = s.hands[from]!.filter((c) => ids.includes(c.id));
s.hands[from] = s.hands[from]!.filter((c) => !ids.includes(c.id));
s.hands[to] = sortHand([...s.hands[to]!, ...moving]);
}
function collectTax(s: DalmutiState, ctx: Ctx): void {
const o = s.order;
const n = o.length;
const pairs: [PlayerId, PlayerId, number][] = [
[o[n - 1]!, o[0]!, 2],
[o[n - 2]!, o[1]!, 1],
];
const owed: Extract<DalmutiState['phase'], { kind: 'taxReturn' }>['owed'] = {};
for (const [from, to, count] of pairs) {
const ids = taxCards(s.hands[from]!, count, s.options.jesterTaxable).map((c) => c.id);
moveCards(s, from, to, ids);
s.taxLog.push({ kind: 'tax', from, to, cardIds: ids });
owed[to] = { to: from, count, done: false };
ctx.events.push({ type: 'tax', from, to, count });
log(s, { t: 'tax', from, to, n: count });
}
s.phase = { kind: 'taxReturn', owed };
s.turnNo += 1;
}
function startPlaying(s: DalmutiState, ctx: Ctx): void {
s.phase = { kind: 'playing' };
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
ctx.events.push({ type: 'trickStart', leader: s.order[0]! });
newTurn(s, s.order[0]!);
}
function endRevolutionCheck(s: DalmutiState, ctx: Ctx): void {
const by = s.revolutionBy;
if (by) {
const great = by === s.order[s.order.length - 1];
s.revolution = great ? 'great' : 'revolution';
if (great) s.order = [...s.order].reverse();
ctx.events.push({ type: 'revolution', player: by, kind: s.revolution, order: [...s.order] });
log(s, { t: 'revolution', p: by, kind: s.revolution });
startPlaying(s, ctx);
return;
}
collectTax(s, ctx);
}
function endRound(s: DalmutiState, ctx: Ctx): void {
const last = withCards(s);
const order = [...s.finishOrder, ...last];
for (const p of last) {
s.discard.push(...s.hands[p]!.map((c) => c.id));
s.hands[p] = [];
}
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
s.trick = { ...s.trick, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
const n = order.length;
const points = Object.fromEntries(order.map((p, i) => [p, n - 1 - i]));
for (const p of order) s.totals[p] = (s.totals[p] ?? 0) + points[p]!;
s.roundResults.push({ round: s.round, order, revolution: s.revolution, points });
ctx.events.push({ type: 'roundEnd', round: s.round, order });
log(s, { t: 'round', round: s.round });
if (s.round >= s.options.rounds) {
finish(s, ctx);
return;
}
s.round += 1;
s.order = order;
deal(s, ctx);
}
/** Final ranking (§6): total points (ties → better last-round rank) or the last round's order. */
export function finalOf(s: Pick<DalmutiState, 'roundResults' | 'totals' | 'players' | 'options'>): DalmutiFinal {
const last = s.roundResults[s.roundResults.length - 1]!;
const lastRank = (p: PlayerId) => last.order.indexOf(p);
const sorted =
s.options.scoring === 'last-round' ? [...last.order] : [...s.players].sort((a, b) => s.totals[b]! - s.totals[a]! || lastRank(a) - lastRank(b));
const players = sorted.map((p, i) => ({
player: p,
rank: i + 1,
total: s.totals[p] ?? 0,
roundRanks: s.roundResults.map((r) => r.order.indexOf(p) + 1),
}));
const rounds = s.roundResults.length;
let summary: string;
if (s.options.scoring === 'last-round') {
summary = `${rounds}라운드를 마쳤어요. 마지막 라운드에서 가장 먼저 손패를 다 낸 사람이 1등이에요!`;
} else {
const top = players[0]!;
const tied = players.filter((x) => x.total === top.total).length > 1;
summary =
rounds === 1
? '가장 먼저 손패를 다 낸 사람이 1등이에요!'
: `${rounds}라운드 동안 순위 점수 ${top.total}점을 모아 가장 높은 신분을 지켰어요!${tied ? ' (같은 점수는 마지막 라운드 순위로 정했어요)' : ''}`;
}
return { ranking: sorted.map((p) => [p]), players, summary };
}
function finish(s: DalmutiState, ctx: Ctx): void {
s.final = finalOf(s);
s.phase = { kind: 'finished' };
ctx.events.push({ type: 'gameEnd', ranking: s.final.ranking });
}
// ---------------------------------------------------------------- playing
function playCards(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
const hand = s.hands[p]!;
const cards = ids.map((id) => hand.find((c) => c.id === id)!);
const rank = setRank(cards)!;
const play: Play & { by: PlayerId } = { cardIds: [...ids], count: ids.length, rank, by: p };
s.hands[p] = hand.filter((c) => !ids.includes(c.id));
s.trick.top = play;
s.trick.pile.push(play);
s.trick.passesSinceTop = [];
const faces = sortHand(cards).map((c) => c.n);
ctx.events.push({ type: 'played', player: p, cards: faces, count: play.count, rank });
log(s, { t: 'play', p, cards: faces, count: play.count, rank });
if (s.hands[p]!.length === 0) {
s.finishOrder.push(p);
ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length });
log(s, { t: 'out', p, place: s.finishOrder.length });
}
if (withCards(s).length <= 1) {
endRound(s, ctx);
return;
}
afterMove(s, p, ctx);
}
function pass(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
s.trick.passesSinceTop.push(p);
if (s.options.passLocksOut && !s.trick.lockedOut.includes(p)) s.trick.lockedOut.push(p);
ctx.events.push({ type: 'passed', player: p });
log(s, { t: 'pass', p });
afterMove(s, p, ctx);
}
/** §8.2: trick ends when everyone with cards except the top player has passed (or is locked out). */
function afterMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
const top = s.trick.top!;
const waitingOn = withCards(s).filter((x) => x !== top.by && !s.trick.passesSinceTop.includes(x) && !s.trick.lockedOut.includes(x));
if (waitingOn.length === 0) {
const leader = s.hands[top.by]!.length > 0 ? top.by : nextWithCards(s, top.by);
log(s, { t: 'trick', p: leader });
startTrick(s, leader, ctx);
return;
}
newTurn(s, nextActor(s, p));
}
/** What `p` may play now (rank, set sizes). */
export function choicesFor(s: DalmutiState, p: PlayerId): Choice[] {
if (s.phase.kind !== 'playing' || s.current !== p) return [];
const hand = s.hands[p]!;
const J = hand.filter(isJ).length;
const top = s.trick.top;
const out: Choice[] = [];
for (let r = 1; r <= 12; r++) {
const have = hand.filter((c) => c.n === r).length;
if (have === 0) continue;
if (!top) out.push({ rank: r, have, counts: Array.from({ length: have + J }, (_, i) => i + 1), needsJester: false });
else if (r < top.rank && have + J >= top.count) out.push({ rank: r, have, counts: [top.count], needsJester: have < top.count });
}
if (J > 0 && !top) out.push({ rank: JESTER, have: J, counts: Array.from({ length: J }, (_, i) => i + 1), needsJester: false });
return out;
}
/** Card ids for a set: `count` cards of `rank`, using at least `minJesters` jesters (more if needed). */
export function pickSet(hand: DCard[], rank: number, count: number, minJesters = 0): string[] | null {
const jesters = sortHand(hand.filter(isJ));
if (rank === JESTER) return count <= jesters.length ? jesters.slice(0, count).map((c) => c.id) : null;
const nums = sortHand(hand.filter((c) => c.n === rank));
const j = Math.max(minJesters, count - nums.length);
if (j > jesters.length || count - j < 1 || count - j > nums.length) return null;
return [...nums.slice(0, count - j), ...jesters.slice(0, j)].map((c) => c.id);
}
/** §8.5 lead timeout: all cards of the highest number; only jesters left → all jesters. */
export function timeoutLead(hand: DCard[]): string[] {
const nums = hand.filter((c) => !isJ(c));
if (nums.length === 0) return hand.map((c) => c.id);
const hi = Math.max(...nums.map((c) => c.n));
return sortHand(nums.filter((c) => c.n === hi)).map((c) => c.id);
}
function validatePlay(s: DalmutiState, actor: PlayerId, a: PlayAction): Validation {
if (s.phase.kind !== 'playing') return fail('지금은 카드를 낼 수 없어요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
if (s.trick.lockedOut.includes(actor)) return fail('이번 트릭에서는 이미 패스했어요.');
if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.');
const hand = s.hands[actor]!;
const cards = a.cardIds.map((id) => hand.find((c) => c.id === id));
if (cards.some((c) => !c)) return fail('손에 없는 카드예요.');
const rank = setRank(cards as DCard[]);
if (rank === null) return fail('같은 숫자끼리만 낼 수 있어요.');
const top = s.trick.top;
if (top) {
if (a.cardIds.length !== top.count) return fail(`앞사람과 같은 장수(${top.count}장)로 내야 해요.`);
if (rank >= top.rank) return fail('더 낮은 숫자만 낼 수 있어요.');
}
return ok;
}
function validateReturn(s: DalmutiState, actor: PlayerId, ids: string[]): Validation {
if (s.phase.kind !== 'taxReturn') return fail('지금은 카드를 돌려줄 때가 아니에요.');
const owe = s.phase.owed[actor];
if (!owe) return fail('돌려줄 카드가 없어요.');
if (owe.done) return fail('이미 카드를 돌려줬어요.');
if (ids.length !== owe.count) return fail(`돌려줄 카드 ${owe.count}장을 골라 주세요.`);
if (new Set(ids).size !== ids.length) return fail('같은 카드를 두 번 고를 수 없어요.');
if (ids.some((id) => !s.hands[actor]!.some((c) => c.id === id))) return fail('손에 없는 카드예요.');
return ok;
}
function returnTax(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
if (s.phase.kind !== 'taxReturn') throw new Error('not in taxReturn');
const owe = s.phase.owed[p]!;
moveCards(s, p, owe.to, ids);
owe.done = true;
s.taxLog.push({ kind: 'return', from: p, to: owe.to, cardIds: [...ids] });
ctx.events.push({ type: 'taxReturned', from: p, to: owe.to, count: ids.length });
log(s, { t: 'return', from: p, to: owe.to, n: ids.length });
if (Object.values(s.phase.owed).every((x) => x.done)) startPlaying(s, ctx);
}
function confirmHand(s: DalmutiState, p: PlayerId, declare: boolean, ctx: Ctx): void {
if (s.phase.kind !== 'revolutionCheck') throw new Error('not in revolutionCheck');
s.phase.confirmed[p] = true;
if (declare) {
s.revolutionBy = p;
ctx.events.push({ type: 'revolutionDeclared', player: p });
}
if (s.players.every((x) => (s.phase as { confirmed: Record<PlayerId, boolean> }).confirmed[x])) endRevolutionCheck(s, ctx);
}
const hasBothJesters = (s: DalmutiState, p: PlayerId) => s.hands[p]!.filter(isJ).length === 2;
const canDeclare = (s: DalmutiState, p: PlayerId) =>
s.phase.kind === 'revolutionCheck' && !s.phase.confirmed[p] && s.revolutionBy === null && hasBothJesters(s, p);
function waiting(s: DalmutiState): PlayerId[] {
const ph = s.phase;
switch (ph.kind) {
case 'revolutionCheck':
return s.players.filter((p) => !ph.confirmed[p]);
case 'taxReturn':
return s.players.filter((p) => ph.owed[p] && !ph.owed[p]!.done);
case 'playing':
return [s.current];
case 'finished':
return [];
}
}
function deadlineMs(s: DalmutiState): number | null {
const w = waiting(s);
const allAway = w.length > 0 && w.every((p) => s.away[p]);
switch (s.phase.kind) {
case 'revolutionCheck':
return allAway ? AWAY_MS : REVOLUTION_MS;
case 'taxReturn':
return allAway ? AWAY_MS : TAX_MS;
case 'playing':
return allAway ? AWAY_MS : s.options.turnSeconds * 1000;
case 'finished':
return null;
}
}
function timeoutMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
const ph = s.phase;
switch (ph.kind) {
case 'revolutionCheck':
confirmHand(s, p, false, ctx);
break;
case 'taxReturn':
returnTax(s, p, worstCards(s.hands[p]!, ph.owed[p]!.count).map((c) => c.id), ctx);
break;
case 'playing':
if (s.trick.top) pass(s, p, ctx);
else playCards(s, p, timeoutLead(s.hands[p]!), ctx);
break;
}
}
// ---------------------------------------------------------------- view
const faceOf = (id: string): number => (id.startsWith('j') ? JESTER : Number(id.slice(1, id.indexOf('-'))));
function playView(pl: Play & { by: PlayerId }): PlayView {
return { by: pl.by, count: pl.count, rank: pl.rank, cards: pl.cardIds.map(faceOf).sort((a, b) => a - b) };
}
// ---------------------------------------------------------------- definition
export const dalmuti: Def = {
id: 'dalmuti',
nameKo: '달무티',
minPlayers: 4,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: dalmutiOptionsSchema.parse({}),
optionsSchema: dalmutiOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: dalmutiActionSchema as unknown as Def['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 4 || players.length > 8) throw new Error(`dalmuti: ${players.length} players not allowed`);
const deck = deckFor(options.removeHighCards);
let order: PlayerId[];
let firstDraw: Record<PlayerId, number[]> | null = null;
if (options.firstRanking === 'draw') {
const d = drawFirstRanks(players, deck, rng);
order = d.order;
firstDraw = d.draws;
} else order = rng.shuffle(players);
const s: DalmutiState = {
options,
players: [...players],
order,
round: 1,
hands: {},
discard: [],
phase: { kind: 'playing' },
revolution: 'none',
revolutionBy: null,
firstDraw,
taxLog: [],
trick: { leader: order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] },
current: order[0]!,
finishOrder: [],
roundResults: [],
totals: Object.fromEntries(players.map((p) => [p, 0])),
turnStartedAt: now,
turnNo: 0,
seq: 0,
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
away: Object.fromEntries(players.map((p) => [p, false])),
left: Object.fromEntries(players.map((p) => [p, false])),
log: [],
final: null,
};
deal(s, { rng, now, events: [] });
return s;
},
validate(s, actor, a) {
if (!s.players.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'leave':
return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'confirmHand': {
const ph = s.phase;
if (ph.kind !== 'revolutionCheck') return fail('지금은 손패를 확인할 때가 아니에요.');
if (ph.confirmed[actor]) return fail('이미 확인했어요.');
if (a.declareRevolution && !hasBothJesters(s, actor)) return fail('광대 2장이 있어야 혁명할 수 있어요.');
return ok;
}
case 'returnTax':
return validateReturn(s, actor, a.cardIds);
case 'play':
return validatePlay(s, actor, a);
case 'pass':
if (s.phase.kind !== 'playing') return fail('지금은 패스할 수 없어요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
if (!s.trick.top) return fail('새로 시작할 때는 패스할 수 없어요.');
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
const s = cloneState(prev);
const ctx: Ctx = { rng, now, events: [] };
const before = `${s.turnNo}|${s.phase.kind}`;
s.seq += 1;
if (a.type !== 'timeout' && a.type !== 'leave') {
s.timeoutsInRow[actor] = 0;
s.away[actor] = !!s.left[actor];
}
switch (a.type) {
case 'confirmHand':
confirmHand(s, actor, !!a.declareRevolution, ctx);
break;
case 'returnTax':
returnTax(s, actor, a.cardIds, ctx);
break;
case 'play':
playCards(s, actor, a.cardIds, ctx);
break;
case 'pass':
pass(s, actor, ctx);
break;
case 'leave':
// Not eliminated (§8.5): the seat keeps playing on automatic moves.
s.left[actor] = true;
s.away[actor] = true;
ctx.events.push({ type: 'left', player: actor });
log(s, { t: 'left', p: actor });
break;
case 'timeout': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
s.away[actor] = true;
ctx.events.push({ type: 'away', player: actor });
}
ctx.events.push({ type: 'timeout', player: actor });
log(s, { t: 'timeout', p: actor });
timeoutMove(s, actor, ctx);
break;
}
}
if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now;
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.players.includes(viewer) ? viewer : null;
const ph = s.phase;
const n = s.order.length;
const top = s.trick.top;
const w = waiting(s);
let my: DalmutiView['my'] = null;
if (me) {
const hand = s.hands[me]!;
const mineNow = ph.kind === 'playing' && s.current === me;
const owe = ph.kind === 'taxReturn' && ph.owed[me] && !ph.owed[me]!.done ? { to: ph.owed[me]!.to, count: ph.owed[me]!.count } : null;
const handIds = new Set(hand.map((c) => c.id));
const received = s.taxLog.filter((t) => t.to === me).flatMap((t) => t.cardIds).filter((id) => handIds.has(id));
my = {
hand,
choices: choicesFor(s, me),
jesters: hand.filter(isJ).length,
canPlay: mineNow,
canPass: mineNow && !!top,
leading: mineNow && !top,
lockedOut: s.trick.lockedOut.includes(me),
canConfirm: ph.kind === 'revolutionCheck' && !ph.confirmed[me],
...(canDeclare(s, me) ? { canDeclareRevolution: true } : {}),
owe,
receivedIds: received,
suggestedReturn: owe ? worstCards(hand, owe.count).map((c) => c.id) : [],
};
}
const playing = ph.kind === 'playing';
return {
options: s.options,
me,
round: s.round,
phase: ph.kind,
seats: s.order.map((p, i) => ({
id: p,
rank: i,
title: titleOf(i, n),
cards: s.hands[p]!.length,
isTurn: w.includes(p),
passed: playing && s.trick.passesSinceTop.includes(p),
lockedOut: playing && s.trick.lockedOut.includes(p),
place: s.finishOrder.includes(p) ? s.finishOrder.indexOf(p) + 1 : null,
away: !!s.away[p],
left: !!s.left[p],
confirmed: ph.kind === 'revolutionCheck' ? !!ph.confirmed[p] : false,
total: s.totals[p] ?? 0,
})),
current: playing ? s.current : null,
trick: playing ? { leader: s.trick.leader, top: top ? playView(top) : null, pile: s.trick.pile.map((pl) => playView(pl)) } : null,
finishOrder: [...s.finishOrder],
revolution: s.revolution,
revolutionBy: s.revolutionBy,
firstDraw: s.round === 1 ? s.firstDraw : null,
tax: s.taxLog.map((t) => ({
kind: t.kind,
from: t.from,
to: t.to,
count: t.cardIds.length,
cards: me && (me === t.from || me === t.to) ? t.cardIds.map(faceOf).sort((a, b) => a - b) : null,
})),
taxPending: ph.kind === 'taxReturn' ? Object.entries(ph.owed).map(([from, o]) => ({ from, to: o.to, count: o.count, done: o.done })) : [],
roundResults: s.roundResults,
discardCount: s.discard.length,
deckSize: deckSize(s.options),
my,
turnStartedAt: s.turnStartedAt,
deadlineTotalMs: deadlineMs(s) ?? 0,
turnNo: s.turnNo,
log: s.log.slice(-LOG_VIEW),
result: s.final,
};
},
activePlayers: waiting,
deadline(s) {
const ms = deadlineMs(s);
return ms === null ? null : s.turnStartedAt + ms;
},
timeoutPlayers: waiting,
timeoutNotBefore(s) {
return s.phase.kind === 'playing' ? s.turnStartedAt + MIN_TURN_MS : null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.players.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, scores: Object.fromEntries(s.final.players.map((p) => [p.player, p.total])), summary: s.final.summary, reason: 'normal' };
},
legalActions(s, p) {
if (!s.players.includes(p) || s.phase.kind === 'finished') return [];
const ph = s.phase;
const out: DalmutiAction[] = [];
if (ph.kind === 'revolutionCheck') {
if (ph.confirmed[p]) return [];
out.push({ type: 'confirmHand' });
if (canDeclare(s, p)) out.push({ type: 'confirmHand', declareRevolution: true });
return out;
}
if (ph.kind === 'taxReturn') {
const owe = ph.owed[p];
if (!owe || owe.done) return [];
// One representative card per number keeps the list small; pairs of the same number included.
const hand = sortHand(s.hands[p]!);
const reps: DCard[] = [];
for (const c of hand) if (reps.filter((r) => r.n === c.n).length < owe.count) reps.push(c);
if (owe.count === 1) return reps.map((c) => ({ type: 'returnTax', cardIds: [c.id] }));
for (let i = 0; i < reps.length; i++) for (let j = i + 1; j < reps.length; j++) out.push({ type: 'returnTax', cardIds: [reps[i]!.id, reps[j]!.id] });
return out;
}
if (s.current !== p) return [];
const hand = s.hands[p]!;
const J = hand.filter(isJ).length;
for (const ch of choicesFor(s, p)) {
for (const count of ch.counts) {
if (ch.rank === JESTER) {
out.push({ type: 'play', cardIds: pickSet(hand, JESTER, count)! });
continue;
}
const minJ = Math.max(0, count - ch.have);
for (let j = minJ; j <= Math.min(J, count - 1); j++) {
const ids = pickSet(hand, ch.rank, count, j);
if (ids) out.push({ type: 'play', cardIds: ids });
}
}
}
if (s.trick.top) out.push({ type: 'pass' });
return out;
},
/** Own hand (card ids exactly as they would appear in a view). */
secretsOf(s, player) {
return (s.hands[player] ?? []).map((c) => c.id);
},
};

View File

@@ -0,0 +1,287 @@
/** 달무티 options, cards, actions, state and view types (docs/games/dalmuti.md §2, §3, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
// ---------------------------------------------------------------- cards
/** A card: n = 1..12, jester (광대) = 13. */
export interface DCard {
id: string;
n: number;
}
export const JESTER = 13;
/**
* Card names shown on screen. The original rank names are the publisher's wording (§12), so the site
* uses its own generic names with the same numbers.
*/
export const CARD_NAME: Record<number, string> = {
1: '왕',
2: '왕비',
3: '재상',
4: '공작',
5: '백작',
6: '기사',
7: '학자',
8: '상인',
9: '요리사',
10: '목동',
11: '광부',
12: '농부',
13: '광대',
};
/** 80 cards: n copies of n (1..12) + 2 jesters, in a fixed order (n, copy). */
export const FULL_DECK: readonly DCard[] = [
...Array.from({ length: 12 }, (_, i) => i + 1).flatMap((n) => Array.from({ length: n }, (_, k) => ({ id: `c${n}-${k + 1}`, n }))),
{ id: 'j1', n: JESTER },
{ id: 'j2', n: JESTER },
];
const ORDER = new Map(FULL_DECK.map((c, i) => [c.id, i]));
/** Fixed card order (number, then copy) used for tie-breaks and sorting. */
export const cardOrder = (id: string): number => ORDER.get(id) ?? 999;
export function deckFor(removeHighCards: boolean): DCard[] {
return FULL_DECK.filter((c) => !removeHighCards || c.n <= 8 || c.n === JESTER);
}
// ---------------------------------------------------------------- options / actions
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 dalmutiOptionsSchema = z.object({
rounds: lit<1 | 3 | 5 | 7>(1, 3, 5, 7).default(3).meta({ title: '라운드 수', labels: { 1: '1라운드', 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
scoring: z
.enum(['rank-points', 'last-round'])
.default('rank-points')
.meta({ title: '최종 순위 방식', labels: { 'rank-points': '라운드별 순위 점수 합', 'last-round': '마지막 라운드 순위' } }),
firstRanking: z
.enum(['draw', 'random'])
.default('draw')
.meta({ title: '첫 라운드 신분 정하기', labels: { draw: '카드 한 장씩 뽑기', random: '무작위' } }),
revolution: z.boolean().default(true).meta({ title: '혁명 규칙 사용(광대 2장으로 선언)' }),
jesterTaxable: z.boolean().default(false).meta({ title: '세금 낼 때 광대도 좋은 카드로 내기' }),
passLocksOut: z.boolean().default(false).meta({ title: '한 번 패스하면 그 트릭에서 다시 못 내기' }),
removeHighCards: z.boolean().default(false).meta({ title: '9~12 카드를 빼고 하기(38장)' }),
turnSeconds: lit<15 | 20 | 30 | 45>(15, 20, 30, 45)
.default(20)
.meta({ title: '한 턴 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 45: '45초' } }),
});
export type DalmutiOptions = z.infer<typeof dalmutiOptionsSchema>;
export const dalmutiActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('confirmHand'), declareRevolution: z.boolean().optional() }),
z.object({ type: z.literal('returnTax'), cardIds: z.array(z.string().max(8)).min(1).max(2) }),
z.object({ type: z.literal('play'), cardIds: z.array(z.string().max(8)).min(1).max(14) }),
z.object({ type: z.literal('pass') }),
]);
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
export type DalmutiAction = z.infer<typeof dalmutiActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<DalmutiAction, { type: 'play' }>;
// ---------------------------------------------------------------- state
/** A set on the table. rank: the number of the number cards, or 13 for jesters alone. */
export interface Play {
cardIds: string[];
count: number;
rank: number;
}
export type RevolutionKind = 'none' | 'revolution' | 'great';
/**
* The draw (§4-1) and the end of a round (§8.2 `roundOver`) are resolved at once; their results stay in
* `firstDraw` / `roundResults` for the screen. So the state only rests in these phases.
*/
export type Phase =
| { kind: 'revolutionCheck'; confirmed: Record<PlayerId, boolean> }
| { kind: 'taxReturn'; owed: Record<PlayerId, { to: PlayerId; count: number; done: boolean }> }
| { kind: 'playing' }
| { kind: 'finished' };
export interface TaxMove {
kind: 'tax' | 'return';
from: PlayerId;
to: PlayerId;
cardIds: string[];
}
export interface Trick {
leader: PlayerId;
top: (Play & { by: PlayerId }) | null;
passesSinceTop: PlayerId[];
lockedOut: PlayerId[];
pile: (Play & { by: PlayerId })[];
}
export interface RoundResult {
round: number;
/** Finish order = next round's seating; [0] got the most points. */
order: PlayerId[];
revolution: RevolutionKind;
points: Record<PlayerId, number>;
}
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
| { seq: number; t: 'play'; p: PlayerId; cards: number[]; count: number; rank: number }
| { seq: number; t: 'pass'; p: PlayerId }
| { seq: number; t: 'trick'; p: PlayerId }
| { seq: number; t: 'out'; p: PlayerId; place: number }
| { seq: number; t: 'revolution'; p: PlayerId; kind: 'revolution' | 'great' }
| { seq: number; t: 'tax'; from: PlayerId; to: PlayerId; n: number }
| { seq: number; t: 'return'; from: PlayerId; to: PlayerId; n: number }
| { seq: number; t: 'round'; round: number }
| { seq: number; t: 'timeout'; p: PlayerId }
| { seq: number; t: 'left'; p: PlayerId };
export interface PlayerFinal {
player: PlayerId;
rank: number;
total: number;
roundRanks: number[];
}
export interface DalmutiFinal {
ranking: PlayerId[][];
players: PlayerFinal[];
summary: string;
}
export interface DalmutiState {
options: DalmutiOptions;
/** Participants (fixed). */
players: PlayerId[];
/** This round's ranks = turn order; [0] = 대달무티. */
order: PlayerId[];
round: number;
/** Secret. */
hands: Record<PlayerId, DCard[]>;
/** Cards from finished tricks and the last player's leftovers (not sent to clients). */
discard: string[];
phase: Phase;
revolution: RevolutionKind;
revolutionBy: PlayerId | null;
/** First-round rank draw (§4-1): numbers each player drew, re-draws included. null for `random`. */
firstDraw: Record<PlayerId, number[]> | null;
/** This round's tax moves; card contents only for the two people involved. */
taxLog: TaxMove[];
trick: Trick;
current: PlayerId;
finishOrder: PlayerId[];
roundResults: RoundResult[];
totals: Record<PlayerId, number>;
turnStartedAt: number;
/** Changes whenever a new turn or phase starts (resets the timer). */
turnNo: number;
seq: number;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
log: LogEntry[];
final: DalmutiFinal | null;
}
// ---------------------------------------------------------------- view
export type Title = '대달무티' | '소달무티' | '평민' | '소농노' | '대농노';
export interface SeatView {
id: PlayerId;
/** 0-based rank this round (= turn order). */
rank: number;
title: Title;
cards: number;
isTurn: boolean;
/** Passed since the current top set. */
passed: boolean;
lockedOut: boolean;
/** Finish place this round (1-based) once out of cards. */
place: number | null;
away: boolean;
left: boolean;
/** revolutionCheck: has pressed "확인". */
confirmed: boolean;
total: number;
}
export interface PlayView {
by: PlayerId;
count: number;
rank: number;
/** Faces (13 = jester), no ids. */
cards: number[];
}
/** One number the player can play now. */
export interface Choice {
/** 1..12, or 13 for jesters alone. */
rank: number;
/** Number cards of this rank in hand (jesters for rank 13). */
have: number;
/** Legal set sizes. */
counts: number[];
/** Only possible by mixing in jesters (following). */
needsJester: boolean;
}
export interface TaxMoveView {
kind: 'tax' | 'return';
from: PlayerId;
to: PlayerId;
count: number;
/** Faces, only for the giver and the receiver. */
cards: number[] | null;
}
export interface DalmutiView {
options: DalmutiOptions;
me: PlayerId | null;
round: number;
phase: Phase['kind'];
seats: SeatView[];
current: PlayerId | null;
trick: {
leader: PlayerId;
top: PlayView | null;
pile: PlayView[];
} | null;
finishOrder: PlayerId[];
revolution: RevolutionKind;
revolutionBy: PlayerId | null;
firstDraw: Record<PlayerId, number[]> | null;
tax: TaxMoveView[];
/** taxReturn: who still has to give cards back. */
taxPending: { from: PlayerId; to: PlayerId; count: number; done: boolean }[];
roundResults: RoundResult[];
discardCount: number;
deckSize: number;
my: {
hand: DCard[];
choices: Choice[];
jesters: number;
canPlay: boolean;
canPass: boolean;
/** Leading a new trick (must play). */
leading: boolean;
lockedOut: boolean;
canConfirm: boolean;
/** Only in the viewer's own view (§8.3). */
canDeclareRevolution?: boolean;
/** Cards to give back now (taxReturn). */
owe: { to: PlayerId; count: number } | null;
/** Received tax cards still in hand (highlight). */
receivedIds: string[];
/** Default suggestion for the return: the highest number cards. */
suggestedReturn: string[];
} | null;
turnStartedAt: number;
deadlineTotalMs: number;
turnNo: number;
log: LogEntry[];
result: DalmutiFinal | null;
}

View File

@@ -16,6 +16,12 @@ import { gostop } from './gostop';
import { bang } from './bang'; import { bang } from './bang';
import { halliGalli } from './halli-galli'; import { halliGalli } from './halli-galli';
import { marble } from './marble'; import { marble } from './marble';
import { dalmuti } from './dalmuti';
import { splendor } from './splendor';
import { quoridor } from './quoridor';
import { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -35,4 +41,10 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[bang.id]: bang, [bang.id]: bang,
[halliGalli.id]: halliGalli, [halliGalli.id]: halliGalli,
[marble.id]: marble, [marble.id]: marble,
[dalmuti.id]: dalmuti,
[splendor.id]: splendor,
[quoridor.id]: quoridor,
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine,
}; };

View File

@@ -0,0 +1,492 @@
/**
* 라스베가스 (docs/games/las-vegas.md). Hidden info: the bill pile (only its size is public) and, with
* `hideMoney`, the amounts other players won (only the number of bills is public until the end).
*/
import type { ZodType } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
ALL_NOTES,
AWAY_AFTER,
AWAY_MS,
CASINO_VALUES,
HOUSE,
OWN_DICE,
autoPlaceValue,
diceLeft,
houseExtraFor,
lasVegasActionSchema,
lasVegasOptionsSchema,
neutralPerFor,
ownerOrder,
placementPreview,
refillCasinos,
settleCasino,
sum,
type CasinoValue,
type LasVegasAction,
type LasVegasOptions,
type LasVegasState,
type Note,
type Owner,
type Payout,
} from './rules';
export * from './rules';
// ---------------------------------------------------------------- result & view types
export interface LasVegasRankRow {
rank: number;
player: PlayerId;
total: number;
notes: Note[];
conceded: boolean;
}
export interface LasVegasResult {
ranking: LasVegasRankRow[];
winners: PlayerId[];
/** Points won per round. */
roundTotals: Record<PlayerId, number[]>;
summary: string;
reason: GameResult['reason'];
}
export interface LasVegasMoneyView {
count: number;
/** null when hidden from this viewer (`hideMoney`). */
total: number | null;
notes: Note[] | null;
}
export interface LasVegasPayoutView {
casino: number;
removedTies: Owner[];
/** `note`/`key` are null for another player's bill while `hideMoney` hides amounts. */
awards: { owner: Owner; note: Note | null; key: string | null }[];
returned: Note[];
}
export interface LasVegasChoice {
value: number;
own: number;
neutral: number;
/** For the player to move, after placing these dice (§9 미리 보기). */
standing: 'first' | 'award' | 'tie' | 'none';
note: Note | null;
countAfter: number;
}
export interface LasVegasView {
options: LasVegasOptions;
me: PlayerId | null;
seats: PlayerId[];
round: number;
rounds: number;
startPlayer: PlayerId;
/** Player to act, null once finished. */
current: PlayerId | null;
phase: LasVegasState['phase'];
pileCount: number;
casinos: { value: CasinoValue; notes: Note[]; dice: Partial<Record<Owner, number>> }[];
hand: Record<PlayerId, { own: number; neutral: number }>;
neutralPer: number;
houseExtra: number;
houseRoll: LasVegasState['houseRoll'];
lastRoll: LasVegasState['lastRoll'];
/** Number groups of the current roll with previews (phase `place`). */
choices: LasVegasChoice[];
/** What a timeout would pick now (phase `place`). */
autoValue: number | null;
lastPlace: LasVegasState['lastPlace'];
money: Record<PlayerId, LasVegasMoneyView>;
roundResults: { round: number; payouts: LasVegasPayoutView[] }[];
conceded: PlayerId[];
away: PlayerId[];
rollSeq: number;
turnSeq: number;
deadlineAt: number | null;
result: LasVegasResult | null;
}
// ---------------------------------------------------------------- state machine
type S = LasVegasState;
const curId = (s: S): PlayerId => s.seats[s.current]!;
const isIn = (s: S, p: PlayerId) => !s.conceded.includes(p);
const activeIds = (s: S) => s.seats.filter((p) => isIn(s, p));
/** Stable React key / leak-check token of one received bill. */
export const awardKey = (round: number, casino: number, owner: Owner, note: Note): string => `lv-r${round}c${casino}-${owner}-${note}`;
function activeOf(s: S): PlayerId[] {
return s.phase === 'finished' ? [] : [curId(s)];
}
function refreshDeadline(s: S, now: number): void {
if (s.phase === 'finished') {
s.deadlineAt = null;
return;
}
const away = (s.timeouts[curId(s)] ?? 0) >= AWAY_AFTER;
if (away) s.deadlineAt = now + AWAY_MS;
else s.deadlineAt = s.options.turnSeconds === null ? null : now + s.options.turnSeconds * 1000;
}
function finish(s: S, ev: GameEvent[]): void {
s.phase = 'finished';
s.lastRoll = null;
s.deadlineAt = null;
ev.push({ type: 'gameOver' });
}
function startRound(s: S, rng: Rng, ev: GameEvent[]): void {
for (const p of s.seats) s.hand[p] = isIn(s, p) ? { own: OWN_DICE, neutral: s.neutralPer } : { own: 0, neutral: 0 };
refillCasinos(s);
ev.push({ type: 'casinosRefilled', round: s.round, casinos: s.casinos.map((c) => [...c.notes]) });
s.houseRoll = null;
if (s.houseExtra > 0) {
const values: number[] = [];
for (let i = 0; i < s.houseExtra; i++) values.push(1 + rng.int(6));
for (const v of values) {
const c = s.casinos[v - 1]!;
c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + 1;
}
const player = s.seats[s.startSeat]!;
s.houseRoll = { round: s.round, player, values };
ev.push({ type: 'houseDicePlaced', player, values: [...values] });
}
s.current = diceLeft(s, s.seats[s.startSeat]!) > 0 ? s.startSeat : nextSeat(s.startSeat, s.seats.length, (i) => diceLeft(s, s.seats[i]!) === 0);
s.phase = 'roll';
s.lastRoll = null;
s.turnSeq++;
ev.push({ type: 'roundStart', round: s.round, player: curId(s) });
}
function settleRound(s: S, ev: GameEvent[]): void {
const payouts: Payout[] = [];
const back: Note[] = [];
const order = ownerOrder(s);
for (const c of s.casinos) {
const { removedTies, awards, returned } = settleCasino(c.notes, c.dice, order);
if (removedTies.length) ev.push({ type: 'tiesRemoved', casino: c.value, owners: [...removedTies] });
for (const a of awards) {
if (a.owner !== HOUSE) s.money[a.owner]!.push(a.note);
ev.push({ type: 'paid', casino: c.value, owner: a.owner, ...(a.owner === HOUSE || !s.options.hideMoney ? { note: a.note } : {}) });
}
back.push(...returned);
payouts.push({ casino: c.value, removedTies, awards, returned });
c.notes = [];
c.dice = {};
}
s.pile.push(...back);
if (back.length) ev.push({ type: 'notesReturned', notes: back });
s.roundResults.push({ round: s.round, payouts });
ev.push({ type: 'roundEnd', round: s.round });
}
/** Next seat with dice left (the same player again if only they have dice); settles the round when nobody has. */
function advance(s: S, rng: Rng, ev: GameEvent[]): void {
s.lastRoll = null;
const n = s.seats.length;
const hasDice = (i: number) => diceLeft(s, s.seats[i]!) > 0;
const next = nextSeat(s.current, n, (i) => !hasDice(i));
if (hasDice(next)) {
s.current = next;
s.phase = 'roll';
s.turnSeq++;
ev.push({ type: 'turnChanged', player: curId(s) });
return;
}
settleRound(s, ev);
if (s.round >= s.options.rounds) {
finish(s, ev);
return;
}
s.round++;
s.startSeat = nextSeat(s.startSeat, n, (i) => !isIn(s, s.seats[i]!));
startRound(s, rng, ev);
}
function doRoll(s: S, rng: Rng, ev: GameEvent[]): void {
const p = curId(s);
const h = s.hand[p]!;
const own: number[] = [];
const neutral: number[] = [];
for (let i = 0; i < h.own; i++) own.push(1 + rng.int(6));
for (let i = 0; i < h.neutral; i++) neutral.push(1 + rng.int(6));
s.lastRoll = { own, neutral };
s.phase = 'place';
s.rollSeq++;
ev.push({ type: 'rolled', player: p, own: [...own], neutral: [...neutral] });
}
function doPlace(s: S, value: number, auto: boolean, rng: Rng, ev: GameEvent[]): void {
const p = curId(s);
const roll = s.lastRoll;
if (!roll) throw new Error('las-vegas: place without a roll');
const ownCount = roll.own.filter((d) => d === value).length;
const neutralCount = roll.neutral.filter((d) => d === value).length;
if (ownCount + neutralCount === 0) throw new Error('las-vegas: value not rolled');
const c = s.casinos[value - 1]!;
if (ownCount) c.dice[p] = (c.dice[p] ?? 0) + ownCount;
if (neutralCount) c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + neutralCount;
s.hand[p]!.own -= ownCount;
s.hand[p]!.neutral -= neutralCount;
s.lastPlace = { player: p, value, own: ownCount, neutral: neutralCount, auto };
ev.push({ type: 'placed', player: p, value, ownCount, neutralCount, auto });
advance(s, rng, ev);
}
function doConcede(s: S, p: PlayerId, rng: Rng, ev: GameEvent[]): void {
const wasCurrent = curId(s) === p;
s.conceded.push(p);
s.hand[p] = { own: 0, neutral: 0 };
for (const c of s.casinos) delete c.dice[p];
ev.push({ type: 'conceded', player: p });
if (activeIds(s).length <= 1) {
finish(s, ev);
return;
}
if (wasCurrent) advance(s, rng, ev);
}
// ---------------------------------------------------------------- result
function rankRows(s: S): LasVegasRankRow[] {
const rows = s.seats.map((p) => ({ player: p, total: sum(s.money[p]!), notes: [...s.money[p]!].sort((a, b) => b - a), conceded: !isIn(s, p) }));
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total || b.notes.length - a.notes.length);
const out: LasVegasRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.conceded === r.conceded && prev.total === r.total && prev.notes.length === r.notes.length;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: S): LasVegasResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const roundTotals: Record<PlayerId, number[]> = {};
for (const p of s.seats)
roundTotals[p] = s.roundResults.map((rr) => sum(rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === p).map((a) => a.note))));
const top = ranking[0]!;
const early = s.conceded.length > 0 && activeIds(s).length <= 1;
let summary: string;
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${top.total}점 (지폐 ${top.notes.length}장)`;
else {
const second = ranking[1];
summary = `1위 ${top.total}점 (지폐 ${top.notes.length}장)${second ? ` · 2위 ${second.total}점` : ''}`;
}
if (early) summary = `다른 사람이 모두 기권했어요. ${summary}`;
return { ranking, winners, roundTotals, summary, reason: early ? 'resign' : 'normal' };
}
// ---------------------------------------------------------------- definition
export const lasVegas: GameDefinition<LasVegasState, LasVegasAction, LasVegasView, LasVegasOptions> = {
id: 'las-vegas',
nameKo: '라스베가스',
minPlayers: 2,
maxPlayers: 5,
stateVersion: 1,
defaultOptions: lasVegasOptionsSchema.parse({}),
optionsSchema: lasVegasOptionsSchema as unknown as ZodType<LasVegasOptions>,
actionSchema: lasVegasActionSchema as unknown as ZodType<LasVegasAction>,
playerCountError(options, count) {
return neutralPerFor(options.neutralDice, count) === null ? '5명일 때는 중립(흰) 주사위를 켤 수 없어요. "자동"이나 "끄기"로 바꿔 주세요.' : null;
},
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 5) throw new Error('las-vegas: 2~5명이 할 수 있어요.');
if (players.includes(HOUSE)) throw new Error('las-vegas: reserved player id');
const neutralPer = neutralPerFor(options.neutralDice, players.length);
if (neutralPer === null) throw new Error('las-vegas: 5명일 때는 중립 주사위를 켤 수 없어요.');
const pile = rng.shuffle(ALL_NOTES);
const startSeat = rng.int(players.length);
const s: S = {
options,
seats: [...players],
round: 1,
startSeat,
current: startSeat,
phase: 'roll',
pile,
casinos: CASINO_VALUES.map((value) => ({ value, notes: [], dice: {} })),
hand: {},
lastRoll: null,
money: Object.fromEntries(players.map((p) => [p, []])),
roundResults: [],
neutralPer,
houseExtra: houseExtraFor(neutralPer, players.length),
houseRoll: null,
lastPlace: null,
conceded: [],
timeouts: Object.fromEntries(players.map((p) => [p, 0])),
rollSeq: 0,
turnSeq: 0,
deadlineAt: null,
};
startRound(s, rng, []);
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
if (!s.money[actor]) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (!isIn(s, actor)) return fail('이미 기권했어요.');
if (a.type === 'CONCEDE') return ok;
if (a.type === 'TIMEOUT') return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.');
if (curId(s) !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'ROLL':
if (s.phase !== 'roll' || diceLeft(s, actor) === 0) return fail('지금은 굴릴 차례가 아니에요.');
return ok;
case 'PLACE': {
if (s.phase !== 'place' || !s.lastRoll) return fail('먼저 주사위를 굴려 주세요.');
const v = a.value;
if (!Number.isInteger(v) || !s.lastRoll.own.concat(s.lastRoll.neutral).includes(v)) return fail('그 숫자는 나오지 않았어요.');
return ok;
}
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const ev: GameEvent[] = [];
if (a.type === 'TIMEOUT') {
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
ev.push({ type: 'timeout', player: actor });
if (s.timeouts[actor] === AWAY_AFTER) ev.push({ type: 'away', player: actor });
if (s.phase === 'roll') doRoll(s, rng, ev);
else doPlace(s, autoPlaceValue(s, s.lastRoll!), true, rng, ev);
} else {
if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
switch (a.type) {
case 'ROLL':
if (s.phase !== 'roll') throw new Error('las-vegas: not the roll phase');
doRoll(s, rng, ev);
break;
case 'PLACE':
if (s.phase !== 'place') throw new Error('las-vegas: not the place phase');
doPlace(s, a.value, false, rng, ev);
break;
case 'CONCEDE':
doConcede(s, actor, rng, ev);
break;
}
}
refreshDeadline(s, now);
return { state: s, events: ev };
},
view(s, viewer) {
const me = viewer !== null && s.money[viewer] ? viewer : null;
const done = s.phase === 'finished';
const visible = (p: Owner) => done || !s.options.hideMoney || p === me || p === HOUSE;
const money: LasVegasView['money'] = {};
for (const p of s.seats) {
const notes = s.money[p]!;
money[p] = visible(p)
? { count: notes.length, total: sum(notes), notes: [...notes].sort((a, b) => b - a) }
: { count: notes.length, total: null, notes: null };
}
const roundResults = s.roundResults.map((rr) => ({
round: rr.round,
payouts: rr.payouts.map((po) => ({
casino: po.casino,
removedTies: [...po.removedTies],
awards: po.awards.map((aw) =>
visible(aw.owner)
? { owner: aw.owner, note: aw.note, key: awardKey(rr.round, po.casino, aw.owner, aw.note) }
: { owner: aw.owner, note: null, key: null },
),
returned: [...po.returned],
})),
}));
const cur = done ? null : curId(s);
const choices: LasVegasChoice[] = [];
if (cur && s.phase === 'place' && s.lastRoll) {
for (const v of CASINO_VALUES) {
const own = s.lastRoll.own.filter((d) => d === v).length;
const neutral = s.lastRoll.neutral.filter((d) => d === v).length;
if (own + neutral === 0) continue;
const pv = placementPreview(s, cur, v, own, neutral);
choices.push({ value: v, own, neutral, standing: pv.standing, note: pv.note, countAfter: pv.count });
}
}
return {
options: s.options,
me,
seats: [...s.seats],
round: s.round,
rounds: s.options.rounds,
startPlayer: s.seats[s.startSeat]!,
current: cur,
phase: s.phase,
pileCount: s.pile.length,
casinos: s.casinos.map((c) => ({ value: c.value, notes: [...c.notes], dice: { ...c.dice } })),
hand: Object.fromEntries(s.seats.map((p) => [p, { ...s.hand[p]! }])),
neutralPer: s.neutralPer,
houseExtra: s.houseExtra,
houseRoll: s.houseRoll,
lastRoll: s.lastRoll,
choices,
autoValue: cur && s.phase === 'place' && s.lastRoll ? autoPlaceValue(s, s.lastRoll) : null,
lastPlace: s.lastPlace,
money,
roundResults,
conceded: [...s.conceded],
away: s.seats.filter((p) => isIn(s, p) && (s.timeouts[p] ?? 0) >= AWAY_AFTER),
rollSeq: s.rollSeq,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
result: done ? buildResult(s) : null,
};
},
activePlayers: activeOf,
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
if (s.phase === 'finished' || !s.money[player] || !isIn(s, 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.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), scores, summary: r.summary, reason: r.reason };
},
/** Test helper: ROLL, or one PLACE per rolled number (CONCEDE left out). */
legalActions(s, player) {
if (s.phase === 'finished' || curId(s) !== player || !isIn(s, player)) return [];
if (s.phase === 'roll') return [{ type: 'ROLL' }];
const vals = [...new Set([...s.lastRoll!.own, ...s.lastRoll!.neutral])].sort((a, b) => a - b);
return vals.map((value) => ({ type: 'PLACE' as const, value }));
},
/** Test helper: keys of this player's bills that other viewers must not see (while amounts are hidden). */
secretsOf(s, player) {
if (s.phase === 'finished' || !s.options.hideMoney) return [];
return s.roundResults.flatMap((rr) =>
rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === player).map((a) => awardKey(rr.round, po.casino, player, a.note))),
);
},
};

View File

@@ -0,0 +1,712 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
ALL_NOTES,
AWAY_AFTER,
AWAY_MS,
HOUSE,
MIN_CASINO_SUM,
NOTE_COUNTS,
OWN_DICE,
autoPlaceValue,
lasVegas as lv,
lasVegasActionSchema,
lasVegasOptionsSchema,
refillCasinos,
settleCasino,
sum,
type LasVegasAction,
type LasVegasOptions,
type LasVegasState,
type LasVegasView,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000_000;
const rng = () => SeededRng.fromSeed([5, 4, 3, 2]);
function make(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C'], seed = 1): LasVegasState {
const options = lasVegasOptionsSchema.parse(opts);
return lv.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
}
/** A game whose round-1 first player is seat 0 (seeds are searched). */
function makeFirst(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C']): LasVegasState {
for (let seed = 1; seed < 500; seed++) {
const s = make(opts, players, seed);
if (s.startSeat === 0 && (s.houseExtra === 0 || s.houseRoll)) return s;
}
throw new Error('no seed starts at seat 0');
}
function actE(s: LasVegasState, p: string, a: LasVegasAction, r: SeededRng = rng()): { state: LasVegasState; events: GameEvent[] } {
const v = lv.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return lv.apply(s, p, a, { rng: r, now: (clock += 1000) });
}
const act = (s: LasVegasState, p: string, a: LasVegasAction, r?: SeededRng) => actE(s, p, a, r).state;
const reject = (s: LasVegasState, p: string, a: LasVegasAction) => {
const v = lv.validate(s, p, a);
return v.ok ? null : v.reason;
};
const cur = (s: LasVegasState) => s.seats[s.current]!;
/** Rolls for the current player, then forces the dice (lengths must match the hand). */
function rolled(s: LasVegasState, own: number[], neutral: number[] = []): LasVegasState {
const p = cur(s);
const t = act(s, p, { type: 'ROLL' });
expect(t.lastRoll!.own.length).toBe(own.length);
expect(t.lastRoll!.neutral.length).toBe(neutral.length);
t.lastRoll = { own: [...own], neutral: [...neutral] };
return t;
}
/** Sets every casino's bills (missing = no bills); bills come out of the pile so 54 are conserved. */
function setBills(s: LasVegasState, bills: Record<number, number[]>): void {
const pool = [...s.pile, ...s.casinos.flatMap((c) => c.notes)];
for (const c of s.casinos) {
c.notes = [...(bills[c.value] ?? [])].sort((a, b) => b - a);
for (const n of c.notes) {
const i = pool.indexOf(n);
if (i < 0) throw new Error(`no bill ${n} left`);
pool.splice(i, 1);
}
}
s.pile = pool;
}
/** Puts dice on a casino straight from the owners' hands (house dice need no hand). */
function putDice(s: LasVegasState, value: number, dice: Record<string, number>): void {
const c = s.casinos[value - 1]!;
for (const [o, n] of Object.entries(dice)) {
c.dice[o] = (c.dice[o] ?? 0) + n;
if (o !== HOUSE) s.hand[o]!.own -= n;
}
}
const allBills = (s: LasVegasState) => [...s.pile, ...s.casinos.flatMap((c) => c.notes), ...Object.values(s.money).flat()].sort((a, b) => a - b);
const SORTED_NOTES = [...ALL_NOTES].sort((a, b) => a - b);
// ---------------------------------------------------------------- setup
describe('setup (§3, §4, §10)', () => {
test('54 bills 6·8·8·6·6·5·5·5·5 = 250', () => {
expect(ALL_NOTES.length).toBe(54);
expect(sum(ALL_NOTES)).toBe(250);
expect(Object.values(NOTE_COUNTS)).toEqual([6, 8, 8, 6, 6, 5, 5, 5, 5]);
for (let seed = 1; seed <= 50; seed++) expect(allBills(make({}, ['A', 'B'], seed))).toEqual(SORTED_NOTES);
});
test('each casino ≥ 5, and without the last bill dealt it would be < 5 (dealt from the shuffled pile, top first)', () => {
for (let seed = 1; seed <= 200; seed++) {
const s = make({}, ['A', 'B', 'C'], seed);
// Re-create the shuffle: setup shuffles first.
const pile = SeededRng.fromSeed(seedN(seed)).shuffle(ALL_NOTES);
for (const c of s.casinos) {
const dealt: number[] = [];
while (sum(dealt) < MIN_CASINO_SUM) dealt.push(pile.shift()!);
expect(c.notes).toEqual([...dealt].sort((a, b) => b - a));
expect(sum(c.notes)).toBeGreaterThanOrEqual(5);
expect(sum(dealt) - dealt[dealt.length - 1]!).toBeLessThan(5);
}
expect(s.pile).toEqual(pile);
}
});
test('refill: [1,1,1,1,9,…] gives casino 1 four 1s + 9; an empty pile leaves casinos short', () => {
const t = { pile: [1, 1, 1, 1, 9, 5, 2, 3, 6], casinos: [1, 2, 3, 4, 5, 6].map((v) => ({ value: v as 1, notes: [] as number[], dice: {} })) };
refillCasinos(t);
expect(t.casinos.map((c) => c.notes)).toEqual([[9, 1, 1, 1, 1], [5], [3, 2], [6], [], []]);
expect(t.pile).toEqual([]);
});
test('dice per player: 8 own; neutral auto = 4 each for 2 players only; on = 4 (2p) / 2 (3–4p); off = 0', () => {
expect(make({}, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 4 });
expect(make({}, ['A', 'B', 'C']).hand.A).toEqual({ own: 8, neutral: 0 });
expect(make({ neutralDice: 'on' }, ['A', 'B']).hand.B).toEqual({ own: 8, neutral: 4 });
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).hand.D).toEqual({ own: 8, neutral: 2 });
expect(make({ neutralDice: 'off' }, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 0 });
expect(make({}, ['A', 'B', 'C', 'D', 'E']).hand.E).toEqual({ own: 8, neutral: 0 });
});
test('5 players + neutral on is rejected (option check), auto/off are fine', () => {
const on = lasVegasOptionsSchema.parse({ neutralDice: 'on' });
expect(lv.playerCountError!(on, 5)).toContain('5명');
expect(lv.playerCountError!(on, 4)).toBeNull();
expect(lv.playerCountError!(lasVegasOptionsSchema.parse({}), 5)).toBeNull();
expect(() => make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D', 'E'])).toThrow();
});
test('the first player is chosen by the RNG (varies by seed)', () => {
const starts = new Set<number>();
for (let seed = 1; seed <= 40; seed++) starts.add(make({}, ['A', 'B', 'C', 'D'], seed).startSeat);
expect(starts.size).toBe(4);
});
});
// ---------------------------------------------------------------- turns
describe('ROLL / PLACE (§5, §8.2, §10)', () => {
test('PLACE moves every die of that number (own + neutral); unrolled numbers and out-of-turn are rejected', () => {
let s = makeFirst({ neutralDice: 'on' }, ['A', 'B']);
expect(reject(s, 'A', { type: 'PLACE', value: 3 })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'B', { type: 'ROLL' })).toBe('지금은 내 차례가 아니에요.');
s = rolled(s, [3, 3, 5, 3, 1, 1, 2, 2], [3, 6, 6, 4]);
expect(reject(s, 'A', { type: 'ROLL' })).toBe('지금은 굴릴 차례가 아니에요.');
expect(reject(s, 'A', { type: 'PLACE', value: 4.5 } as never)).toBe('그 숫자는 나오지 않았어요.');
const v = lv.view(s, 'A');
expect(v.choices.map((c) => [c.value, c.own, c.neutral])).toEqual([
[1, 2, 0],
[2, 2, 0],
[3, 3, 1],
[4, 0, 1],
[5, 1, 0],
[6, 0, 2],
]);
const { state, events } = actE(s, 'A', { type: 'PLACE', value: 3 });
expect(state.casinos[2]!.dice).toEqual({ A: 3, house: 1 });
expect(state.hand.A).toEqual({ own: 5, neutral: 3 });
expect(events.find((e) => e.type === 'placed')).toMatchObject({ player: 'A', value: 3, ownCount: 3, neutralCount: 1 });
expect(cur(state)).toBe('B');
expect(state.phase).toBe('roll');
});
test('a number that only came up on neutral dice may be chosen: only the neutral dice move', () => {
let s = makeFirst({}, ['A', 'B']);
s = rolled(s, [1, 1, 2, 2, 3, 3, 4, 4], [6, 6, 5, 1]);
expect(reject(s, 'A', { type: 'PLACE', value: 6 })).toBeNull();
s = act(s, 'A', { type: 'PLACE', value: 6 });
expect(s.casinos[5]!.dice).toEqual({ house: 2 });
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
});
test('players without dice are skipped; the last one with dice takes turns in a row', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
// A places all 8 at once, B 7 of 8, C all 8.
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [4, 4, 4, 4, 4, 4, 4, 1]);
s = act(s, 'B', { type: 'PLACE', value: 4 });
expect(cur(s)).toBe('C');
s = rolled(s, [5, 5, 5, 5, 5, 5, 5, 5]);
s = act(s, 'C', { type: 'PLACE', value: 5 });
// A has no dice: B again.
expect(cur(s)).toBe('B');
expect(s.hand.B).toEqual({ own: 1, neutral: 0 });
s = rolled(s, [6]);
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 6 });
expect(events.some((e) => e.type === 'roundEnd')).toBe(true);
expect(state.round).toBe(2);
});
test('3 players: A,B used up → C rolls several times in a row', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [3, 3, 3, 3, 3, 3, 3, 3]);
s = act(s, 'B', { type: 'PLACE', value: 3 });
s = rolled(s, [1, 1, 1, 1, 4, 4, 4, 4]);
s = act(s, 'C', { type: 'PLACE', value: 1 });
expect(cur(s)).toBe('C');
s = rolled(s, [4, 4, 5, 6]);
s = act(s, 'C', { type: 'PLACE', value: 4 });
expect(cur(s)).toBe('C');
expect(s.round).toBe(1);
});
});
// ---------------------------------------------------------------- settlement
describe('settlement (§5, §5.1, §10)', () => {
const order = ['A', 'B', 'C', 'D', HOUSE];
test('example 1: [8,3,1] A5 B3 C3 D1 → B·C removed, A 8, D 3, 1 returned', () => {
const r = settleCasino([8, 3, 1], { A: 5, B: 3, C: 3, D: 1 }, order);
expect(r.removedTies).toEqual(['B', 'C']);
expect(r.awards).toEqual([
{ owner: 'A', note: 8 },
{ owner: 'D', note: 3 },
]);
expect(r.returned).toEqual([1]);
});
test('example 2: [4,4] A2 C2 B1 D1 → everyone removed, both bills returned', () => {
const r = settleCasino([4, 4], { A: 2, C: 2, B: 1, D: 1 }, order);
expect(r.removedTies).toEqual(['A', 'B', 'C', 'D']);
expect(r.awards).toEqual([]);
expect(r.returned).toEqual([4, 4]);
});
test('example 3: [7,2] C4 B2 D1 → C 7, B 2, D nothing', () => {
const r = settleCasino([7, 2], { C: 4, B: 2, D: 1 }, order);
expect(r.removedTies).toEqual([]);
expect(r.awards).toEqual([
{ owner: 'C', note: 7 },
{ owner: 'B', note: 2 },
]);
expect(r.returned).toEqual([]);
});
test('example 4 (neutral, 2 players): [9] house 3, A 2, B 1 → the house takes 9 (returned), A·B nothing', () => {
const r = settleCasino([9], { house: 3, A: 2, B: 1 }, ['A', 'B', HOUSE]);
expect(r.awards).toEqual([{ owner: HOUSE, note: 9 }]);
expect(r.returned).toEqual([9]);
});
test('house and a player tie → both removed', () => {
const r = settleCasino([6, 5], { house: 2, A: 2, B: 1 }, ['A', 'B', HOUSE]);
expect(r.removedTies).toEqual(['A', HOUSE]);
expect(r.awards).toEqual([{ owner: 'B', note: 6 }]);
expect(r.returned).toEqual([5]);
});
test('a full round in play: payouts, money, roundResults and bills back to the bottom in casino 1→6 order, high first', () => {
let s = makeFirst({}, ['A', 'B']);
setBills(s, { 1: [9], 2: [8, 3, 1], 3: [4, 4], 4: [7, 2], 5: [5], 6: [6, 1] });
const pileBefore = [...s.pile];
// Board before the last placement: casino 1 house 3/A 2/B 1, casino 2 A3/B3, casino 3 A2/B2, casino 4 B1, casino 6 house 1/A 1.
putDice(s, 1, { house: 3, A: 2, B: 1 });
putDice(s, 2, { A: 3, B: 3 });
putDice(s, 3, { A: 2, B: 2 });
putDice(s, 4, { B: 1 });
putDice(s, 6, { house: 1, A: 1 });
s.hand.A = { own: 0, neutral: 0 };
s.hand.B = { own: 1, neutral: 0 };
s.current = 1;
s = rolled(s, [4]);
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 4 });
const rr = state.roundResults[0]!;
expect(rr.round).toBe(1);
expect(rr.payouts.map((p) => [p.casino, p.removedTies, p.awards.map((a) => `${a.owner}:${a.note}`), p.returned])).toEqual([
[1, [], ['house:9'], [9]],
[2, ['A', 'B'], [], [8, 3, 1]],
[3, ['A', 'B'], [], [4, 4]],
[4, [], ['B:7'], [2]],
[5, [], [], [5]],
[6, ['A', 'house'], [], [6, 1]],
]);
expect(state.money).toEqual({ A: [], B: [7] });
// Returned bills go under the old pile (then casinos are refilled from the top for round 2).
const expectedPile = [...pileBefore, 9, 8, 3, 1, 4, 4, 2, 5, 6, 1];
const dealtRound2 = state.casinos.flatMap((c) => c.notes).length;
expect(state.pile).toEqual(expectedPile.slice(dealtRound2));
expect(events.filter((e) => e.type === 'tiesRemoved').map((e) => e.casino)).toEqual([2, 3, 6]);
expect(events.find((e) => e.type === 'notesReturned')).toMatchObject({ notes: [9, 8, 3, 1, 4, 4, 2, 5, 6, 1] });
// hideMoney (default): the paid event hides B's amount, the house's is public.
expect(events.filter((e) => e.type === 'paid')).toEqual([
{ type: 'paid', casino: 1, owner: HOUSE, note: 9 },
{ type: 'paid', casino: 4, owner: 'B' },
]);
expect(allBills(state)).toEqual(SORTED_NOTES);
});
});
// ---------------------------------------------------------------- rounds
describe('rounds (§4.5, §5, §8.2, §10)', () => {
/** Plays the round out: everyone puts all dice on `value` (cycling 1..6 per player). */
function playRound(s: LasVegasState): LasVegasState {
const round = s.round;
let guard = 0;
while (s.round === round && s.phase !== 'finished' && guard++ < 200) {
const p = cur(s);
const h = s.hand[p]!;
const v = 1 + (s.seats.indexOf(p) % 6);
s = rolled(s, Array(h.own).fill(v), Array(h.neutral).fill(v));
s = act(s, p, { type: 'PLACE', value: v });
}
return s;
}
test('the first player moves one seat left each round; the game ends after `rounds` (4 by default)', () => {
let s = make({}, ['A', 'B', 'C'], 3);
const firsts: number[] = [s.startSeat];
for (let r = 1; r <= 4; r++) {
expect(s.round).toBe(r);
expect(cur(s)).toBe(s.seats[s.startSeat]!);
s = playRound(s);
if (r < 4) firsts.push(s.startSeat);
}
expect(s.phase).toBe('finished');
expect(s.roundResults.length).toBe(4);
expect(firsts).toEqual([0, 1, 2, 3].map((i) => (firsts[0]! + i) % 3));
expect(lv.result(s)).not.toBeNull();
});
test('rounds = 3 ends after three rounds; dice come back each round', () => {
let s = make({ rounds: 3 }, ['A', 'B'], 2);
s = playRound(s);
expect(s.hand).toEqual({ A: { own: 8, neutral: 4 }, B: { own: 8, neutral: 4 } });
expect(s.casinos.every((c) => Object.keys(c.dice).length === 0 && sum(c.notes) >= 5)).toBe(true);
s = playRound(playRound(s));
expect(s.phase).toBe('finished');
});
test('3 players + neutral on: at every round start the first player rolls the 2 spare house dice onto casinos', () => {
let s = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
const house = () => s.casinos.reduce((n, c) => n + (c.dice.house ?? 0), 0);
expect(house()).toBe(2);
expect(s.houseRoll).toMatchObject({ round: 1, player: s.seats[s.startSeat] });
for (const v of s.houseRoll!.values) expect(s.casinos[v - 1]!.dice.house).toBeGreaterThanOrEqual(1);
const r = rng();
const t = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
t.hand = { A: { own: 0, neutral: 0 }, B: { own: 0, neutral: 0 }, C: { own: 1, neutral: 0 } };
t.current = 2;
t.phase = 'place';
t.lastRoll = { own: [1], neutral: [] };
const { state, events } = lv.apply(t, 'C', { type: 'PLACE', value: 1 }, { rng: r, now: 5 });
expect(events.find((e) => e.type === 'houseDicePlaced')).toMatchObject({ player: state.seats[state.startSeat] });
s = state;
expect(s.round).toBe(2);
expect(house()).toBe(2);
expect(s.houseRoll!.round).toBe(2);
// 4 players + on: no spare dice.
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).houseRoll).toBeNull();
});
});
// ---------------------------------------------------------------- result
describe('final ranking (§6, §8.6, §10)', () => {
function finished(money: Record<string, number[]>): LasVegasState {
const s = make({}, Object.keys(money), 1);
s.money = money;
s.phase = 'finished';
return s;
}
test('higher total wins; equal totals → more bills wins; equal both → joint rank', () => {
let r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [5] }))!;
expect(r.ranking).toEqual([['B'], ['A'], ['C']]);
r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [9, 4, 5] }))!;
expect(r.ranking).toEqual([['B', 'C'], ['A']]);
expect(r.summary).toBe('공동 1위 2명, 18점 (지폐 3장)');
r = lv.result(finished({ A: [9, 8], B: [6, 6] }))!;
expect(r.summary).toBe('1위 17점 (지폐 2장) · 2위 12점');
expect(r.scores).toEqual({ A: 17, B: 12 });
expect(r.reason).toBe('normal');
const v = lv.view(finished({ A: [9, 8], B: [6, 6] }), null);
expect(v.result!.ranking.map((x) => [x.rank, x.player, x.total, x.notes])).toEqual([
[1, 'A', 17, [9, 8]],
[2, 'B', 12, [6, 6]],
]);
// Everything is revealed at the end.
expect(v.money.B).toEqual({ count: 2, total: 12, notes: [6, 6] });
});
});
// ---------------------------------------------------------------- timeout / away / leave
describe('timeout, away and leaving (§8.5, §10)', () => {
test('timeout rolls in the roll phase, then places the number with the most dice', () => {
let s = makeFirst({ turnSeconds: 20 }, ['A', 'B']);
expect(s.deadlineAt).toBe(clock + 20_000);
expect(lv.onTimeout(s, 'A')).toEqual({ type: 'TIMEOUT' });
s = act(s, 'A', { type: 'TIMEOUT' });
expect(s.phase).toBe('place');
expect(s.deadlineAt).toBe(clock + 20_000);
s.lastRoll = { own: [2, 2, 5, 5, 5, 1, 1, 6], neutral: [2, 3, 4, 6] };
// 2 and 5 both have 3 dice (one of the 2s is neutral) → the better top bill decides.
setBills(s, { 2: [3, 2], 5: [9] });
expect(autoPlaceValue(s, s.lastRoll)).toBe(5);
expect(lv.view(s, 'B').autoValue).toBe(5);
setBills(s, { 2: [9], 5: [9], 6: [9] });
expect(autoPlaceValue(s, s.lastRoll)).toBe(2); // same count and bill → smaller number
s.lastRoll = { own: [4, 4, 4, 4, 1, 2, 3, 6], neutral: [6, 6, 1, 1] };
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
expect(state.casinos[3]!.dice.A).toBe(4);
expect(events.find((e) => e.type === 'placed')).toMatchObject({ value: 4, auto: true });
expect(state.lastPlace!.auto).toBe(true);
});
test('turnSeconds null → no deadline; 3 timeouts in a row → away (5 s deadline); acting again clears it', () => {
let s = makeFirst({ turnSeconds: null }, ['A', 'B']);
expect(lv.deadline!(s)).toBeNull();
for (let i = 0; i < AWAY_AFTER; i++) s = act(s, cur(s), { type: 'TIMEOUT' });
// A: roll, place (B's turn), B: roll — B 1 timeout, A 2.
expect(lv.view(s, null).away).toEqual([]);
s = act(s, 'B', { type: 'TIMEOUT' }); // B place → A
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); // A's 3rd
expect(events.some((e) => e.type === 'away' && e.player === 'A')).toBe(true);
expect(lv.view(state, null).away).toEqual(['A']);
expect(state.deadlineAt).toBe(clock + AWAY_MS);
s = act(state, 'A', { type: 'PLACE', value: state.lastRoll!.own[0] ?? state.lastRoll!.neutral[0]! });
expect(lv.view(s, null).away).toEqual([]);
});
test('a disconnected player (only timeouts) does not block the game: rounds finish', () => {
let s = make({}, ['A', 'B', 'C'], 7);
const r = SeededRng.fromSeed(seedN(70));
let steps = 0;
while (s.phase !== 'finished' && steps++ < 2000) {
const p = cur(s);
const a: LasVegasAction = p === 'B' ? lv.onTimeout(s, p) : lv.legalActions!(s, p)[0]!;
expect(lv.validate(s, p, a)).toEqual({ ok: true });
s = lv.apply(s, p, a, { rng: r, now: (clock += 1000) }).state;
}
expect(s.phase).toBe('finished');
expect(s.roundResults.length).toBe(4);
});
test('concede: dice discarded (also those on the board), skipped afterwards, bills kept, ranked last; last one standing ends the game', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s.money.B = [9, 9, 9];
for (let i = 0; i < 3; i++) s.pile.splice(s.pile.indexOf(9), 1);
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [1, 1, 1, 1, 1, 1, 1, 6]);
s = act(s, 'B', { type: 'PLACE', value: 1 });
expect(lv.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
s = act(s, 'B', { type: 'CONCEDE' }); // not B's turn
expect(s.casinos[0]!.dice.B).toBeUndefined();
expect(s.hand.B).toEqual({ own: 0, neutral: 0 });
expect(reject(s, 'B', { type: 'ROLL' })).toBe('이미 기권했어요.');
expect(lv.onLeave!(s, 'B')).toBeNull();
expect(cur(s)).toBe('C');
s = rolled(s, [6, 6, 6, 6, 6, 6, 6, 6]);
s = act(s, 'C', { type: 'PLACE', value: 6 });
expect(cur(s)).toBe('A'); // B skipped
const { state, events } = actE(s, 'C', { type: 'CONCEDE' });
expect(events.some((e) => e.type === 'gameOver')).toBe(true);
const r = lv.result(state)!;
expect(r.ranking).toEqual([['A'], ['B'], ['C']]); // conceded players last (B has more bills than C)
expect(r.reason).toBe('resign');
expect(r.summary.startsWith('다른 사람이 모두 기권했어요.')).toBe(true);
expect(allBills(state)).toEqual([...SORTED_NOTES]);
});
test('the current player conceding passes the turn on', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
s = act(s, 'A', { type: 'CONCEDE' });
expect(cur(s)).toBe('B');
expect(s.phase).toBe('roll');
expect(s.lastRoll).toBeNull();
});
});
// ---------------------------------------------------------------- view / hidden info
describe('view and hidden info (§8.3, §10)', () => {
function withMoney(opts: Partial<LasVegasOptions>) {
const s = makeFirst(opts, ['A', 'B', 'C']);
s.money = { A: [9, 2], B: [7], C: [] };
s.roundResults = [
{
round: 1,
payouts: [
{
casino: 3,
removedTies: ['C'],
awards: [
{ owner: 'A', note: 9 },
{ owner: 'B', note: 7 },
{ owner: HOUSE, note: 5 },
{ owner: 'A', note: 2 },
],
returned: [5],
},
],
},
];
// Keep 54 bills: take them out of the pile.
for (const n of [9, 2, 7]) s.pile.splice(s.pile.indexOf(n), 1);
return s;
}
test('hideMoney: other players and spectators see bill counts only; own amounts and house/returned bills are public', () => {
const s = withMoney({});
const vb = lv.view(s, 'B');
expect(vb.money.A).toEqual({ count: 2, total: null, notes: null });
expect(vb.money.B).toEqual({ count: 1, total: 7, notes: [7] });
const aw = vb.roundResults[0]!.payouts[0]!.awards;
expect(aw.map((a) => [a.owner, a.note])).toEqual([
['A', null],
['B', 7],
[HOUSE, 5],
['A', null],
]);
const spec = lv.view(s, null);
expect(Object.values(spec.money).every((m) => m.total === null && m.notes === null)).toBe(true);
expect(spec.roundResults[0]!.payouts[0]!.awards.filter((a) => a.owner !== HOUSE).every((a) => a.note === null && a.key === null)).toBe(true);
for (const viewer of ['A', 'B', 'C', null]) {
const json = JSON.stringify(lv.view(s, viewer));
expect(json).not.toContain('"pile"');
expect(json).not.toContain('"rng"');
expect(json).not.toContain('"timeouts"');
for (const other of ['A', 'B', 'C']) {
if (other === viewer) continue;
for (const secret of lv.secretsOf!(s, other)) expect(json).not.toContain(secret);
}
}
expect(lv.view(s, 'A').pileCount).toBe(s.pile.length);
expect(lv.secretsOf!(s, 'A').length).toBe(2);
});
test('hideMoney = false: amounts are public', () => {
const s = withMoney({ hideMoney: false });
expect(lv.view(s, null).money.A).toEqual({ count: 2, total: 11, notes: [9, 2] });
expect(lv.secretsOf!(s, 'A')).toEqual([]);
});
test('choices preview for the player to move: 1등 / 동점 주의 / lower bill / nothing', () => {
let s = makeFirst({ neutralDice: 'off' }, ['A', 'B']);
setBills(s, { 1: [9, 4], 2: [8], 3: [6], 4: [5] });
putDice(s, 1, { B: 3 });
putDice(s, 2, { B: 2 });
putDice(s, 3, { B: 1 });
putDice(s, 4, { B: 2 });
s = rolled(s, [1, 2, 2, 3, 3, 3, 4, 5]);
const v: LasVegasView = lv.view(s, 'B');
const by = Object.fromEntries(v.choices.map((c) => [c.value, [c.standing, c.note, c.countAfter]]));
expect(by).toEqual({ 1: ['award', 4, 1], 2: ['tie', null, 2], 3: ['first', 6, 3], 4: ['none', null, 1], 5: ['none', null, 1] });
});
test('no RNG/pile in the view; same seed + same actions → same result (replay)', () => {
const run = () => {
let s = make({}, ['A', 'B', 'C'], 11);
const r = SeededRng.fromSeed(seedN(12));
let t = 0;
while (s.phase !== 'finished') {
const p = cur(s);
const legal = lv.legalActions!(s, p);
s = lv.apply(s, p, legal[t++ % legal.length]!, { rng: r, now: t * 1000 }).state;
}
return s;
};
expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
expect(run().money).toEqual(run().money);
});
test('schema: defaults, Korean metadata for every option, client cannot send TIMEOUT', () => {
expect(lv.defaultOptions).toEqual({ rounds: 4, neutralDice: 'auto', hideMoney: true, turnSeconds: 30 });
const shape = lasVegasOptionsSchema.shape;
for (const k of Object.keys(shape) as (keyof typeof shape)[]) {
const meta = shape[k].meta() as { title?: string };
expect(meta?.title).toMatch(/[가-힣]/);
}
expect(lasVegasActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 7 }).success).toBe(false);
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 3 }).success).toBe(true);
expect(lasVegasOptionsSchema.safeParse({ turnSeconds: null }).success).toBe(true);
expect(lasVegasOptionsSchema.safeParse({ rounds: 6 }).success).toBe(false);
});
});
// ---------------------------------------------------------------- random playouts
describe('random playouts (docs/09 §7)', () => {
const OPTS: Partial<LasVegasOptions>[] = [
{},
{ neutralDice: 'on' },
{ neutralDice: 'off', hideMoney: false },
{ rounds: 3, turnSeconds: null },
{ rounds: 5, turnSeconds: 20 },
];
function invariant(s: LasVegasState, total: { neutral: number }): void {
// 54 bills always: pile + casinos + money.
const bills = allBills(s);
if (bills.length !== 54 || bills.join() !== SORTED_NOTES.join()) throw new Error('bills not conserved');
if (s.phase === 'finished') return;
// Dice: active players hold 8 own dice between hand and casinos; house dice never exceed the neutral pool.
let house = 0;
for (const c of s.casinos) {
house += c.dice.house ?? 0;
for (const [o, n] of Object.entries(c.dice)) if (o !== HOUSE && (n! <= 0 || s.conceded.includes(o))) throw new Error('bad dice count');
if (c.notes.some((n, i) => i > 0 && n > c.notes[i - 1]!)) throw new Error('casino bills not descending');
}
for (const p of s.seats) {
const placed = s.casinos.reduce((n, c) => n + (c.dice[p] ?? 0), 0);
if (!s.conceded.includes(p) && placed + s.hand[p]!.own !== OWN_DICE) throw new Error(`own dice of ${p} not conserved`);
}
const neutralHands = s.seats.reduce((n, p) => n + s.hand[p]!.neutral, 0);
if (s.conceded.length === 0 && house + neutralHands !== total.neutral) throw new Error('neutral dice not conserved');
if (house + neutralHands > total.neutral) throw new Error('too many neutral dice');
// The player to act always has dice.
if (s.hand[cur(s)]!.own + s.hand[cur(s)]!.neutral === 0) throw new Error('current player has no dice');
}
function leakCheck(s: LasVegasState): void {
for (const viewer of [...s.seats, null]) {
const v = lv.view(s, viewer);
const json = JSON.stringify(v);
if (json.includes('"pile"') || json.includes('"rng"')) throw new Error('pile/rng in view');
if (s.phase === 'finished' || !s.options.hideMoney) continue;
for (const p of s.seats) {
if (p === viewer) continue;
if (v.money[p]!.total !== null || v.money[p]!.notes !== null) throw new Error(`money of ${p} leaked to ${viewer}`);
for (const rr of v.roundResults)
for (const po of rr.payouts) for (const a of po.awards) if (a.owner === p && a.note !== null) throw new Error('award leaked');
for (const secret of lv.secretsOf!(s, p)) if (json.includes(secret)) throw new Error('secret leaked');
}
}
}
test('1,000 seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic', () => {
let finished = 0;
const play = (i: number, check: boolean): string => {
const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0);
const players = Array.from({ length: Math.min(5, n) }, (_, k) => `p${k}`);
let opts = OPTS[i % OPTS.length]!;
if (players.length === 5 && opts.neutralDice === 'on') opts = { ...opts, neutralDice: 'auto' };
const options = lasVegasOptionsSchema.parse(opts);
const rngG = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 100_000));
let s = lv.setup({ players, options, rng: rngG, now: 0 });
const total = { neutral: s.neutralPer * players.length + s.houseExtra };
let now = 0;
for (let step = 0; step < 2000; step++) {
if (check) {
invariant(s, total);
if (step % 5 === 0) leakCheck(s);
}
if (lv.result(s)) {
if (check) leakCheck(s);
return JSON.stringify(s);
}
const p = cur(s);
if (check) {
const t = lv.onTimeout(s, p);
const v = lv.validate(s, p, t);
if (!v.ok) throw new Error(`timeout rejected: ${v.reason}`);
}
const roll = chooser.int(100);
let actor = p;
let a: LasVegasAction;
if (roll < 1) {
// A random seated player concedes now and then.
const alive = s.seats.filter((q) => !s.conceded.includes(q));
actor = alive[chooser.int(alive.length)]!;
a = { type: 'CONCEDE' };
} else if (roll < 10) a = { type: 'TIMEOUT' };
else {
const legal = lv.legalActions!(s, p);
a = legal[chooser.int(legal.length)]!;
}
const v = lv.validate(s, actor, a);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason}`);
s = lv.apply(s, actor, a, { rng: rngG, now: (now += 1000) }).state;
}
throw new Error(`seed ${i} did not finish`);
};
for (let i = 1; i <= 1000; i++) {
const a = play(i, true);
finished++;
if (i % 50 === 0) expect(play(i, false)).toBe(a);
}
expect(finished).toBe(1000);
}, 60_000);
test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => {
for (let i = 1; i <= 100; i++) {
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4));
const opts = lasVegasOptionsSchema.parse(OPTS[i % OPTS.length]!.neutralDice === 'on' && players.length === 5 ? {} : OPTS[i % OPTS.length]!);
const r = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
expect(r.finished).toBe(true);
const st = r.finalState as LasVegasState;
expect(st.roundResults.length).toBe(opts.rounds);
expect(allBills(st)).toEqual(SORTED_NOTES);
if (i % 20 === 0) {
const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,199 @@
/**
* 라스베가스 규칙 계산 (docs/games/las-vegas.md §3–§5, §8). Pure helpers shared by the definition,
* the view and the tests. State-mutating helpers work on a state `apply` has already cloned.
*/
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
// ---------------------------------------------------------------- options & actions
const lit = <T extends number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const lasVegasOptionsSchema = z.object({
rounds: lit(3, 4, 5)
.default(4)
.meta({ title: '라운드 수', labels: { 3: '3라운드(짧게)', 4: '4라운드(기본)', 5: '5라운드' } }),
neutralDice: z
.enum(['auto', 'on', 'off'])
.default('auto')
.meta({
title: '중립(흰) 주사위',
labels: { auto: '자동(2명일 때만, 1인당 4개)', on: '켜기(2명 4개, 3~4명 2개씩)', off: '끄기' },
}),
hideMoney: z.boolean().default(true).meta({ title: '다른 사람이 받은 지폐 금액 숨기기(장 수만 공개)' }),
turnSeconds: lit(20, 30, 60, null)
.default(30)
.meta({ title: '굴리기·놓기 제한 시간', labels: { 20: '20초', 30: '30초', 60: '60초', null: '무제한' } }),
});
export type LasVegasOptions = z.infer<typeof lasVegasOptionsSchema>;
export const lasVegasActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('ROLL') }),
z.object({ type: z.literal('PLACE'), value: z.number().int().min(1).max(6) }),
z.object({ type: z.literal('CONCEDE') }),
]);
export type LasVegasClientAction = z.infer<typeof lasVegasActionSchema>;
/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */
export type LasVegasAction = LasVegasClientAction | { type: 'TIMEOUT' };
// ---------------------------------------------------------------- components
export type Note = number;
export const HOUSE = 'house' as const;
export type Owner = PlayerId | typeof HOUSE;
export type CasinoValue = 1 | 2 | 3 | 4 | 5 | 6;
/** Bill denominations → count (§3): 54 bills, 250 in total. */
export const NOTE_COUNTS: Record<Note, number> = { 1: 6, 2: 8, 3: 8, 4: 6, 5: 6, 6: 5, 7: 5, 8: 5, 9: 5 };
export const ALL_NOTES: Note[] = Object.entries(NOTE_COUNTS).flatMap(([v, n]) => Array<Note>(n).fill(Number(v)));
export const OWN_DICE = 8;
export const MIN_CASINO_SUM = 5;
export const CASINO_VALUES: CasinoValue[] = [1, 2, 3, 4, 5, 6];
/** Consecutive automatic actions before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
/** Deadline for away players (keeps the game moving). */
export const AWAY_MS = 5000;
export interface Casino {
value: CasinoValue;
/** Descending. */
notes: Note[];
dice: Partial<Record<Owner, number>>;
}
export interface Payout {
casino: number;
removedTies: Owner[];
awards: { owner: Owner; note: Note }[];
returned: Note[];
}
export interface RoundResult {
round: number;
payouts: Payout[];
}
export interface LasVegasState {
options: LasVegasOptions;
seats: PlayerId[];
round: number;
/** This round's first player (seat index). */
startSeat: number;
/** Seat index of the player to act. */
current: number;
phase: 'roll' | 'place' | 'finished';
/** Hidden draw pile, index 0 = top. */
pile: Note[];
casinos: Casino[];
/** Dice still in hand. */
hand: Record<PlayerId, { own: number; neutral: number }>;
lastRoll: { own: number[]; neutral: number[] } | null;
money: Record<PlayerId, Note[]>;
roundResults: RoundResult[];
/** Neutral dice each player gets per round (0 = variant off). */
neutralPer: number;
/** Neutral dice the round's first player rolls straight onto the casinos (3 players + variant). */
houseExtra: number;
/** Last round-start house roll (3 players + variant). */
houseRoll: { round: number; player: PlayerId; values: number[] } | null;
lastPlace: { player: PlayerId; value: number; own: number; neutral: number; auto: boolean } | null;
conceded: PlayerId[];
timeouts: Record<PlayerId, number>;
rollSeq: number;
turnSeq: number;
deadlineAt: number | null;
}
// ---------------------------------------------------------------- setup helpers
/** Neutral dice per player for a player count; null = not allowed (5 players + `on`). */
export function neutralPerFor(option: LasVegasOptions['neutralDice'], players: number): number | null {
if (option === 'off') return 0;
if (option === 'auto') return players === 2 ? 4 : 0;
if (players === 2) return 4;
if (players <= 4) return 2;
return null;
}
/** Leftover neutral dice the first player rolls each round (only 3 players: 8 − 3×2 = 2). */
export const houseExtraFor = (neutralPer: number, players: number): number => (neutralPer > 0 && players === 3 ? 2 : 0);
/** Deals bills from the top of the pile until each casino holds ≥ 5 (§4.2). Casinos are emptied first. */
export function refillCasinos(s: Pick<LasVegasState, 'pile' | 'casinos'>): void {
for (const c of s.casinos) {
c.notes = [];
c.dice = {};
let sum = 0;
while (sum < MIN_CASINO_SUM && s.pile.length > 0) {
const n = s.pile.shift()!;
c.notes.push(n);
sum += n;
}
c.notes.sort((a, b) => b - a);
}
}
// ---------------------------------------------------------------- settlement
/**
* One casino's payout (§5 정산): owners with equal counts are all removed (the house included), the rest
* take bills in descending order. Bills the house wins and bills nobody wins are returned.
* `order` fixes the listing order of tied owners (seat order, house last).
*/
export function settleCasino(notes: Note[], dice: Partial<Record<Owner, number>>, order: Owner[]): Payout & { ranked: Owner[] } {
const owners = order.filter((o) => (dice[o] ?? 0) > 0);
const byCount = new Map<number, Owner[]>();
for (const o of owners) byCount.set(dice[o]!, [...(byCount.get(dice[o]!) ?? []), o]);
const removedTies = owners.filter((o) => byCount.get(dice[o]!)!.length > 1);
const ranked = owners.filter((o) => !removedTies.includes(o)).sort((a, b) => dice[b]! - dice[a]!);
const sorted = [...notes].sort((a, b) => b - a);
const awards: Payout['awards'] = [];
ranked.forEach((o, i) => {
if (i < sorted.length) awards.push({ owner: o, note: sorted[i]! });
});
const returned = [...awards.filter((a) => a.owner === HOUSE).map((a) => a.note), ...sorted.slice(ranked.length)].sort((a, b) => b - a);
return { casino: 0, removedTies, awards, returned, ranked };
}
export const ownerOrder = (s: Pick<LasVegasState, 'seats'>): Owner[] => [...s.seats, HOUSE];
/** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */
export function placementPreview(
s: Pick<LasVegasState, 'seats' | 'casinos'>,
player: PlayerId,
value: number,
own: number,
neutral: number,
): { standing: 'first' | 'award' | 'tie' | 'none'; note: Note | null; count: number } {
const c = s.casinos[value - 1]!;
const dice = { ...c.dice };
if (own > 0) dice[player] = (dice[player] ?? 0) + own;
if (neutral > 0) dice[HOUSE] = (dice[HOUSE] ?? 0) + neutral;
const count = dice[player] ?? 0;
if (count === 0) return { standing: 'none', note: null, count };
const out = settleCasino(c.notes, dice, ownerOrder(s));
if (out.removedTies.includes(player)) return { standing: 'tie', note: null, count };
const idx = out.ranked.indexOf(player);
const note = out.awards.find((a) => a.owner === player)?.note ?? null;
return { standing: note === null ? 'none' : idx === 0 ? 'first' : 'award', note, count };
}
/** Deterministic timeout choice (§8.5): most dice, then the casino's best bill, then the smaller number. */
export function autoPlaceValue(s: Pick<LasVegasState, 'casinos'>, roll: { own: number[]; neutral: number[] }): number {
let best = 0;
let bestKey: [number, number] = [-1, -1];
for (const v of CASINO_VALUES) {
const n = roll.own.filter((d) => d === v).length + roll.neutral.filter((d) => d === v).length;
if (n === 0) continue;
const key: [number, number] = [n, s.casinos[v - 1]!.notes[0] ?? 0];
if (key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
best = v;
bestKey = key;
}
}
return best;
}
export const sum = (notes: Note[]): number => notes.reduce((a, b) => a + b, 0);
export const diceLeft = (s: Pick<LasVegasState, 'hand'>, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0);

View File

@@ -0,0 +1,139 @@
/** 구십구 cards and what each one does to the total (docs/games/ninety-nine.md §3, §5.2). */
import type { CardFace, ChoiceView, NNCard, NNOptions, PlayAction, Suit } from './types';
export const SUITS: Suit[] = ['S', 'H', 'D', 'C'];
/** Dedicated deck: 1–8 ×7, 10 ×21, ±9 / ±10 / 0 × `specialCounts`, joker ×2 (91 cards by default). */
export function krDeck(specialCounts: number): NNCard[] {
const out: NNCard[] = [];
let n = 0;
const id = () => `k${n++}`;
for (let v = 1; v <= 8; v++) for (let i = 0; i < 7; i++) out.push({ id: id(), kind: 'add', v, face: { label: String(v) } });
for (let i = 0; i < 21; i++) out.push({ id: id(), kind: 'add', v: 10, face: { label: '10' } });
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 9, face: { label: '±9' } });
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 10, face: { label: '±10' } });
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'reverse', face: { label: '0' } });
for (let i = 0; i < 2; i++) out.push({ id: id(), kind: 'joker', face: { label: '조커' } });
return out;
}
const RANK_LABEL: Record<number, string> = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' };
/** Classic card from rank 1..13 (A=1, J=11, Q=12, K=13), Bicycle effects. */
function classicCard(id: string, suit: Suit, rank: number): NNCard {
const face: CardFace = { label: RANK_LABEL[rank] ?? String(rank), suit };
if (rank === 1) return { id, kind: 'ace', face };
if (rank === 4) return { id, kind: 'reverse', face };
if (rank === 9) return { id, kind: 'hold', face };
if (rank === 10) return { id, kind: 'minus', v: 10, face };
if (rank === 13) return { id, kind: 'to99', face };
return { id, kind: 'add', v: rank >= 11 ? 10 : rank, face };
}
/** 52 trump cards per deck (no jokers); the second deck adds a "b" suffix. */
export function classicDeck(decks: 1 | 2): NNCard[] {
const out: NNCard[] = [];
for (let d = 0; d < decks; d++) {
for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push(classicCard(`t${suit}${rank}${d ? 'b' : ''}`, suit, rank));
}
return out;
}
export const jokerRange = (o: Pick<NNOptions, 'jokerRange'>): { min: number; max: number } => (o.jokerRange === '0-99' ? { min: 0, max: 99 } : { min: 60, max: 99 });
export const OVER = '99를 넘어서 낼 수 없어요';
export const UNDER = '0보다 작아져서 뺄 수 없어요';
function choice(label: string, total: number, o: NNOptions, extra: Partial<ChoiceView> = {}, minus = false): ChoiceView {
let t = total;
if (minus && t < 0 && o.floorAtZero === 'clamp') t = 0;
if (t > 99) return { label, total: t, legal: false, reason: OVER, ...extra };
if (t < 0) return { label, total: t, legal: false, reason: UNDER, ...extra };
return { label, total: t, legal: true, ...extra };
}
/** Every way to play `c` on total `s` (empty for the joker, which picks a number). */
export function choicesFor(c: NNCard, s: number, o: NNOptions): ChoiceView[] {
switch (c.kind) {
case 'add':
return [choice(`+${c.v}`, s + c.v, o)];
case 'plusMinus':
return [choice(`+${c.v}`, s + c.v, o, { sign: '+' }), choice(`−${c.v}`, s - c.v, o, { sign: '-' }, true)];
case 'minus':
return [choice(`−${c.v}`, s - c.v, o, {}, true)];
case 'reverse':
return [choice('방향 바꾸기', s, o)];
case 'hold':
return [choice('그대로', s, o)];
case 'to99':
return [choice('99로', 99, o)];
case 'ace':
return [choice('+1', s + 1, o, { aceValue: 1 }), choice(`+${o.aceHigh}`, s + o.aceHigh, o, { aceValue: o.aceHigh })];
case 'joker':
return [];
}
}
/** Some way to play `c` keeps the total within 0..99. */
export function playable(c: NNCard, s: number, o: NNOptions): boolean {
return c.kind === 'joker' || choicesFor(c, s, o).some((x) => x.legal);
}
/** The total a play makes, or the Korean reason it is not allowed (§8.2). */
export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction): { ok: true; total: number; label: string } | { ok: false; reason: string } {
const extra = (k: 'sign' | 'aceValue' | 'jokerValue') => a[k] !== undefined;
if (c.kind === 'joker') {
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '조커는 숫자만 골라 주세요' };
const r = jokerRange(o);
if (a.jokerValue === undefined) return { ok: false, reason: `조커로 만들 수(${r.min}~${r.max})를 골라 주세요` };
if (!Number.isInteger(a.jokerValue) || a.jokerValue < r.min || a.jokerValue > r.max) return { ok: false, reason: `조커는 ${r.min}부터 ${r.max} 사이 숫자만 고를 수 있어요` };
return { ok: true, total: a.jokerValue, label: String(a.jokerValue) };
}
if (extra('jokerValue')) return { ok: false, reason: '숫자는 조커를 낼 때만 고를 수 있어요' };
const options = choicesFor(c, s, o);
let pick: ChoiceView | undefined;
if (c.kind === 'plusMinus') {
if (extra('aceValue')) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' };
if (!a.sign) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' };
pick = options.find((x) => x.sign === a.sign);
} else if (c.kind === 'ace') {
if (extra('sign')) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` };
if (a.aceValue === undefined) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` };
pick = options.find((x) => x.aceValue === a.aceValue);
if (!pick) return { ok: false, reason: `A는 1이나 ${o.aceHigh}로만 낼 수 있어요` };
} else {
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '이 카드는 고를 것 없이 바로 내요' };
pick = options[0];
}
if (!pick) return { ok: false, reason: '낼 수 없는 카드예요' };
if (!pick.legal) return { ok: false, reason: pick.reason ?? OVER };
return { ok: true, total: pick.total, label: pick.label };
}
/** §8.5 tie-break group: no change → plain numbers → ± cards → K / joker. */
const GROUP: Record<NNCard['kind'], number> = { reverse: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 };
/**
* Timer move (§8.5): the legal (card, choice) with the smallest resulting total; ties by the group order above,
* then card id. Uses only the player's own hand and the public total.
*/
export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction | null {
let best: { a: PlayAction; total: number; g: number; id: string } | null = null;
for (const c of hand) {
const cands: { a: PlayAction; total: number }[] = [];
if (c.kind === 'joker') {
const r = jokerRange(o);
cands.push({ a: { type: 'play', cardId: c.id, jokerValue: r.min }, total: r.min });
} else {
for (const x of choicesFor(c, s, o)) {
if (!x.legal) continue;
cands.push({ a: { type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) }, total: x.total });
}
}
for (const k of cands) {
const g = GROUP[c.kind];
if (!best || k.total < best.total || (k.total === best.total && (g < best.g || (g === best.g && c.id < best.id)))) best = { a: k.a, total: k.total, g, id: c.id };
}
}
return best?.a ?? null;
}

View File

@@ -0,0 +1,459 @@
/** 구십구 (docs/games/ninety-nine.md). Pure and deterministic; state is JSON. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import { choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards';
import {
ninetyNineActionSchema,
ninetyNineOptionsSchema,
type HandCardView,
type LogEntry,
type NNAction,
type NNCard,
type NNOptions,
type NinetyNineState,
type NinetyNineView,
type PlayAction,
type PlayerResult,
} from './types';
export * from './cards';
export * from './types';
/** Deadline once a player has timed out this many turns in a row (§8.5). */
const AWAY_AFTER = 3;
const AWAY_MS = 3000;
const LOG_MAX = 40;
const LOG_VIEW = 20;
const RECENT_VIEW = 6;
interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
// ---------------------------------------------------------------- helpers
/** Copy for `apply`: containers are copied, card objects / log entries / options are shared (never mutated). */
function cloneState(s: NinetyNineState): NinetyNineState {
const rec = <T>(r: Record<PlayerId, T>): Record<PlayerId, T> => ({ ...r });
return {
...s,
seats: [...s.seats],
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
drawPile: [...s.drawPile],
discard: [...s.discard],
alive: rec(s.alive),
lives: rec(s.lives),
livesLost: rec(s.livesLost),
onBoard: rec(s.onBoard),
eliminationOrder: [...s.eliminationOrder],
eliminatedAtRound: rec(s.eliminatedAtRound),
leftOrder: [...s.leftOrder],
timeoutsInRow: rec(s.timeoutsInRow),
away: rec(s.away),
log: [...s.log],
};
}
export const resolvedLives = (o: Pick<NNOptions, 'mode' | 'lives'>): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
export const resolvedDecks = (o: Pick<NNOptions, 'mode' | 'deckCount'>, players: number): 1 | 2 =>
o.mode !== 'classic' ? 1 : o.deckCount === 'auto' ? (players >= 7 ? 2 : 1) : o.deckCount;
export const fullDeck = (s: Pick<NinetyNineState, 'mode' | 'options' | 'decks'>): NNCard[] =>
s.mode === 'classic' ? classicDeck(s.decks) : krDeck(s.options.specialCounts);
/** Every card is in exactly one place (test invariant). */
export const cardTotal = (s: NinetyNineState): number =>
s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0);
const aliveSeats = (s: NinetyNineState): PlayerId[] => s.seats.filter((p) => s.alive[p]);
function log(s: NinetyNineState, e: Record<string, unknown> & { t: LogEntry['t'] }): void {
s.log.push({ seq: s.seq, ...e } as LogEntry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
/** Next player still in the game from `from` (who may be out) in direction `dir`. */
function nextAlive(s: NinetyNineState, from: PlayerId, dir: 1 | -1): PlayerId {
const n = s.seats.length;
let idx = s.seats.indexOf(from);
for (let k = 0; k < n; k++) {
idx = (idx + dir + n) % n;
if (s.alive[s.seats[idx]!]) return s.seats[idx]!;
}
return from;
}
const hasPlay = (s: NinetyNineState, p: PlayerId): boolean => (s.hands[p] ?? []).some((c) => playable(c, s.total, s.options));
/** Collects every card, shuffles, deals `handSize` to everyone still in from `starter` clockwise; total 0 (§5.3). */
function dealRound(s: NinetyNineState, starter: PlayerId, ctx: Ctx): void {
const pool = [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p] ?? [])];
s.drawPile = ctx.rng.shuffle(pool);
s.discard = [];
s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as NNCard[]]));
s.total = 0;
s.direction = 1;
const order: PlayerId[] = [];
for (let k = 0, p = starter; k < aliveSeats(s).length; k++, p = nextAlive(s, p, 1)) order.push(p);
for (let r = 0; r < s.options.handSize; r++) for (const p of order) {
const c = s.drawPile.shift();
if (c) s.hands[p]!.push(c);
}
s.round += 1;
ctx.events.push({ type: 'roundStarted', round: s.round, starter });
log(s, { t: 'round', round: s.round, starter });
}
function eliminate(s: NinetyNineState, p: PlayerId, why: 'noCard' | 'left', ctx: Ctx): void {
s.alive[p] = false;
s.eliminationOrder.push(p);
s.eliminatedAtRound[p] = s.round;
// The hand is not revealed (§8.3): only the count moves to the discard pile.
s.discard.push(...s.hands[p]!);
s.hands[p] = [];
ctx.events.push({ type: 'eliminated', player: p, why });
log(s, { t: 'out', p, why });
}
function finish(s: NinetyNineState, ctx: Ctx): void {
const winners = aliveSeats(s);
const order = [...winners, ...[...s.eliminationOrder].reverse()];
const ranking: PlayerResult[] = order.map((p, i) => ({
player: p,
rank: i + 1,
livesLost: s.livesLost[p] ?? 0,
eliminatedAtRound: s.eliminatedAtRound[p] ?? null,
left: s.leftOrder.includes(p),
}));
const allLeft = s.eliminationOrder.length > 0 && s.eliminationOrder.every((p) => s.leftOrder.includes(p));
const summary = allLeft
? '다른 사람이 모두 나가서 남은 사람이 이겼어요'
: `끝까지 살아남은 사람이 이겼어요! 마지막 합계 ${s.total}${s.livesStart > 0 ? ` · ${s.round}판` : ''}`;
s.final = { ranking, winner: winners, lastTotal: s.total, summary };
s.phase = 'finished';
ctx.events.push({ type: 'gameEnd', winner: winners });
}
/**
* Player `p` could not play (§5.3). Returns who must be checked next, or null when the game is over.
*/
function lose(s: NinetyNineState, p: PlayerId, ctx: Ctx): PlayerId | null {
ctx.events.push({ type: 'noCard', player: p, total: s.total });
log(s, { t: 'noCard', p });
if (s.livesStart > 0) {
let out = false;
if (s.lives[p]! > 0) {
s.lives[p]! -= 1;
s.livesLost[p]! += 1;
ctx.events.push({ type: 'lifeLost', player: p, left: s.lives[p] });
log(s, { t: 'life', p, left: s.lives[p]! });
if (s.lives[p] === 0) {
if (s.options.onTheBoard) s.onBoard[p] = true;
else out = true;
}
} else out = true; // on the board with no lives left: one more loss is out
const next = nextAlive(s, p, s.direction);
if (out) eliminate(s, p, 'noCard', ctx);
if (aliveSeats(s).length <= 1) {
finish(s, ctx);
return null;
}
const starter = out ? next : p;
dealRound(s, starter, ctx);
return starter;
}
const next = nextAlive(s, p, s.direction);
eliminate(s, p, 'noCard', ctx);
if (aliveSeats(s).length <= 1) {
finish(s, ctx);
return null;
}
if (s.options.afterElimination === 'reset') dealRound(s, next, ctx);
return next;
}
/** Hands the turn to `p`; anyone with nothing playable loses at once, possibly several in a row (§8.2). */
function startTurn(s: NinetyNineState, p: PlayerId | null, ctx: Ctx): void {
let who = p;
// Each loss costs a life or a seat, so this always ends.
while (who !== null && s.phase === 'playing') {
if (aliveSeats(s).length <= 1) {
finish(s, ctx);
return;
}
if (hasPlay(s, who)) {
s.current = who;
s.turnNo += 1;
return;
}
who = lose(s, who, ctx);
}
}
function reshuffle(s: NinetyNineState, ctx: Ctx): void {
if (s.discard.length === 0) return;
const n = s.discard.length;
s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(s.discard)];
s.discard = [];
ctx.events.push({ type: 'reshuffled', count: n });
log(s, { t: 'reshuffle', n });
}
function playCard(s: NinetyNineState, p: PlayerId, a: PlayAction, ctx: Ctx, auto: boolean): void {
const hand = s.hands[p]!;
const card = hand.find((c) => c.id === a.cardId)!;
const r = resolvePlay(card, s.total, s.options, a);
if (!r.ok) throw new Error(`illegal play reached apply: ${r.reason}`);
const from = s.total;
s.hands[p] = hand.filter((c) => c.id !== card.id);
s.discard.push(card);
s.total = r.total;
const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' ? r.label : undefined;
ctx.events.push({ type: 'cardPlayed', player: p, face: card.face, kind: card.kind, total: s.total, ...(choice ? { choice } : {}) });
log(s, { t: 'play', p, face: card.face, kind: card.kind, from, total: s.total, ...(choice ? { choice } : {}), ...(auto ? { auto: true } : {}) });
if (card.kind === 'reverse') {
s.direction = s.direction === 1 ? -1 : 1;
ctx.events.push({ type: 'directionChanged', direction: s.direction });
}
if (s.options.refill) {
if (s.drawPile.length === 0) reshuffle(s, ctx);
const c = s.drawPile.shift();
if (c) {
s.hands[p]!.push(c);
ctx.events.push({ type: 'cardDrawn', player: p });
}
}
startTurn(s, nextAlive(s, p, s.direction), ctx);
}
function leave(s: NinetyNineState, p: PlayerId, ctx: Ctx): void {
s.leftOrder.push(p);
s.timeoutsInRow[p] = 0;
const wasCurrent = s.current === p;
const next = nextAlive(s, p, s.direction);
eliminate(s, p, 'left', ctx);
if (aliveSeats(s).length <= 1) {
finish(s, ctx);
return;
}
// Leaving is not losing a round: the total stays and the next player goes on.
if (wasCurrent) startTurn(s, next, ctx);
}
function validatePlay(s: NinetyNineState, actor: PlayerId, a: PlayAction): Validation {
if (s.current !== actor) return fail('지금은 내 차례가 아니에요');
const card = s.hands[actor]!.find((c) => c.id === a.cardId);
if (!card) return fail('손에 없는 카드예요');
const r = resolvePlay(card, s.total, s.options, a);
return r.ok ? ok : fail(r.reason);
}
function deadlineMs(s: NinetyNineState): number | null {
if (s.phase !== 'playing') return null;
return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000;
}
function handView(s: NinetyNineState, p: PlayerId, myTurn: boolean): HandCardView[] {
const o = s.options;
return (s.hands[p] ?? []).map((c) => {
const choices = choicesFor(c, s.total, o);
const legal = playable(c, s.total, o);
let badge: string | null = null;
if (!legal) badge = choices.some((x) => x.total > 99) ? '99 초과' : '0 미만';
return { id: c.id, kind: c.kind, face: c.face, choices, joker: c.kind === 'joker' ? jokerRange(o) : null, legal: legal && myTurn, badge };
});
}
// ---------------------------------------------------------------- definition
type Def = GameDefinition<NinetyNineState, NNAction, NinetyNineView, NNOptions>;
export const ninetyNine: Def = {
id: 'ninety-nine',
nameKo: '구십구',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
defaultOptions: ninetyNineOptionsSchema.parse({}),
optionsSchema: ninetyNineOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: ninetyNineActionSchema as unknown as Def['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 10) throw new Error(`ninety-nine: ${players.length} players not allowed`);
const livesStart = resolvedLives(options);
const rec = <T>(v: T) => Object.fromEntries(players.map((p) => [p, v])) as Record<PlayerId, T>;
const s: NinetyNineState = {
mode: options.mode,
options,
livesStart,
decks: resolvedDecks(options, players.length),
seats: [...players],
hands: {},
drawPile: [],
discard: [],
total: 0,
direction: 1,
current: players[0]!,
alive: rec(true),
lives: rec(livesStart),
livesLost: rec(0),
onBoard: rec(false),
eliminationOrder: [],
eliminatedAtRound: rec<number | null>(null),
leftOrder: [],
round: 1,
phase: 'playing',
turnStartedAt: now,
seq: 0,
turnNo: 0,
timeoutsInRow: rec(0),
away: rec(false),
log: [],
final: null,
};
const ctx: Ctx = { rng, now, events: [] };
// §4: shuffle the deck, then pick the first player with the same RNG.
const deck = rng.shuffle(fullDeck(s));
const starter = players[rng.int(players.length)]!;
s.drawPile = deck;
const order: PlayerId[] = [];
for (let k = 0, p = starter; k < players.length; k++, p = nextAlive(s, p, 1)) order.push(p);
s.hands = Object.fromEntries(players.map((p) => [p, [] as NNCard[]]));
for (let r = 0; r < options.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!);
// Someone may already have nothing playable (e.g. only trump 10s at 0).
startTurn(s, starter, ctx);
return s;
},
validate(s, actor, a) {
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요');
if (a.type === 'leave') return s.alive[actor] ? ok : fail('이미 게임에서 빠졌어요');
if (!s.alive[actor]) return fail('탈락해서 카드를 낼 수 없어요');
if (a.type === 'timeout') return s.current === actor ? ok : fail('시간 초과 대상이 아니에요');
if (a.type === 'play') return validatePlay(s, actor, a);
return fail('알 수 없는 행동이에요');
},
apply(prev, actor, a, { rng, now }) {
const s = cloneState(prev);
const ctx: Ctx = { rng, now, events: [] };
const before = s.turnNo;
s.seq += 1;
switch (a.type) {
case 'play':
s.timeoutsInRow[actor] = 0;
s.away[actor] = false;
playCard(s, actor, a, ctx, false);
break;
case 'timeout': {
ctx.events.push({ type: 'timeout', player: actor });
log(s, { t: 'timeout', p: actor });
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
s.away[actor] = true;
ctx.events.push({ type: 'away', player: actor });
}
const auto = safestPlay(s.hands[actor]!, s.total, s.options);
if (!auto) throw new Error('current player has no legal play');
playCard(s, actor, auto, ctx, true);
break;
}
case 'leave':
leave(s, actor, ctx);
break;
}
if (s.turnNo !== before) s.turnStartedAt = now;
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.seats.includes(viewer) ? viewer : null;
const live = s.phase === 'playing';
const myTurn = live && me !== null && s.current === me;
let from = s.log.length;
while (from > 0 && s.log[from - 1]!.t !== 'round') from--;
const plays = s.log.slice(from).filter((e): e is Extract<LogEntry, { t: 'play' }> => e.t === 'play');
return {
mode: s.mode,
options: s.options,
livesStart: s.livesStart,
jokerRange: jokerRange(s.options),
me,
seats: s.seats.map((p) => ({
id: p,
cards: s.hands[p]?.length ?? 0,
lives: s.lives[p] ?? 0,
livesLost: s.livesLost[p] ?? 0,
alive: !!s.alive[p],
onBoard: !!s.onBoard[p],
left: s.leftOrder.includes(p),
away: !!s.away[p],
isTurn: live && s.current === p,
})),
total: s.total,
direction: s.direction,
current: live ? s.current : null,
phase: s.phase,
turnNo: s.turnNo,
round: s.round,
drawPileCount: s.drawPile.length,
discardCount: s.discard.length,
recent: plays.slice(-RECENT_VIEW),
log: s.log.slice(-LOG_VIEW),
eliminationOrder: s.eliminationOrder,
my: me ? { hand: s.alive[me] ? handView(s, me, myTurn) : [], myTurn } : null,
turnStartedAt: s.turnStartedAt,
deadlineTotalMs: deadlineMs(s) ?? 0,
result: s.final,
};
},
activePlayers(s) {
return s.phase === 'playing' ? [s.current] : [];
},
deadline(s) {
const ms = deadlineMs(s);
return ms === null ? null : s.turnStartedAt + ms;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (s.phase !== 'playing' || !s.seats.includes(player) || !s.alive[player]) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
const winners = s.final.winner;
const ranking: PlayerId[][] = [winners, ...s.final.ranking.filter((r) => !winners.includes(r.player)).map((r) => [r.player])];
return { ranking, summary: s.final.summary, reason: 'normal' };
},
/** Test helper. The joker offers a few representative numbers instead of all 40. */
legalActions(s, p) {
if (s.phase !== 'playing' || s.current !== p) return [];
const out: NNAction[] = [];
for (const c of s.hands[p]!) {
if (c.kind === 'joker') {
const r = jokerRange(s.options);
const vals = [...new Set([r.min, r.max, Math.round((r.min + r.max) / 2), Math.min(r.max, Math.max(r.min, s.total))])];
for (const jokerValue of vals) out.push({ type: 'play', cardId: c.id, jokerValue });
continue;
}
for (const x of choicesFor(c, s.total, s.options)) {
if (!x.legal) continue;
out.push({ type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) });
}
}
return out;
},
/** Own hand plus the draw pile — card ids exactly as they would appear in a view. */
secretsOf(s, player) {
return [...(s.hands[player] ?? []).map((c) => c.id), ...s.drawPile.map((c) => c.id)];
},
};

View File

@@ -0,0 +1,599 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
cardTotal,
classicDeck,
fullDeck,
krDeck,
ninetyNine,
safestPlay,
type NNAction,
type NNCard,
type NNOptions,
type NinetyNineState,
type PlayAction,
} from './index';
// ---------------------------------------------------------------- helpers
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
let clock = 1_000_000;
function make(opts: Partial<NNOptions> = {}, players = ['A', 'B', 'C'], seed = 1): NinetyNineState {
const options = ninetyNine.optionsSchema.parse(opts);
return ninetyNine.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/**
* Replaces hands (by face label, e.g. "7", "±9", "0", "조커", "K"), total and turn. Unused cards become the
* draw pile in deck order (or the discard pile with `emptyDraw`).
*/
function rig(
s: NinetyNineState,
r: { hands: Record<string, string[]>; total: number; current?: string; direction?: 1 | -1; emptyDraw?: boolean },
): NinetyNineState {
const pool = fullDeck(s);
const take = (label: string): NNCard => {
const i = pool.findIndex((c) => c.face.label === label);
if (i < 0) throw new Error(`no card ${label} left`);
return pool.splice(i, 1)[0]!;
};
for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(take);
s.total = r.total;
s.current = r.current ?? s.seats[0]!;
s.direction = r.direction ?? 1;
s.drawPile = r.emptyDraw ? [] : pool;
s.discard = r.emptyDraw ? pool : [];
s.phase = 'playing';
s.final = null;
return s;
}
function actE(s: NinetyNineState, p: string, a: NNAction): { state: NinetyNineState; events: GameEvent[] } {
const v = ninetyNine.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return ninetyNine.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: NinetyNineState, p: string, a: NNAction) => actE(s, p, a).state;
const idOf = (s: NinetyNineState, p: string, label: string): string => {
const c = s.hands[p]!.find((x) => x.face.label === label);
if (!c) throw new Error(`${p} has no ${label}`);
return c.id;
};
const play = (s: NinetyNineState, p: string, label: string, extra: Partial<PlayAction> = {}): PlayAction => ({ type: 'play', cardId: idOf(s, p, label), ...extra });
const reject = (s: NinetyNineState, p: string, a: NNAction): string | null => {
const v = ninetyNine.validate(s, p, a);
return v.ok ? null : v.reason;
};
const labelsOf = (s: NinetyNineState, p: string) => s.hands[p]!.map((c) => c.face.label);
const legalLabels = (s: NinetyNineState, p: string) =>
[...new Set(ninetyNine.legalActions!(s, p).map((a) => (a.type === 'play' ? s.hands[p]!.find((c) => c.id === a.cardId)!.face.label : a.type)))].sort();
const types = (events: GameEvent[]) => events.map((e) => e.type);
// ---------------------------------------------------------------- deck and setup
describe('덱 구성과 셋업 (§3, §4)', () => {
test('kr-product default: 91 cards (1–8 ×7, 10 ×21, ±9/±10/0 ×4, joker ×2); specialCounts=2 → 85', () => {
const d = krDeck(4);
expect(d).toHaveLength(91);
const count = (f: (c: NNCard) => boolean) => d.filter(f).length;
for (let v = 1; v <= 8; v++) expect(count((c) => c.kind === 'add' && c.v === v)).toBe(7);
expect(count((c) => c.kind === 'add' && c.v === 10)).toBe(21);
expect(count((c) => c.kind === 'add' && c.v === 9)).toBe(0);
expect(count((c) => c.kind === 'plusMinus' && c.v === 9)).toBe(4);
expect(count((c) => c.kind === 'plusMinus' && c.v === 10)).toBe(4);
expect(count((c) => c.kind === 'reverse')).toBe(4);
expect(count((c) => c.kind === 'joker')).toBe(2);
expect(krDeck(2)).toHaveLength(85);
expect(new Set(d.map((c) => c.id)).size).toBe(91);
expect(cardTotal(make({ specialCounts: 2 }))).toBe(85);
});
test('classic: 52 cards (A ace, 4 reverse, 9 hold, 10 minus, K to99); 7+ players with auto → 104', () => {
const d = classicDeck(1);
expect(d).toHaveLength(52);
expect(d.filter((c) => c.kind === 'to99')).toHaveLength(4);
expect(d.filter((c) => c.kind === 'ace')).toHaveLength(4);
expect(d.filter((c) => c.kind === 'add' && c.v === 10)).toHaveLength(8); // J, Q
expect(cardTotal(make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F']))).toBe(52);
const seven = make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']);
expect(cardTotal(seven)).toBe(104);
expect(new Set(fullDeck(seven).map((c) => c.id)).size).toBe(104);
expect(cardTotal(make({ mode: 'classic', deckCount: 2 }))).toBe(104);
expect(cardTotal(make({ mode: 'classic', deckCount: 1 }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']))).toBe(52);
});
test('same seed → same deal and first player; handSize cards each; total 0; lives per mode', () => {
const a = make({}, ['A', 'B', 'C', 'D'], 5);
const b = make({}, ['A', 'B', 'C', 'D'], 5);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
expect(JSON.stringify(make({}, ['A', 'B', 'C', 'D'], 6).hands)).not.toBe(JSON.stringify(a.hands));
const starters = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).current));
expect(starters.size).toBeGreaterThan(1);
for (const p of a.seats) expect(a.hands[p]).toHaveLength(3);
expect(a.total).toBe(0);
expect(a.livesStart).toBe(0);
expect(make({ mode: 'classic' }).livesStart).toBe(3);
expect(make({ mode: 'classic', lives: 0 }).livesStart).toBe(0);
expect(make({ handSize: 5 }).hands.A).toHaveLength(5);
});
});
// ---------------------------------------------------------------- legality
describe('낼 수 있는 카드 (§5.2, §10)', () => {
test('95 with [8, 5, 4]: only 4; it makes 99; the next player with [1, 2, 3] is out at once', () => {
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['8', '5', '4'], B: ['1', '2', '3'], C: ['0', '10', '7'] }, total: 95 });
expect(legalLabels(s, 'A')).toEqual(['4']);
expect(reject(s, 'A', play(s, 'A', '8'))).toBe('99를 넘어서 낼 수 없어요');
const v = ninetyNine.view(s, 'A');
expect(v.my!.hand.map((c) => [c.face.label, c.legal, c.badge, c.choices[0]!.total])).toEqual([
['8', false, '99 초과', 103],
['5', false, '99 초과', 100],
['4', true, null, 99],
]);
const out = actE(s, 'A', play(s, 'A', '4'));
s = out.state;
expect(s.total).toBe(99);
expect(s.alive.B).toBe(false);
expect(s.eliminationOrder).toEqual(['B']);
expect(s.current).toBe('C');
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated']);
expect(s.hands.B).toEqual([]);
expect(cardTotal(s)).toBe(91);
});
test('±10 at 5: minus is refused with forbid, makes 0 with clamp; plus makes 15; the sign is required', () => {
const base = (floorAtZero: NNOptions['floorAtZero']) => rig(make({ floorAtZero }), { hands: { A: ['±10', '1'], B: ['2'], C: ['3'] }, total: 5 });
let s = base('forbid');
expect(reject(s, 'A', play(s, 'A', '±10', { sign: '-' }))).toBe('0보다 작아져서 뺄 수 없어요');
expect(reject(s, 'A', play(s, 'A', '±10'))).toBe('더하기와 빼기 중 하나를 골라 주세요');
expect(act(s, 'A', play(s, 'A', '±10', { sign: '+' })).total).toBe(15);
expect(ninetyNine.view(s, 'A').my!.hand[0]!.choices.map((c) => [c.label, c.total, c.legal])).toEqual([
['+10', 15, true],
['−10', -5, false],
]);
s = base('clamp');
expect(act(s, 'A', play(s, 'A', '±10', { sign: '-' })).total).toBe(0);
// ±9 at 95: plus is over, minus is fine → still playable.
s = rig(make(), { hands: { A: ['±9'], B: ['1'], C: ['1'] }, total: 95 });
expect(reject(s, 'A', play(s, 'A', '±9', { sign: '+' }))).toBe('99를 넘어서 낼 수 없어요');
expect(act(s, 'A', play(s, 'A', '±9', { sign: '-' })).total).toBe(86);
});
test('joker at 20: 99 is fine; 59 and 100 are refused; 0~99 range allows small numbers', () => {
const s = rig(make(), { hands: { A: ['조커', '1'], B: ['2'], C: ['3'] }, total: 20 });
expect(act(s, 'A', play(s, 'A', '조커', { jokerValue: 99 })).total).toBe(99);
const high = rig(make(), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 70 });
expect(act(high, 'A', play(high, 'A', '조커', { jokerValue: 70 })).total).toBe(70); // the same number is fine
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 59 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 100 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
expect(ninetyNine.actionSchema.safeParse(play(s, 'A', '조커', { jokerValue: 100 })).success).toBe(false);
expect(reject(s, 'A', play(s, 'A', '조커'))).toContain('골라 주세요');
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 70.5 }))).toContain('60부터 99');
const kid = rig(make({ jokerRange: '0-99' }), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 80 });
expect(act(kid, 'A', play(kid, 'A', '조커', { jokerValue: 0 })).total).toBe(0);
expect(ninetyNine.view(kid, 'A').my!.hand[0]!.joker).toEqual({ min: 0, max: 99 });
});
test('0 card: with 3 players B’s 0 turns the direction and A plays next; with 2 players it stays the opponent', () => {
let s = rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' });
const out = actE(s, 'B', play(s, 'B', '0'));
s = out.state;
expect(s.total).toBe(30);
expect(s.direction).toBe(-1);
expect(s.current).toBe('A');
expect(types(out.events)).toContain('directionChanged');
s = act(s, 'A', play(s, 'A', '1'));
expect(s.current).toBe('C');
let two = rig(make({}, ['A', 'B']), { hands: { A: ['1'], B: ['0', '3'] }, total: 30, current: 'B' });
two = act(two, 'B', play(two, 'B', '0'));
expect(two.direction).toBe(-1);
expect(two.current).toBe('A');
});
test('classic: K at 90 → 99; then only 4 / 9 / 10 / K; A is refused even as 1; A as 11 at 88 makes 99', () => {
const o = { mode: 'classic' as const, lives: 0 as const };
let s = rig(make(o), { hands: { A: ['K', '2'], B: ['A', '4', '9', '10', 'K', '2', 'J'], C: ['3'] }, total: 90 });
s = act(s, 'A', play(s, 'A', 'K'));
expect(s.total).toBe(99);
expect(s.current).toBe('B');
expect(legalLabels(s, 'B')).toEqual(['10', '4', '9', 'K']);
expect(reject(s, 'B', play(s, 'B', 'A', { aceValue: 1 }))).toBe('99를 넘어서 낼 수 없어요');
expect(act(s, 'B', play(s, 'B', '10')).total).toBe(89);
expect(act(s, 'B', play(s, 'B', '9')).total).toBe(99);
expect(act(s, 'B', play(s, 'B', 'K')).total).toBe(99);
s = rig(make(o), { hands: { A: ['A', '2'], B: ['3'], C: ['3'] }, total: 88 });
expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 11 })).total).toBe(99);
expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 1 })).total).toBe(89);
expect(reject(s, 'A', play(s, 'A', 'A'))).toBe('A를 1로 낼지 11로 낼지 골라 주세요');
expect(reject(s, 'A', play(s, 'A', 'A', { aceValue: 10 }))).toBe('A는 1이나 11로만 낼 수 있어요');
const ten = rig(make({ ...o, aceHigh: 10 }), { hands: { A: ['A'], B: ['3'], C: ['3'] }, total: 80 });
expect(act(ten, 'A', play(ten, 'A', 'A', { aceValue: 10 })).total).toBe(90);
// 10 at 5: forbid refuses, clamp makes 0.
const low = rig(make(o), { hands: { A: ['10', '2'], B: ['3'], C: ['3'] }, total: 5 });
expect(reject(low, 'A', play(low, 'A', '10'))).toBe('0보다 작아져서 뺄 수 없어요');
const clamp = rig(make({ ...o, floorAtZero: 'clamp' }), { hands: { A: ['10'], B: ['3'], C: ['3'] }, total: 5 });
expect(act(clamp, 'A', play(clamp, 'A', '10')).total).toBe(0);
// 9 passes without changing anything; 4 reverses.
const hold = rig(make(o), { hands: { A: ['9'], B: ['3'], C: ['3'] }, total: 50 });
const h = act(hold, 'A', play(hold, 'A', '9'));
expect([h.total, h.direction, h.current]).toEqual([50, 1, 'B']);
});
test('a cleared hand (refill off) has nothing to play and loses at the start of the turn', () => {
let s = rig(make({ refill: false }), { hands: { A: ['1'], B: ['2', '3'], C: ['4'] }, total: 10 });
s = act(s, 'A', play(s, 'A', '1'));
expect(s.hands.A).toEqual([]);
s = act(s, 'B', play(s, 'B', '2'));
s = act(s, 'C', play(s, 'C', '4'));
expect(s.alive.A).toBe(false);
expect(s.current).toBe('B');
});
});
// ---------------------------------------------------------------- losing
describe('패배 처리 (§5.3, §10)', () => {
test('lives=3: the loser loses one life, a new round starts at 0 with the loser first; at 0 lives → out, the next one starts', () => {
let s = rig(make({ lives: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
const out = actE(s, 'A', play(s, 'A', '4'));
s = out.state;
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'lifeLost', 'roundStarted']);
expect(s.lives.B).toBe(2);
expect(s.livesLost.B).toBe(1);
expect(s.alive.B).toBe(true);
expect([s.total, s.round, s.current, s.direction]).toEqual([0, 2, 'B', 1]);
for (const p of s.seats) expect(s.hands[p]).toHaveLength(3);
expect(s.discard).toEqual([]);
expect(cardTotal(s)).toBe(91);
// B on the last life loses again → out; C (next after B) starts round 3.
s = rig(s, { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
s.lives.B = 1;
s = act(s, 'A', play(s, 'A', '4'));
expect(s.alive.B).toBe(false);
expect(s.lives.B).toBe(0);
expect(s.eliminationOrder).toEqual(['B']);
expect([s.total, s.round, s.current]).toEqual([0, 3, 'C']);
expect(s.hands.B).toEqual([]);
expect(s.hands.A).toHaveLength(3);
expect(s.eliminatedAtRound.B).toBe(2);
});
test('onTheBoard: at 0 lives the player stays in and is out only after one more loss', () => {
let s = rig(make({ lives: 1, onTheBoard: true }), { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 });
s = act(s, 'A', play(s, 'A', '4'));
expect([s.alive.B, s.lives.B, s.onBoard.B, s.current]).toEqual([true, 0, true, 'B']);
s = rig(s, { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 });
s = act(s, 'A', play(s, 'A', '4'));
expect(s.alive.B).toBe(false);
expect(s.current).toBe('C');
});
test('chain (lives=0, continue): at 98 the next two have nothing → both out in one apply, in order; the third plays', () => {
let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['8'], B: ['2', '3', '5'], C: ['2', '4', '6'], D: ['1', '10'] }, total: 90 });
const out = actE(s, 'A', play(s, 'A', '8'));
s = out.state;
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated', 'noCard', 'eliminated']);
expect(out.events.filter((e) => e.type === 'eliminated').map((e) => e.player)).toEqual(['B', 'C']);
expect(s.eliminationOrder).toEqual(['B', 'C']);
expect([s.total, s.current]).toEqual([98, 'D']);
expect(cardTotal(s)).toBe(91);
});
test('reset after an elimination: total back to 0, everyone redealt, the next player starts', () => {
let s = rig(make({ afterElimination: 'reset' }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
s = act(s, 'A', play(s, 'A', '4'));
expect(s.alive.B).toBe(false);
expect([s.total, s.current, s.round]).toEqual([0, 'C', 2]);
expect(s.hands.A).toHaveLength(3);
expect(s.hands.C).toHaveLength(3);
expect(cardTotal(s)).toBe(91);
});
test('last one standing wins: ranking by elimination order, Korean summary, lives lost in the result', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['4'], B: ['5', '6'] }, total: 95 });
s = act(s, 'A', play(s, 'A', '4'));
expect(s.phase).toBe('finished');
const r = ninetyNine.result(s)!;
expect(r.ranking).toEqual([['A'], ['B']]);
expect(r.summary).toBe('끝까지 살아남은 사람이 이겼어요! 마지막 합계 99');
expect(s.final!.ranking.map((x) => [x.player, x.rank, x.eliminatedAtRound])).toEqual([
['A', 1, null],
['B', 2, 1],
]);
expect(ninetyNine.activePlayers(s)).toEqual([]);
expect(ninetyNine.deadline!(s)).toBeNull();
expect(reject(s, 'A', { type: 'timeout' })).toBe('게임이 이미 끝났어요');
});
});
// ---------------------------------------------------------------- piles
describe('더미 소진 (§5.1)', () => {
test('refill with an empty draw pile: the discards are shuffled into a new pile; the total stays', () => {
let s = rig(make(), { hands: { A: ['7', '1', '2'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true });
const n = s.discard.length;
const out = actE(s, 'A', play(s, 'A', '7'));
s = out.state;
expect(types(out.events)).toEqual(['cardPlayed', 'reshuffled', 'cardDrawn']);
expect(out.events[1]!.count).toBe(n + 1);
expect(s.total).toBe(47);
expect(s.hands.A).toHaveLength(3);
expect(s.discard).toEqual([]);
expect(s.drawPile).toHaveLength(n);
expect(cardTotal(s)).toBe(91);
// Only the card just played is left: it is shuffled back and drawn again.
let dry = rig(make(), { hands: { A: ['7'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true });
dry.discard = [];
dry = act(dry, 'A', play(dry, 'A', '7'));
expect(labelsOf(dry, 'A')).toEqual(['7']);
expect([dry.discard.length, dry.drawPile.length, dry.total]).toEqual([0, 0, 47]);
});
});
// ---------------------------------------------------------------- timer / away / leave
describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0; [±9, 3, 0] at 50 → −9 (41)', () => {
let s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
expect(ninetyNine.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
expect(ninetyNine.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
const out = actE(s, 'A', { type: 'timeout' });
expect(labelsOf(out.state, 'A')).not.toContain('0');
expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, -1, 'C']);
expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, auto: true });
s = rig(make(), { hands: { A: ['±9', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
expect(act(s, 'A', { type: 'timeout' }).total).toBe(41);
// Joker only → the lowest joker number; tie at the same total → the no-change card first.
s = rig(make(), { hands: { A: ['조커'], B: ['1'], C: ['1'] }, total: 30 });
expect(act(s, 'A', { type: 'timeout' }).total).toBe(60);
s = rig(make(), { hands: { A: ['조커', '0'], B: ['1'], C: ['1'] }, total: 60 });
const t = act(s, 'A', { type: 'timeout' });
expect([t.total, t.direction]).toEqual([60, -1]);
const cls = rig(make({ mode: 'classic' }), { hands: { A: ['K', 'A', '9'], B: ['2'], C: ['2'] }, total: 95 });
expect(safestPlay(cls.hands.A!, 95, cls.options)).toEqual({ type: 'play', cardId: idOf(cls, 'A', '9') });
});
test('a disconnected player keeps being auto-played; after 3 in a row the limit is 3 s; the game never stalls', () => {
let s = make({ turnSeconds: 15 }, ['A', 'B', 'C'], 3);
const first = s.current;
expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 15_000);
let steps = 0;
while (!ninetyNine.result(s) && steps < 2000) {
const p = ninetyNine.activePlayers(s)[0]!;
s = act(s, p, ninetyNine.onTimeout(s, p));
steps++;
if (steps === 9 && s.phase === 'playing' && s.timeoutsInRow[s.current] === 3) {
expect(s.away[s.current]).toBe(true);
expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 3000);
}
}
expect(ninetyNine.result(s)).not.toBeNull();
expect(first).toBeTruthy();
// A real play clears the away flag.
let t = rig(make(), { hands: { A: ['1', '2'], B: ['1'], C: ['1'] }, total: 10 });
t.away.A = true;
t.timeoutsInRow.A = 4;
t = act(t, 'A', play(t, 'A', '1'));
expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 0]);
});
test('leaving: the current player leaving passes the turn with the same total; 2 players → the other wins', () => {
let s = rig(make({ lives: 3 }), { hands: { A: ['1'], B: ['2'], C: ['3'] }, total: 40 });
expect(ninetyNine.onLeave!(s, 'A')).toEqual({ type: 'leave' });
s = act(s, 'A', { type: 'leave' });
expect([s.alive.A, s.current, s.total, s.round]).toEqual([false, 'B', 40, 1]);
expect(ninetyNine.onLeave!(s, 'A')).toBeNull();
s = act(s, 'C', { type: 'leave' });
expect(s.phase).toBe('finished');
expect(ninetyNine.result(s)!.ranking).toEqual([['B'], ['C'], ['A']]);
expect(s.final!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요');
expect(ninetyNine.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
});
});
// ---------------------------------------------------------------- validation and leaks
describe('검증과 정보 유출 (§8.3, §10)', () => {
test('not my turn, ± without a sign, joker number out of range, resent packet: refused and nothing changes', () => {
const s = rig(make(), { hands: { A: ['±9', '조커', '3'], B: ['1', '2'], C: ['4'] }, total: 30 });
const snap = JSON.stringify(s);
expect(reject(s, 'B', play(s, 'B', '1'))).toBe('지금은 내 차례가 아니에요');
expect(reject(s, 'A', play(s, 'A', '±9'))).toBe('더하기와 빼기 중 하나를 골라 주세요');
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 40 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
expect(reject(s, 'A', { type: 'play', cardId: s.hands.B![0]!.id })).toBe('손에 없는 카드예요');
expect(reject(s, 'A', play(s, 'A', '3', { sign: '+' }))).toBe('이 카드는 고를 것 없이 바로 내요');
expect(reject(s, 'X', play(s, 'A', '3'))).toBe('이 게임의 플레이어가 아니에요');
const a = play(s, 'A', '3');
const next = act(s, 'A', a);
expect(reject(next, 'A', a)).toBe('지금은 내 차례가 아니에요');
expect(JSON.stringify(s)).toBe(snap);
const dead = rig(make(), { hands: { A: ['4'], B: ['5', '6'], C: ['0', '2'] }, total: 95 });
const after = act(dead, 'A', play(dead, 'A', '4'));
expect(reject(after, 'B', { type: 'timeout' })).toBe('탈락해서 카드를 낼 수 없어요');
});
test('views: opponents and spectators see counts only; refills and eliminated hands are never revealed', () => {
let s = rig(make(), { hands: { A: ['4', '1', '2'], B: ['5', '6', '7'], C: ['0', '3', '8'] }, total: 95 });
const bHand = s.hands.B!.map((c) => c.id);
const out = actE(s, 'A', play(s, 'A', '4'));
s = out.state;
const drawn = s.hands.A!.at(-1)!.id;
for (const viewer of ['B', 'C', null]) {
const json = JSON.stringify(ninetyNine.view(s, viewer));
expect(json).not.toContain(`"${drawn}"`);
for (const id of bHand) expect(json).not.toContain(`"${id}"`);
for (const c of s.drawPile.slice(0, 20)) expect(json).not.toContain(`"${c.id}"`);
checkNoLeak(ninetyNine, s, viewer ?? 'C', s.seats);
}
expect(JSON.stringify(ninetyNine.view(s, 'A'))).toContain(`"${drawn}"`);
expect(JSON.stringify(out.events)).not.toContain('"id"');
expect(out.events.find((e) => e.type === 'cardDrawn')).toEqual({ type: 'cardDrawn', player: 'A' });
const v = ninetyNine.view(s, null);
expect(v.my).toBeNull();
expect(v.seats.map((x) => [x.id, x.cards, x.alive])).toEqual([
['A', 3, true],
['B', 0, false],
['C', 3, true],
]);
expect(v.recent.map((e) => [e.face.label, e.total])).toEqual([['4', 99]]);
expect(ninetyNine.view(s, 'B').my!.hand).toEqual([]);
});
test('options: defaults, Korean metadata on every option; client actions only in the schema', () => {
const d = ninetyNine.defaultOptions;
expect(d).toMatchObject({ mode: 'kr-product', handSize: 3, refill: true, lives: 'auto', onTheBoard: false, afterElimination: 'continue', floorAtZero: 'forbid', specialCounts: 4, jokerRange: '60-99', aceHigh: 11, deckCount: 'auto', turnSeconds: 15 });
const shape = (ninetyNine.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } | undefined }> }).shape;
for (const [k, f] of Object.entries(shape)) {
const m = f.meta();
expect(m?.title, k).toMatch(/[가-힣]/);
expect(m?.labels, k).toBeTruthy();
}
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', sign: '+' }).success).toBe(true);
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', aceValue: 5 }).success).toBe(false);
});
});
// ---------------------------------------------------------------- random playouts
interface Report {
steps: number;
finished: boolean;
trace: string;
final: NinetyNineState;
}
const deckSizeOf = (s: NinetyNineState): number => (s.mode === 'classic' ? 52 * s.decks : 79 + 3 * s.options.specialCounts);
function invariants(s: NinetyNineState): void {
const size = deckSizeOf(s);
if (cardTotal(s) !== size) throw new Error(`card count ${cardTotal(s)} != ${size}`);
const ids = new Set<string>();
for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) {
if (ids.has(c.id)) throw new Error(`duplicate card ${c.id}`);
ids.add(c.id);
}
if (s.total < 0 || s.total > 99) throw new Error(`total out of range ${s.total}`);
for (const p of s.seats) {
if (!s.alive[p] && s.hands[p]!.length) throw new Error(`eliminated ${p} holds cards`);
if (s.lives[p]! < 0) throw new Error('negative lives');
}
if (s.phase === 'playing') {
if (!s.alive[s.current]) throw new Error('current player is out');
if (!ninetyNine.legalActions!(s, s.current).length) throw new Error('current player has no legal move');
}
const alive = s.seats.filter((p) => s.alive[p]).length;
if (s.phase === 'finished' ? alive !== 1 : alive < 2) throw new Error(`alive=${alive} in ${s.phase}`);
}
function checkLeaks(s: NinetyNineState, players: PlayerId[]): void {
for (const p of players) checkNoLeak(ninetyNine, s, p, players);
}
/** Cheap per-step version: the only card ids in a view are the viewer's own hand (no discard, pile or other hands). */
const CARD_ID = /"(k\d+|t[SHDC]\d+b?)"/g;
function checkIds(s: NinetyNineState, players: PlayerId[]): void {
for (const p of [...players, null]) {
const own = new Set(p ? s.hands[p]!.map((c) => c.id) : []);
for (const m of JSON.stringify(ninetyNine.view(s, p)).matchAll(CARD_ID)) {
if (!own.has(m[1]!)) throw new Error(`view for ${p} shows card ${m[1]} it does not hold`);
}
}
}
function fastPlayout(players: PlayerId[], options: NNOptions, seed: RngState, maxSteps: number): Report {
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 = ninetyNine.setup({ players, options, rng: r, now });
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 3 === 0) checkIds(s, players);
if (step % 200 === 100) checkLeaks(s, players);
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
if (ninetyNine.result(s)) {
checkIds(s, players);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const actor = ninetyNine.activePlayers(s)[0]!;
const v0 = ninetyNine.validate(s, actor, ninetyNine.onTimeout(s, actor));
if (!v0.ok) throw new Error(`onTimeout rejected: ${v0.reason}`);
const legal = ninetyNine.legalActions!(s, actor);
// Mostly random legal plays, sometimes the timer move or someone leaving.
const roll = chooser.int(100);
let who = actor;
let action: NNAction;
if (roll < 8) action = { type: 'timeout' };
else if (roll === 99) {
const alive = s.seats.filter((p) => s.alive[p]);
who = alive[chooser.int(alive.length)]!;
action = ninetyNine.onLeave!(s, who)!;
} else action = legal[chooser.int(legal.length)]!;
const v = ninetyNine.validate(s, who, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const out = ninetyNine.apply(s, who, action, { rng: r, now });
if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id');
s = out.state;
trace.push(`${who}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
const PRESETS: Partial<NNOptions>[] = [
{},
{ lives: 3 },
{ afterElimination: 'reset', floorAtZero: 'clamp', specialCounts: 6 },
{ refill: false, handSize: 5 },
{ jokerRange: '0-99', handSize: 4, lives: 2, onTheBoard: true },
{ mode: 'classic' },
{ mode: 'classic', lives: 0, aceHigh: 10 },
{ mode: 'classic', lives: 1, onTheBoard: true, floorAtZero: 'clamp', deckCount: 2 },
{ mode: 'classic', refill: false, handSize: 5, lives: 2 },
{ mode: 'classic', deckCount: 1, handSize: 5, lives: 5 },
];
describe('random playouts', () => {
test('1,000 seeded games finish: cards conserved, no leaks, onTimeout always legal, deterministic', () => {
let finished = 0;
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % 9);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = ninetyNine.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
const a = fastPlayout(players, options, seedN(i), 20_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`);
finished++;
const res = ninetyNine.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
}, 60_000);
test.each([
[2, {}],
[3, { lives: 3 }],
[5, { afterElimination: 'reset' }],
[10, {}],
[4, { mode: 'classic' }],
[8, { mode: 'classic', lives: 1, onTheBoard: true }],
] as const)('randomPlayout with the engine’s per-step checks: %i players %o', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = ninetyNine.optionsSchema.parse(opts);
const check = (st: unknown) => invariants(st as NinetyNineState);
for (let i = 1; i <= 3; i++) {
const a = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: check, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,203 @@
/** 구십구 options, actions, state and view types (docs/games/ninety-nine.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
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 ninetyNineOptionsSchema = z.object({
mode: z
.enum(['kr-product', 'classic'])
.default('kr-product')
.meta({ title: '규칙', labels: { 'kr-product': '전용 카드(±9·±10·0·조커)', classic: '트럼프 카드(전통 99)' } }),
handSize: lit(3, 4, 5).default(3).meta({ title: '손패 장수', labels: { 3: '3장', 4: '4장', 5: '5장' } }),
refill: z.boolean().default(true).meta({ title: '카드를 낸 뒤 1장 보충', labels: { true: '보충해요', false: '보충 안 해요' } }),
lives: lit<'auto' | 0 | 1 | 2 | 3 | 5>('auto', 0, 1, 2, 3, 5)
.default('auto')
.meta({
title: '목숨 수',
labels: { auto: '규칙 기본(전용 카드 없음, 트럼프 3개)', 0: '없음(바로 탈락)', 1: '1개', 2: '2개', 3: '3개', 5: '5개' },
}),
onTheBoard: z
.boolean()
.default(false)
.meta({ title: '목숨을 다 잃어도 한 번 더 질 때까지 남기', labels: { true: '켜기', false: '끄기' } }),
afterElimination: z
.enum(['continue', 'reset'])
.default('continue')
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '0으로 하고 다시 나누기' } }),
floorAtZero: z
.enum(['forbid', 'clamp'])
.default('forbid')
.meta({ title: '빼서 0보다 작아질 때', labels: { forbid: '그렇게는 못 빼요', clamp: '0으로 맞춰요' } }),
specialCounts: lit(2, 3, 4, 5, 6)
.default(4)
.meta({ title: '(전용 카드) ±9·±10·0 카드 장수(각각)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장', 6: '6장' } }),
jokerRange: z
.enum(['60-99', '0-99'])
.default('60-99')
.meta({ title: '(전용 카드) 조커로 부를 수 있는 수', labels: { '60-99': '60~99', '0-99': '0~99(어린이용)' } }),
aceHigh: lit(10, 11).default(11).meta({ title: '(트럼프) A의 큰 값', labels: { 10: '10', 11: '11' } }),
deckCount: lit<'auto' | 1 | 2>('auto', 1, 2)
.default('auto')
.meta({ title: '(트럼프) 덱 수', labels: { auto: '자동(7명부터 2벌)', 1: '1벌', 2: '2벌' } }),
turnSeconds: lit(10, 15, 20, 30)
.default(15)
.meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }),
});
export type NNOptions = z.infer<typeof ninetyNineOptionsSchema>;
export type NNMode = NNOptions['mode'];
export const ninetyNineActionSchema = z.object({
type: z.literal('play'),
cardId: z.string().max(12),
sign: z.enum(['+', '-']).optional(),
aceValue: lit(1, 10, 11).optional(),
jokerValue: z.number().int().min(0).max(99).optional(),
});
export type PlayAction = z.infer<typeof ninetyNineActionSchema>;
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type NNAction = PlayAction | { type: 'timeout' } | { type: 'leave' };
export type Suit = 'S' | 'H' | 'D' | 'C';
/** What is printed on the card (no id: safe to show in logs). */
export interface CardFace {
/** kr: "7", "10", "±9", "0", "조커"; classic: "A", "2".."10", "J", "Q", "K". */
label: string;
/** Classic only. */
suit?: Suit;
}
export type NNCard =
| { id: string; kind: 'add'; v: number; face: CardFace }
| { id: string; kind: 'plusMinus'; v: 9 | 10; face: CardFace }
| { id: string; kind: 'reverse'; face: CardFace }
| { id: string; kind: 'hold'; face: CardFace }
| { id: string; kind: 'minus'; v: 10; face: CardFace }
| { id: string; kind: 'ace'; face: CardFace }
| { id: string; kind: 'to99'; face: CardFace }
| { id: string; kind: 'joker'; face: CardFace };
export type CardKind = NNCard['kind'];
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
| { seq: number; t: 'play'; p: PlayerId; face: CardFace; kind: CardKind; from: number; total: number; choice?: string; auto?: boolean }
| { seq: number; t: 'life'; p: PlayerId; left: number }
| { seq: number; t: 'out'; p: PlayerId; why: 'noCard' | 'left' }
| { seq: number; t: 'noCard'; p: PlayerId }
| { seq: number; t: 'round'; round: number; starter: PlayerId }
| { seq: number; t: 'reshuffle'; n: number }
| { seq: number; t: 'timeout'; p: PlayerId };
export interface PlayerResult {
player: PlayerId;
rank: number;
livesLost: number;
eliminatedAtRound: number | null;
left: boolean;
}
export interface NNFinal {
ranking: PlayerResult[];
winner: PlayerId[];
lastTotal: number;
summary: string;
}
export interface NinetyNineState {
mode: NNMode;
options: NNOptions;
/** Lives per player resolved from the `auto` option (0 = no lives). */
livesStart: number;
decks: 1 | 2;
seats: PlayerId[];
hands: Record<PlayerId, NNCard[]>;
/** Secret; [0] is the top. */
drawPile: NNCard[];
/** Only the count is public (faces are in the log). */
discard: NNCard[];
total: number;
direction: 1 | -1;
current: PlayerId;
alive: Record<PlayerId, boolean>;
lives: Record<PlayerId, number>;
livesLost: Record<PlayerId, number>;
onBoard: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
eliminatedAtRound: Record<PlayerId, number | null>;
leftOrder: PlayerId[];
round: number;
phase: 'playing' | 'finished';
turnStartedAt: number;
/** Applied actions. */
seq: number;
/** Changes whenever a new turn starts. */
turnNo: number;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
log: LogEntry[];
final: NNFinal | null;
}
// ---------------------------------------------------------------- view
/** One way to play a card (e.g. "+9" or "−9") and the total it would make. */
export interface ChoiceView {
label: string;
sign?: '+' | '-';
aceValue?: 1 | 10 | 11;
/** Total after playing (may be over 99 or below 0 when not legal). */
total: number;
legal: boolean;
reason?: string;
}
export interface HandCardView {
id: string;
kind: CardKind;
face: CardFace;
/** Ways to play it; the joker has none (pick a number in `joker`). */
choices: ChoiceView[];
joker: { min: number; max: number } | null;
legal: boolean;
/** Short badge for an unplayable card, e.g. "99 초과". */
badge: string | null;
}
export interface SeatView {
id: PlayerId;
cards: number;
lives: number;
livesLost: number;
alive: boolean;
onBoard: boolean;
left: boolean;
away: boolean;
isTurn: boolean;
}
export interface NinetyNineView {
mode: NNMode;
options: NNOptions;
livesStart: number;
jokerRange: { min: number; max: number };
me: PlayerId | null;
seats: SeatView[];
total: number;
direction: 1 | -1;
current: PlayerId | null;
phase: NinetyNineState['phase'];
turnNo: number;
round: number;
drawPileCount: number;
discardCount: number;
/** Plays of this round, newest last (up to 6). */
recent: Extract<LogEntry, { t: 'play' }>[];
log: LogEntry[];
eliminationOrder: PlayerId[];
my: { hand: HandCardView[]; myTurn: boolean } | null;
turnStartedAt: number;
deadlineTotalMs: number;
result: NNFinal | null;
}

View File

@@ -0,0 +1,222 @@
/**
* 벽 쌓기 길찾기 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로.
* 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점.
*/
export type Seat = 'S' | 'W' | 'N' | 'E';
export interface Cell {
c: number;
r: number;
}
export type Orientation = 'h' | 'v';
export interface Wall {
c: number;
r: number;
o: Orientation;
}
/** 8×8 앵커별 점유, 인덱스 r*8+c. */
export interface WallIndex {
h: boolean[];
v: boolean[];
}
export type JumpTrigger = 'wallEdgePawn' | 'wallOnly';
export const SIZE = 9;
export const ANCHORS = 8;
const CELLS = SIZE * SIZE;
export const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
export const START: Record<Seat, Cell> = { S: { c: 4, r: 0 }, W: { c: 0, r: 4 }, N: { c: 4, r: 8 }, E: { c: 8, r: 4 } };
/** 목표 줄: 남 → 9행, 북 → 1행, 서 → i열, 동 → a열. */
export const isGoal = (seat: Seat, c: number, r: number): boolean =>
seat === 'S' ? r === SIZE - 1 : seat === 'N' ? r === 0 : seat === 'W' ? c === SIZE - 1 : c === 0;
export const emptyWallIndex = (): WallIndex => ({ h: new Array<boolean>(64).fill(false), v: new Array<boolean>(64).fill(false) });
export const onBoard = (c: number, r: number): boolean => c >= 0 && r >= 0 && c < SIZE && r < SIZE;
export const anchorInRange = (c: number, r: number): boolean => Number.isInteger(c) && Number.isInteger(r) && c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS;
export const cellIdx = (c: number, r: number): number => r * SIZE + c;
export const sameCell = (a: Cell, b: Cell): boolean => a.c === b.c && a.r === b.r;
export const cellLabel = (p: Cell): string => `${'abcdefghi'[p.c]}${p.r + 1}`;
export const wallLabel = (w: Wall): string => `${w.o === 'h' ? '가로' : '세로'} ${cellLabel(w)}`;
const hasH = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.h[r * ANCHORS + c]!;
const hasV = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.v[r * ANCHORS + c]!;
/** (c,r)에서 (dc,dr) 방향 이웃으로 가는 길이 벽에 막혔는지. 판 안 여부는 호출하는 쪽이 본다. */
export function blocked(w: WallIndex, c: number, r: number, dc: number, dr: number): boolean {
if (dr === 1) return hasH(w, c, r) || hasH(w, c - 1, r);
if (dr === -1) return hasH(w, c, r - 1) || hasH(w, c - 1, r - 1);
if (dc === 1) return hasV(w, c, r) || hasV(w, c, r - 1);
return hasV(w, c - 1, r) || hasV(w, c - 1, r - 1);
}
/** 북·남·동·서 순서(결정적). */
export const DIRS: readonly [number, number][] = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
/** 기존 벽과 겹치는지(§5.3 조건 3): 같은 방향 반 칸 겹침, 같은 앵커 십자 교차. */
export function overlaps(w: WallIndex, c: number, r: number, o: Orientation): boolean {
if (o === 'h') return hasH(w, c - 1, r) || hasH(w, c, r) || hasH(w, c + 1, r) || hasV(w, c, r);
return hasV(w, c, r - 1) || hasV(w, c, r) || hasV(w, c, r + 1) || hasH(w, c, r);
}
export function setWall(w: WallIndex, wall: Wall, on: boolean): void {
(wall.o === 'h' ? w.h : w.v)[wall.r * ANCHORS + wall.c] = on;
}
// BFS 작업 공간(호출마다 stamp로 초기화 → 할당 없음). 순수 함수 결과에는 영향 없음.
const queue = new Int16Array(CELLS);
const seen = new Int32Array(CELLS);
const dist = new Int16Array(CELLS);
const parent = new Int16Array(CELLS);
let stamp = 0;
/** 말은 무시하고 벽만 보는 최단 거리(칸 수). 길이 없으면 -1. §5.3 경로 검사. */
export function pathLength(w: WallIndex, start: Cell, seat: Seat): number {
if (isGoal(seat, start.c, start.r)) return 0;
stamp++;
let head = 0;
let tail = 0;
const s0 = cellIdx(start.c, start.r);
queue[tail++] = s0;
seen[s0] = stamp;
dist[s0] = 0;
while (head < tail) {
const cur = queue[head++]!;
const c = cur % SIZE;
const r = (cur - c) / SIZE;
for (const [dc, dr] of DIRS) {
const nc = c + dc;
const nr = r + dr;
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
const ni = nr * SIZE + nc;
if (seen[ni] === stamp) continue;
seen[ni] = stamp;
dist[ni] = dist[cur]! + 1;
if (isGoal(seat, nc, nr)) return dist[ni]!;
queue[tail++] = ni;
}
}
return -1;
}
/** 출발 칸 다음부터 목표 줄의 칸까지 최단 경로(결정적). 길이 없으면 null. */
export function shortestPath(w: WallIndex, start: Cell, seat: Seat): Cell[] | null {
if (isGoal(seat, start.c, start.r)) return [];
stamp++;
let head = 0;
let tail = 0;
const s0 = cellIdx(start.c, start.r);
queue[tail++] = s0;
seen[s0] = stamp;
parent[s0] = -1;
while (head < tail) {
const cur = queue[head++]!;
const c = cur % SIZE;
const r = (cur - c) / SIZE;
for (const [dc, dr] of DIRS) {
const nc = c + dc;
const nr = r + dr;
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
const ni = nr * SIZE + nc;
if (seen[ni] === stamp) continue;
seen[ni] = stamp;
parent[ni] = cur;
if (isGoal(seat, nc, nr)) {
const out: Cell[] = [];
for (let i = ni; i !== s0; i = parent[i]!) out.push({ c: i % SIZE, r: Math.floor(i / SIZE) });
return out.reverse();
}
queue[tail++] = ni;
}
}
return null;
}
export interface Pawn {
seat: Seat;
pos: Cell;
}
/** §5.2 이동 가능 칸(중복 없음, 결정적 순서). `pawns`는 판에 있는 모든 말(나 포함). */
export function legalPawnMoves(w: WallIndex, pawns: readonly Pawn[], me: Cell, trigger: JumpTrigger): Cell[] {
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
const out: Cell[] = [];
const seenOut = new Set<number>();
const add = (c: number, r: number) => {
const i = cellIdx(c, r);
if (!seenOut.has(i)) {
seenOut.add(i);
out.push({ c, r });
}
};
for (const [dc, dr] of DIRS) {
const nc = me.c + dc;
const nr = me.r + dr;
if (!onBoard(nc, nr) || blocked(w, me.c, me.r, dc, dr)) continue;
if (!occ.has(cellIdx(nc, nr))) {
add(nc, nr);
continue;
}
// 인접 칸에 말 Q: 곧게 뛰어넘기 → 안 되면 조건에 따라 대각.
const jc = nc + dc;
const jr = nr + dr;
const jOn = onBoard(jc, jr);
const wallBehind = jOn && blocked(w, nc, nr, dc, dr);
if (jOn && !wallBehind && !occ.has(cellIdx(jc, jr))) {
add(jc, jr);
continue;
}
if (trigger === 'wallOnly' && !wallBehind) continue;
for (const [pc, pr] of dc === 0 ? [[1, 0], [-1, 0]] : [[0, 1], [0, -1]]) {
const tc = nc + pc!;
const tr = nr + pr!;
if (onBoard(tc, tr) && !blocked(w, nc, nr, pc!, pr!) && !occ.has(cellIdx(tc, tr))) add(tc, tr);
}
}
return out;
}
/** 이동할 수 없는 칸의 이유(한국어). */
export function moveRefusal(w: WallIndex, pawns: readonly Pawn[], me: Cell, to: Cell, trigger: JumpTrigger): string {
if (!onBoard(to.c, to.r)) return '판 밖이에요.';
if (sameCell(me, to)) return '지금 있는 칸이에요.';
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
if (occ.has(cellIdx(to.c, to.r))) return '다른 말이 있는 칸이에요.';
const dc = to.c - me.c;
const dr = to.r - me.r;
const ad = Math.abs(dc) + Math.abs(dr);
if (ad === 1) return blocked(w, me.c, me.r, dc, dr) ? '벽이 가로막고 있어요.' : '그 칸으로는 갈 수 없어요.';
if (Math.abs(dc) === 1 && Math.abs(dr) === 1) {
// 대각: 옆에 말이 있고 곧게 넘을 수 있으면 대각은 안 됨.
for (const [sc, sr] of [
[dc, 0],
[0, dr],
] as const) {
if (!occ.has(cellIdx(me.c + sc, me.r + sr)) || blocked(w, me.c, me.r, sc, sr)) continue;
const jc = me.c + 2 * sc;
const jr = me.r + 2 * sr;
if (onBoard(jc, jr) && !blocked(w, me.c + sc, me.r + sr, sc, sr) && !occ.has(cellIdx(jc, jr))) {
return '곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.';
}
if (trigger === 'wallOnly' && !(onBoard(jc, jr) && blocked(w, me.c + sc, me.r + sr, sc, sr))) {
return '이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.';
}
const mc = me.c + sc;
const mr = me.r + sr;
if (blocked(w, mc, mr, to.c - mc, to.r - mr)) return '벽이 가로막고 있어요.';
}
return '대각선으로는 상대 말을 넘을 때만 갈 수 있어요.';
}
if (ad === 2 && (dc === 0 || dr === 0)) {
const sc = Math.sign(dc);
const sr = Math.sign(dr);
if (!occ.has(cellIdx(me.c + sc, me.r + sr))) return '한 번에 한 칸씩만 움직일 수 있어요.';
return '벽이 가로막고 있어요.';
}
if (ad === 3 && (dc === 0 || dr === 0)) return '말은 한 번에 하나만 뛰어넘을 수 있어요.';
return '그 칸으로는 갈 수 없어요.';
}

View File

@@ -0,0 +1,838 @@
/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
ANCHORS,
SEAT_KO,
SIZE,
START,
anchorInRange,
emptyWallIndex,
isGoal,
legalPawnMoves,
moveRefusal,
overlaps,
pathLength,
sameCell,
setWall,
shortestPath,
type Cell,
type Pawn,
type Seat,
type Wall,
type WallIndex,
} from './board';
export type { Cell, Seat, Wall, Orientation, JumpTrigger } from './board';
export { SEAT_KO, cellLabel, wallLabel } from './board';
const ORDER_2: Seat[] = ['S', 'N'];
/** 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동. */
const ORDER_4: Seat[] = ['S', 'W', 'N', 'E'];
const ALL_SEATS: Seat[] = ['S', 'W', 'N', 'E'];
export const WALLS_4P = 5;
const COUNT_ERROR = '2명 또는 4명이 필요해요.';
export const quoridorOptionsSchema = z.object({
wallsPerPlayer: z.number().int().min(6).max(10).default(10).meta({ title: '2명일 때 1인당 벽 수(4명은 5개 고정)' }),
diagonalJumpTrigger: z
.enum(['wallEdgePawn', 'wallOnly'])
.default('wallEdgePawn')
.meta({
title: '대각선으로 넘어갈 수 있는 때',
labels: { wallEdgePawn: '넘을 말 뒤가 벽·판 끝·다른 말일 때', wallOnly: '넘을 말 뒤가 벽일 때만' },
}),
seatOrder: z.enum(['random', 'joinOrder']).default('random').meta({ title: '시작 자리·순서', labels: { random: '무작위', joinOrder: '들어온 순서' } }),
continueForRanking: z.boolean().default(false).meta({ title: '4명일 때 1등이 나와도 계속해서 2·3등 가리기' }),
timeControl: z
.object({
kind: z
.enum(['none', 'perMove', 'fischer'])
.default('perMove')
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
})
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
.meta({ title: '시간 제한' }),
timeoutPolicy: z
.enum(['autoMove', 'lose'])
.default('autoMove')
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }),
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
undo: z
.enum(['off', '1', '3', 'unlimited'])
.default('3')
.meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
showPath: z.boolean().default(true).meta({ title: '내 최단 길 보여 주기(힌트)' }),
});
export type QuoridorOptions = z.infer<typeof quoridorOptionsSchema>;
const coord = z.number().int().min(-20).max(20);
export const quoridorActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('move'), to: z.object({ c: coord, r: coord }) }),
z.object({ type: z.literal('placeWall'), c: coord, r: coord, o: z.enum(['h', 'v']) }),
z.object({ type: z.literal('requestUndo') }),
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
z.object({ type: z.literal('offerDraw') }),
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
z.object({ type: z.literal('cancelOffer') }),
z.object({ type: z.literal('resign') }),
]);
/** 클라이언트 행동 + 시스템 전용 timeout(스키마에 없음). */
export type QuoridorAction = z.infer<typeof quoridorActionSchema> | { type: 'timeout' };
export type SeatStatus = 'playing' | 'finished' | 'resigned' | 'timedOut';
export interface SeatState {
seat: Seat;
playerId: PlayerId;
/** null = 판에서 제거(4인 기권·시간패, 순위 계속 모드의 도착). */
pos: Cell | null;
wallsLeft: number;
status: SeatStatus;
finishOrder: number | null;
/** 기권·시간패 순서(늦을수록 높은 순위). */
outOrder: number | null;
}
export type QuoridorMove =
| { kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
| { kind: 'wall'; seat: Seat; wall: Wall }
| { kind: 'pass'; seat: Seat };
export type QuoridorReason = 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed' | 'stalemate';
export interface QuoridorResult {
ranking: { playerId: PlayerId; rank: number }[];
reason: QuoridorReason;
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number | null; wallsLeft: number; status: SeatStatus }[];
moveCount: number;
summaryKo: string;
}
export interface QuoridorState {
options: QuoridorOptions;
seats: SeatState[];
turnIndex: number;
walls: Wall[];
wallIndex: WallIndex;
moves: QuoridorMove[];
phase: 'playing' | 'finished';
/** 네 자리 키가 항상 있음(2인이면 W/E는 쓰지 않음). */
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
/** `plies` = 수락 시 `moves` 끝에서 되돌릴 항목 수. */
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[]; plies: number } | null };
/** lastOfferMove: 마지막 제안 때의 moves.length(-1 = 없음). */
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
outCount: number;
result: QuoridorResult | null;
}
export interface QuoridorLegalWall extends Wall {
/** 이 벽을 놓으면 상대(들) 최단 거리 합이 몇 칸 늘어나는지. */
opp: number;
/** 내 최단 거리가 몇 칸 늘어나는지. */
me: number;
}
export interface QuoridorSeatView {
seat: Seat;
playerId: PlayerId;
pos: Cell | null;
wallsLeft: number;
status: SeatStatus;
finishOrder: number | null;
/** 말 무시, 벽만 본 최단 거리(판에 없으면 null). */
distance: number | null;
autoMoveStreak: number;
}
export interface QuoridorView {
size: number;
seats: QuoridorSeatView[];
mySeat: Seat | null;
turn: Seat;
phase: QuoridorState['phase'];
walls: Wall[];
moves: QuoridorMove[];
moveCount: number;
lastMove: QuoridorMove | null;
/** 차례인 플레이어에게만(그 외 null). */
legalPawnMoves: Cell[] | null;
legalWalls: QuoridorLegalWall[] | null;
myShortestPath: Cell[] | null;
wallsPerPlayer: number;
diagonalJumpTrigger: QuoridorOptions['diagonalJumpTrigger'];
continueForRanking: boolean;
showPath: boolean;
timeControl: QuoridorOptions['timeControl'];
timeoutPolicy: QuoridorOptions['timeoutPolicy'];
remainingMs: Record<Seat, number>;
turnStartedAt: number;
undo: { allowed: boolean; remaining: Record<Seat, number | null>; pending: { by: Seat; accepted: Seat[]; plies: number } | null; responders: Seat[] };
draw: { allowed: boolean; pendingBy: Seat | null; accepted: Seat[]; responders: Seat[] };
result: QuoridorResult | null;
}
// ---------------------------------------------------------------- helpers
const undoLimit = (o: QuoridorOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
const seatIndexOf = (s: QuoridorState, p: PlayerId): number => s.seats.findIndex((x) => x.playerId === p);
const bySeat = (s: QuoridorState, seat: Seat): SeatState => s.seats.find((x) => x.seat === seat)!;
const playing = (s: QuoridorState): SeatState[] => s.seats.filter((x) => x.status === 'playing');
const pawnsOf = (s: QuoridorState): Pawn[] => s.seats.filter((x) => x.pos).map((x) => ({ seat: x.seat, pos: x.pos! }));
const recordOf = <T>(v: T): Record<Seat, T> => ({ S: v, W: v, N: v, E: v });
const four = (s: QuoridorState) => s.seats.length === 4;
export const wallsFor = (o: QuoridorOptions, players: number): number => (players === 4 ? WALLS_4P : o.wallsPerPlayer);
export function playerCountError(n: number): string | null {
return n === 2 || n === 4 ? null : COUNT_ERROR;
}
export function pawnMovesFor(s: QuoridorState, i: number): Cell[] {
const x = s.seats[i]!;
if (!x.pos || x.status !== 'playing') return [];
return legalPawnMoves(s.wallIndex, pawnsOf(s), x.pos, s.options.diagonalJumpTrigger);
}
/** 앵커 범위·남은 벽·겹침(§5.3 조건 1~3)만 본 이유. 통과하면 null. */
function wallShapeRefusal(s: QuoridorState, i: number, c: number, r: number, o: 'h' | 'v'): string | null {
if (s.seats[i]!.wallsLeft <= 0) return '남은 벽이 없어요.';
if (!anchorInRange(c, r)) return '벽은 판 밖으로 나갈 수 없어요.';
if (overlaps(s.wallIndex, c, r, o)) return '다른 벽과 겹쳐요.';
return null;
}
/** 후보 벽을 잠시 세우고 남아 있는 모든 플레이어의 최단 거리를 잰다(-1 = 막힘). */
function distancesWith(s: QuoridorState, w: Wall | null): number[] {
if (w) setWall(s.wallIndex, w, true);
const out = s.seats.map((x) => (x.status === 'playing' && x.pos ? pathLength(s.wallIndex, x.pos, x.seat) : 0));
if (w) setWall(s.wallIndex, w, false);
return out;
}
/** §5.3 조건 4. `s.wallIndex`를 잠시 바꿨다가 되돌린다 — 반드시 사본 상태로 부른다. */
function blocksSomeone(s: QuoridorState, w: Wall): boolean {
setWall(s.wallIndex, w, true);
let bad = false;
for (const x of s.seats) {
if (x.status === 'playing' && x.pos && pathLength(s.wallIndex, x.pos, x.seat) < 0) {
bad = true;
break;
}
}
setWall(s.wallIndex, w, false);
return bad;
}
export function wallRefusal(s: QuoridorState, i: number, w: Wall): string | null {
const shape = wallShapeRefusal(s, i, w.c, w.r, w.o);
if (shape) return shape;
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
return blocksSomeone(t, { c: w.c, r: w.r, o: w.o }) ? '누군가의 길을 완전히 막을 수 없어요.' : null;
}
/** 합법 벽 목록(최대 128 후보 × 남은 사람 수만큼 BFS). `withDelta`면 거리 변화량도. */
export function legalWallsFor(s: QuoridorState, i: number, withDelta = false): QuoridorLegalWall[] {
if (s.seats[i]!.wallsLeft <= 0 || s.seats[i]!.status !== 'playing') return [];
// wallIndex를 잠시 바꾸므로 사본에서 계산(state는 불변 취급).
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
const base = withDelta ? distancesWith(t, null) : [];
const out: QuoridorLegalWall[] = [];
for (let r = 0; r < ANCHORS; r++) {
for (let c = 0; c < ANCHORS; c++) {
for (const o of ['h', 'v'] as const) {
if (overlaps(t.wallIndex, c, r, o)) continue;
const w = { c, r, o };
if (!withDelta) {
if (!blocksSomeone(t, w)) out.push({ ...w, opp: 0, me: 0 });
continue;
}
const d = distancesWith(t, w);
if (d.some((v) => v < 0)) continue;
let opp = 0;
t.seats.forEach((x, k) => {
if (k !== i && x.status === 'playing') opp += d[k]! - base[k]!;
});
out.push({ ...w, opp, me: d[i]! - base[i]! });
}
}
}
return out;
}
function hasLegalWall(s: QuoridorState, i: number): boolean {
if (s.seats[i]!.wallsLeft <= 0) return false;
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
for (let r = 0; r < ANCHORS; r++) {
for (let c = 0; c < ANCHORS; c++) {
for (const o of ['h', 'v'] as const) if (!overlaps(t.wallIndex, c, r, o) && !blocksSomeone(t, { c, r, o })) return true;
}
}
return false;
}
const canAct = (s: QuoridorState, i: number): boolean => pawnMovesFor(s, i).length > 0 || hasLegalWall(s, i);
function distanceOf(s: QuoridorState, x: SeatState): number | null {
if (x.status === 'finished') return 0;
if (!x.pos) return null;
return pathLength(s.wallIndex, x.pos, x.seat);
}
/** 요청자 외 아직 남아 있는 플레이어. */
function respondersOf(s: QuoridorState, by: Seat): Seat[] {
return playing(s)
.map((x) => x.seat)
.filter((x) => x !== by);
}
function clearPending(s: QuoridorState): void {
s.undo.pending = null;
s.draw.pendingBy = null;
s.draw.accepted = [];
}
// ---------------------------------------------------------------- finishing
function rankAndFinish(s: QuoridorState, reason: QuoridorReason, events: GameEvent[]): void {
s.phase = 'finished';
clearPending(s);
const ranking: QuoridorResult['ranking'] = [];
let next = 1;
const pushGroup = (group: SeatState[]) => {
for (const x of group) ranking.push({ playerId: x.playerId, rank: next });
next += group.length;
};
const finished = s.seats.filter((x) => x.status === 'finished').sort((a, b) => a.finishOrder! - b.finishOrder!);
for (const x of finished) pushGroup([x]);
const alive = playing(s);
if (reason === 'drawAgreed') pushGroup(alive);
else {
const byDist = new Map<number, SeatState[]>();
for (const x of alive) {
const d = distanceOf(s, x) ?? 999;
byDist.set(d, [...(byDist.get(d) ?? []), x]);
}
for (const d of [...byDist.keys()].sort((a, b) => a - b)) pushGroup(byDist.get(d)!);
}
const out = s.seats.filter((x) => x.status === 'resigned' || x.status === 'timedOut').sort((a, b) => b.outOrder! - a.outOrder!);
for (const x of out) pushGroup([x]);
const moveCount = s.moves.filter((m) => m.kind !== 'pass').length;
const first = ranking.filter((r) => r.rank === 1).map((r) => s.seats.find((x) => x.playerId === r.playerId)!.seat);
const firstKo = first.map((x) => SEAT_KO[x]).join('·');
const lastOut = out[0];
let summaryKo: string;
switch (reason) {
case 'reachedGoal':
summaryKo = `${firstKo} 승리 — ${moveCount}수 만에 도착`;
break;
case 'lastPlayerStanding':
summaryKo = `${finished.map((x) => SEAT_KO[x.seat]).join(' → ')} 순서로 도착`;
break;
case 'resign':
summaryKo = four(s) && out.length > 1 ? `다른 사람이 모두 기권해 ${firstKo} 승리` : `${SEAT_KO[lastOut!.seat]} 기권으로 ${firstKo} 승리`;
break;
case 'timeLoss':
summaryKo = `${SEAT_KO[lastOut!.seat]} 시간패로 ${firstKo} 승리`;
break;
case 'drawAgreed':
summaryKo = '합의 무승부';
break;
case 'stalemate':
summaryKo = '아무도 움직일 수 없어 남은 거리로 순위를 정했어요';
break;
}
s.result = {
ranking,
reason,
perPlayer: s.seats.map((x) => ({ playerId: x.playerId, seat: x.seat, distanceLeft: distanceOf(s, x), wallsLeft: x.wallsLeft, status: x.status })),
moveCount,
summaryKo,
};
events.push({ type: 'gameEnded', reason, ranking });
}
/**
* 차례 넘기기(§8.2): `from` 다음의 남은 플레이어. 이동·벽 모두 불가하면 자동 패스.
* 모두가 아무것도 못 하면(극히 드묾) 남은 거리로 순위를 정해 끝낸다.
*/
function advance(s: QuoridorState, from: number, now: number, events: GameEvent[]): void {
const n = s.seats.length;
let i = from;
for (let k = 0; k < n; k++) {
i = nextSeat(i, n, (j) => s.seats[j]!.status !== 'playing');
if (canAct(s, i)) {
s.turnIndex = i;
s.clock.turnStartedAt = now;
return;
}
const seat = s.seats[i]!.seat;
s.moves.push({ kind: 'pass', seat });
events.push({ type: 'forcedPass', seat });
}
s.turnIndex = i;
rankAndFinish(s, 'stalemate', events);
}
/** 기권·시간패 처리(§6, §8.5). 2인은 상대 승, 4인은 말 제거·남은 벽 소멸. */
function removeSeat(s: QuoridorState, i: number, status: 'resigned' | 'timedOut', now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
clearPending(s);
x.status = status;
x.outOrder = ++s.outCount;
events.push({ type: status === 'resigned' ? 'playerResigned' : 'timeLoss', seat: x.seat });
if (four(s)) {
x.pos = null;
x.wallsLeft = 0;
}
if (playing(s).length <= 1) {
rankAndFinish(s, status === 'resigned' ? 'resign' : 'timeLoss', events);
return;
}
if (s.turnIndex === i) advance(s, i, now, events);
}
/** 이미 검증된 말 이동. 도착 판정 → 종료 또는 계속 → 차례 넘기기. */
function movePawn(s: QuoridorState, i: number, to: Cell, auto: boolean, now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
const from = x.pos!;
x.pos = { c: to.c, r: to.r };
s.moves.push({ kind: 'move', seat: x.seat, from, to: x.pos, auto });
clearPending(s);
events.push({ type: 'pawnMoved', seat: x.seat, from, to: x.pos, auto });
if (isGoal(x.seat, to.c, to.r)) {
x.status = 'finished';
x.finishOrder = s.seats.filter((y) => y.finishOrder !== null).length + 1;
events.push({ type: 'playerFinished', seat: x.seat, order: x.finishOrder });
if (!(four(s) && s.options.continueForRanking)) {
rankAndFinish(s, 'reachedGoal', events);
return;
}
x.pos = null;
if (playing(s).length <= 1) {
// 마지막 1명은 자동으로 다음 순위.
rankAndFinish(s, 'lastPlayerStanding', events);
return;
}
}
advance(s, i, now, events);
}
function placeWall(s: QuoridorState, i: number, w: Wall, now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
const wall = { c: w.c, r: w.r, o: w.o };
s.walls.push(wall);
setWall(s.wallIndex, wall, true);
x.wallsLeft--;
s.moves.push({ kind: 'wall', seat: x.seat, wall });
clearPending(s);
events.push({ type: 'wallPlaced', seat: x.seat, wall });
advance(s, i, now, events);
}
/** §8.5 autoMove: 목표까지 거리가 가장 짧아지는 칸(동률은 RNG). */
function autoTarget(s: QuoridorState, i: number, rng: Rng): Cell | null {
const x = s.seats[i]!;
const cands = pawnMovesFor(s, i);
if (cands.length === 0) return null;
let best = Infinity;
let pool: Cell[] = [];
for (const c of cands) {
const d = pathLength(s.wallIndex, c, x.seat);
const dd = d < 0 ? 999 : d;
if (dd < best) {
best = dd;
pool = [c];
} else if (dd === best) pool.push(c);
}
return pool[rng.int(pool.length)]!;
}
/** 직전 행동부터 되돌릴 기록 시작 인덱스. 없으면 -1. */
function undoStart(s: QuoridorState, seat: Seat): number {
let lastAction = -1;
for (let k = s.moves.length - 1; k >= 0; k--) {
if (s.moves[k]!.kind !== 'pass') {
lastAction = k;
break;
}
}
if (lastAction < 0) return -1;
if (four(s)) return s.moves[lastAction]!.seat === seat ? lastAction : -1;
for (let k = s.moves.length - 1; k >= 0; k--) {
const m = s.moves[k]!;
if (m.kind !== 'pass' && m.seat === seat) return k;
}
return -1;
}
/** 기록 끝에서 `plies`개를 거꾸로 되돌린다(벽 개수 포함). */
function rewind(s: QuoridorState, plies: number): void {
for (let k = 0; k < plies; k++) {
const m = s.moves.pop()!;
if (m.kind === 'move') {
const x = bySeat(s, m.seat);
if (!x.pos || !sameCell(x.pos, m.to)) throw new Error('undo: move record does not match the board');
x.pos = { ...m.from };
} else if (m.kind === 'wall') {
const last = s.walls.pop();
if (!last || last.c !== m.wall.c || last.r !== m.wall.r || last.o !== m.wall.o) throw new Error('undo: wall record mismatch');
setWall(s.wallIndex, last, false);
bySeat(s, m.seat).wallsLeft++;
}
}
}
function chargeClock(s: QuoridorState, i: number, now: number): boolean {
if (s.options.timeControl.kind !== 'fischer') return true;
const seat = s.seats[i]!.seat;
s.clock.remainingMs[seat] -= now - s.clock.turnStartedAt;
if (s.clock.remainingMs[seat] <= 0) {
s.clock.remainingMs[seat] = 0;
return false;
}
s.clock.remainingMs[seat] += s.options.timeControl.incSec * 1000;
return true;
}
// ---------------------------------------------------------------- definition
export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
id: 'quoridor',
nameKo: '벽 쌓기 길찾기',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: quoridorOptionsSchema.parse({}),
optionsSchema: quoridorOptionsSchema as unknown as z.ZodType<QuoridorOptions>,
actionSchema: quoridorActionSchema as z.ZodType<QuoridorAction>,
playerCountError: (_o, n) => playerCountError(n),
setup({ players, options, rng, now }) {
const err = playerCountError(players.length);
if (err) throw new Error(`quoridor: ${err}`);
const order = players.length === 4 ? ORDER_4 : ORDER_2;
const assigned = options.seatOrder === 'random' ? rng.shuffle(players) : [...players];
const walls = wallsFor(options, players.length);
const base = options.timeControl.baseSec * 1000;
return {
options,
seats: order.map((seat, k) => ({ seat, playerId: assigned[k]!, pos: { ...START[seat] }, wallsLeft: walls, status: 'playing' as const, finishOrder: null, outOrder: null })),
turnIndex: 0,
walls: [],
wallIndex: emptyWallIndex(),
moves: [],
phase: 'playing',
clock: { remainingMs: recordOf(base), turnStartedAt: now, autoMoveStreak: recordOf(0) },
undo: { usedBy: recordOf(0), pending: null },
draw: { pendingBy: null, accepted: [], lastOfferMove: recordOf(-1) },
outCount: 0,
result: null,
};
},
validate(s, actor, a) {
const i = seatIndexOf(s, actor);
if (i < 0) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
const x = s.seats[i]!;
if (x.status !== 'playing') return fail(x.status === 'finished' ? '이미 목표에 도착했어요.' : '이미 게임에서 빠졌어요.');
const myTurn = s.turnIndex === i;
switch (a.type) {
case 'move': {
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
const to = a.to;
if (pawnMovesFor(s, i).some((m) => sameCell(m, to))) return ok;
return fail(moveRefusal(s.wallIndex, pawnsOf(s), x.pos!, to, s.options.diagonalJumpTrigger));
}
case 'placeWall': {
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
const why = wallRefusal(s, i, { c: a.c, r: a.r, o: a.o });
return why ? fail(why) : ok;
}
case 'timeout':
return myTurn ? ok : fail('지금은 내 차례가 아니에요.');
case 'requestUndo': {
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
if (s.undo.usedBy[x.seat] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
if (undoStart(s, x.seat) < 0) return fail(four(s) ? '방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.' : '아직 무를 수가 없어요.');
return ok;
}
case 'respondUndo': {
const p = s.undo.pending;
if (!p || p.by === x.seat || p.accepted.includes(x.seat)) return fail('응답할 무르기 요청이 없어요.');
return ok;
}
case 'offerDraw': {
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
const last = s.draw.lastOfferMove[x.seat];
if (last >= 0 && !s.moves.slice(last).some((m) => m.kind !== 'pass' && m.seat === x.seat)) {
return fail('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
}
return ok;
}
case 'respondDraw': {
const by = s.draw.pendingBy;
if (!by || by === x.seat || s.draw.accepted.includes(x.seat)) return fail('응답할 무승부 제안이 없어요.');
return ok;
}
case 'cancelOffer':
if (s.undo.pending?.by !== x.seat && s.draw.pendingBy !== x.seat) return fail('취소할 요청이 없어요.');
return ok;
case 'resign':
return ok;
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const i = seatIndexOf(s, actor);
const x = s.seats[i]!;
const seat = x.seat;
const events: GameEvent[] = [];
switch (a.type) {
case 'move':
case 'placeWall': {
if (!chargeClock(s, i, now)) {
removeSeat(s, i, 'timedOut', now, events);
break;
}
s.clock.autoMoveStreak[seat] = 0;
if (a.type === 'move') {
if (!pawnMovesFor(s, i).some((m) => sameCell(m, a.to))) throw new Error('illegal pawn move');
movePawn(s, i, a.to, false, now, events);
} else {
if (wallRefusal(s, i, a)) throw new Error('illegal wall');
placeWall(s, i, a, now, events);
}
break;
}
case 'timeout': {
const tc = s.options.timeControl.kind;
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[seat] < s.options.autoMoveLimit) {
s.clock.autoMoveStreak[seat]++;
events.push({ type: 'timeoutAutoMove', seat });
const to = autoTarget(s, i, rng);
if (to) movePawn(s, i, to, true, now, events);
else {
s.moves.push({ kind: 'pass', seat });
clearPending(s);
events.push({ type: 'forcedPass', seat });
advance(s, i, now, events);
}
} else {
if (tc === 'fischer') s.clock.remainingMs[seat] = 0;
removeSeat(s, i, 'timedOut', now, events);
}
break;
}
case 'requestUndo': {
const start = undoStart(s, seat);
s.undo.pending = { by: seat, accepted: [], plies: s.moves.length - start };
events.push({ type: 'undoRequested', seat });
break;
}
case 'respondUndo': {
const p = s.undo.pending!;
if (!a.accept) {
s.undo.pending = null;
events.push({ type: 'undoDeclined', seat: p.by, by: seat });
break;
}
p.accepted.push(seat);
if (!respondersOf(s, p.by).every((y) => p.accepted.includes(y))) {
events.push({ type: 'undoAcceptedBy', seat: p.by, by: seat });
break;
}
s.undo.pending = null;
// 생각하던 사람이 쓴 시간은 차감하고, 시계는 그대로 둔다.
if (s.options.timeControl.kind === 'fischer') {
const thinker = s.seats[s.turnIndex]!.seat;
s.clock.remainingMs[thinker] = Math.max(0, s.clock.remainingMs[thinker] - (now - s.clock.turnStartedAt));
}
rewind(s, p.plies);
s.undo.usedBy[p.by]++;
s.turnIndex = s.seats.findIndex((y) => y.seat === p.by);
s.clock.turnStartedAt = now;
for (const y of ALL_SEATS) s.draw.lastOfferMove[y] = Math.min(s.draw.lastOfferMove[y], s.moves.length);
events.push({ type: 'undoAccepted', seat: p.by, plies: p.plies });
break;
}
case 'offerDraw':
s.draw.pendingBy = seat;
s.draw.accepted = [];
s.draw.lastOfferMove[seat] = s.moves.length;
events.push({ type: 'drawOffered', seat });
break;
case 'respondDraw': {
if (!a.accept) {
s.draw.pendingBy = null;
s.draw.accepted = [];
events.push({ type: 'drawDeclined', by: seat });
break;
}
s.draw.accepted.push(seat);
if (respondersOf(s, s.draw.pendingBy!).every((y) => s.draw.accepted.includes(y))) {
events.push({ type: 'drawAccepted' });
rankAndFinish(s, 'drawAgreed', events);
} else events.push({ type: 'drawAcceptedBy', by: seat });
break;
}
case 'cancelOffer':
if (s.undo.pending?.by === seat) s.undo.pending = null;
if (s.draw.pendingBy === seat) {
s.draw.pendingBy = null;
s.draw.accepted = [];
}
events.push({ type: 'offerCancelled', seat });
break;
case 'resign':
removeSeat(s, i, 'resigned', now, events);
break;
}
return { state: s, events };
},
view(s, viewer) {
const vi = viewer === null ? -1 : seatIndexOf(s, viewer);
const mySeat = vi >= 0 ? s.seats[vi]!.seat : null;
const mine = s.phase === 'playing' && vi >= 0 && vi === s.turnIndex && s.seats[vi]!.status === 'playing';
const limit = undoLimit(s.options);
const me = vi >= 0 ? s.seats[vi]! : null;
let lastMove: QuoridorMove | null = null;
for (let k = s.moves.length - 1; k >= 0; k--) {
if (s.moves[k]!.kind !== 'pass') {
lastMove = s.moves[k]!;
break;
}
}
return {
size: SIZE,
seats: s.seats.map((x) => ({
seat: x.seat,
playerId: x.playerId,
pos: x.pos,
wallsLeft: x.wallsLeft,
status: x.status,
finishOrder: x.finishOrder,
distance: distanceOf(s, x),
autoMoveStreak: s.clock.autoMoveStreak[x.seat],
})),
mySeat,
turn: s.seats[s.turnIndex]!.seat,
phase: s.phase,
walls: s.walls,
moves: s.moves,
moveCount: s.moves.length,
lastMove,
legalPawnMoves: mine ? pawnMovesFor(s, vi) : null,
legalWalls: mine ? legalWallsFor(s, vi, true) : null,
myShortestPath: mine && s.options.showPath && me?.pos ? shortestPath(s.wallIndex, me.pos, me.seat) : null,
wallsPerPlayer: wallsFor(s.options, s.seats.length),
diagonalJumpTrigger: s.options.diagonalJumpTrigger,
continueForRanking: four(s) && s.options.continueForRanking,
showPath: s.options.showPath,
timeControl: s.options.timeControl,
timeoutPolicy: s.options.timeoutPolicy,
remainingMs: s.clock.remainingMs,
turnStartedAt: s.clock.turnStartedAt,
undo: {
allowed: s.options.undo !== 'off',
remaining: Object.fromEntries(ALL_SEATS.map((y) => [y, Number.isFinite(limit) ? limit - s.undo.usedBy[y] : null])) as Record<Seat, number | null>,
pending: s.undo.pending,
responders: s.undo.pending ? respondersOf(s, s.undo.pending.by) : [],
},
draw: {
allowed: s.options.drawOffer,
pendingBy: s.draw.pendingBy,
accepted: s.draw.accepted,
responders: s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy) : [],
},
result: s.result,
};
},
activePlayers(s) {
if (s.phase !== 'playing') return [];
const out = [s.seats[s.turnIndex]!.playerId];
const p = s.undo.pending;
const waiting = p ? respondersOf(s, p.by).filter((y) => !p.accepted.includes(y)) : s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy).filter((y) => !s.draw.accepted.includes(y)) : [];
for (const y of waiting) {
const id = bySeat(s, y).playerId;
if (!out.includes(id)) out.push(id);
}
return out;
},
deadline(s) {
if (s.phase !== 'playing') return null;
const tc = s.options.timeControl;
const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
return start + s.options.idleLimitSec * 1000;
},
timeoutPlayers(s) {
return s.phase === 'playing' ? [s.seats[s.turnIndex]!.playerId] : [];
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
const i = seatIndexOf(s, player);
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return null;
return { type: 'resign' };
},
result(s): GameResult | null {
const r = s.result;
if (!r) return null;
const groups = new Map<number, PlayerId[]>();
for (const x of r.ranking) groups.set(x.rank, [...(groups.get(x.rank) ?? []), x.playerId]);
const reason: Record<QuoridorReason, GameResult['reason']> = {
reachedGoal: 'normal',
lastPlayerStanding: 'normal',
stalemate: 'normal',
resign: 'resign',
timeLoss: 'timeout',
drawAgreed: 'draw-agreed',
};
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
summary: r.summaryKo,
reason: reason[r.reason],
};
},
/** 테스트용: 차례인 사람의 모든 말 이동·벽, 대기 중인 요청의 응답. */
legalActions(s, p) {
const i = seatIndexOf(s, p);
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return [];
const seat = s.seats[i]!.seat;
const out: QuoridorAction[] = [];
const u = s.undo.pending;
if (u && u.by !== seat && !u.accepted.includes(seat)) out.push({ type: 'respondUndo', accept: true }, { type: 'respondUndo', accept: false });
const d = s.draw.pendingBy;
if (d && d !== seat && !s.draw.accepted.includes(seat)) out.push({ type: 'respondDraw', accept: false });
if (s.turnIndex !== i) return out;
for (const to of pawnMovesFor(s, i)) out.push({ type: 'move', to });
for (const w of legalWallsFor(s, i)) out.push({ type: 'placeWall', c: w.c, r: w.r, o: w.o });
return out;
},
/** 완전 정보 게임: 숨길 패가 없다(RNG는 상태 밖, view의 힌트는 차례인 사람에게만 — 테스트에서 따로 확인). */
secretsOf() {
return [];
},
};

View File

@@ -0,0 +1,696 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import {
legalWallsFor,
pawnMovesFor,
quoridor,
type Cell,
type QuoridorAction,
type QuoridorOptions,
type QuoridorState,
type Seat,
} from './index';
import { cellLabel, emptyWallIndex, isGoal, pathLength, setWall, type Wall } from './board';
const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]);
/** 'e5' → { c: 4, r: 4 } (열 a~i 왼→오, 행 1~9 아래→위). */
const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 });
/** 'h:d4' → 앵커 d4 (c=3, r=3)의 가로벽. */
const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) });
/** joinOrder: 2인 A=남 B=북, 4인 A=남 B=서 C=북 D=동. */
function make(n: 2 | 4 = 2, opts: Partial<QuoridorOptions> = {}): QuoridorState {
const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder', ...opts });
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
return quoridor.setup({ players, options, rng: rng(), now: 0 });
}
/** 말 위치·벽·차례를 직접 정한 국면. */
function position(s: QuoridorState, pos: Partial<Record<Seat, string | null>>, walls: string[] = [], turn?: Seat): QuoridorState {
for (const x of s.seats) if (x.seat in pos) x.pos = pos[x.seat] ? at(pos[x.seat]!) : null;
s.walls = walls.map(wall);
s.wallIndex = emptyWallIndex();
for (const w of s.walls) setWall(s.wallIndex, w, true);
if (turn) s.turnIndex = s.seats.findIndex((x) => x.seat === turn);
return s;
}
function act(s: QuoridorState, actor: string, a: QuoridorAction, now = 1000, seed = 9): QuoridorState {
const v = quoridor.validate(s, actor, a);
if (!v.ok) throw new Error(v.reason);
return quoridor.apply(s, actor, a, { rng: rng(seed), now }).state;
}
const turnId = (s: QuoridorState) => s.seats[s.turnIndex]!.playerId;
const go = (s: QuoridorState, to: string, now = 1000) => act(s, turnId(s), { type: 'move', to: at(to) }, now);
const put = (s: QuoridorState, w: string, now = 1000) => act(s, turnId(s), { type: 'placeWall', ...wall(w) }, now);
const tryGo = (s: QuoridorState, to: string) => quoridor.validate(s, turnId(s), { type: 'move', to: at(to) });
const tryPut = (s: QuoridorState, w: string, actor = turnId(s)) => quoridor.validate(s, actor, { type: 'placeWall', ...wall(w) });
const moves = (s: QuoridorState) => pawnMovesFor(s, s.turnIndex).map(cellLabel).sort();
const seatOf = (s: QuoridorState, seat: Seat) => s.seats.find((x) => x.seat === seat)!;
const reason = (v: { ok: boolean; reason?: string }) => (v.ok ? 'ok' : v.reason);
describe('setup', () => {
test('2인: 남 e1 → 9행, 북 e9 → 1행, 남 먼저, 벽 10개씩', () => {
const s = make(2);
expect(s.seats.map((x) => [x.seat, x.playerId, cellLabel(x.pos!), x.wallsLeft])).toEqual([
['S', 'A', 'e1', 10],
['N', 'B', 'e9', 10],
]);
expect(turnId(s)).toBe('A');
expect(isGoal('S', 0, 8) && isGoal('N', 8, 0) && !isGoal('S', 4, 7)).toBe(true);
expect(quoridor.view(s, null).seats.map((x) => x.distance)).toEqual([8, 8]);
});
test('4인: 남 e1, 서 a5, 북 e9, 동 i5, 시계 방향 차례, 벽 5개씩; 하우스 룰 2인 벽 수', () => {
let s = make(4);
expect(s.seats.map((x) => `${x.seat}${cellLabel(x.pos!)}${x.wallsLeft}`)).toEqual(['Se15', 'Wa55', 'Ne95', 'Ei55']);
expect(isGoal('W', 8, 3) && isGoal('E', 0, 3)).toBe(true);
s = go(s, 'e2');
expect(turnId(s)).toBe('B');
s = go(s, 'b5');
expect(turnId(s)).toBe('C');
s = go(s, 'e8');
expect(turnId(s)).toBe('D');
s = go(s, 'h5');
expect(turnId(s)).toBe('A');
expect(make(2, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(6);
expect(make(4, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(5);
});
test('3인은 시작할 수 없어요', () => {
expect(quoridor.playerCountError!(quoridor.defaultOptions, 3)).toBe('2명 또는 4명이 필요해요.');
expect(quoridor.playerCountError!(quoridor.defaultOptions, 2)).toBeNull();
expect(quoridor.playerCountError!(quoridor.defaultOptions, 4)).toBeNull();
expect(() => quoridor.setup({ players: ['A', 'B', 'C'], options: quoridor.defaultOptions, rng: rng(), now: 0 })).toThrow('2명 또는 4명');
});
test('random 자리 배정은 RNG로, 같은 시드면 같은 결과', () => {
const seats = (n: number) => quoridor.setup({ players: ['A', 'B', 'C', 'D'], options: quoridor.defaultOptions, rng: rng(n), now: 0 }).seats.map((x) => x.playerId).join('');
expect(seats(3)).toBe(seats(3));
expect(new Set([1, 2, 3, 4, 5, 6, 7, 8].map(seats)).size).toBeGreaterThan(1);
});
});
describe('pawn moves (§5.2)', () => {
test('기본 이동 4방향; 벽·판 끝·다른 말 때문에 막힘', () => {
let s = position(make(), { S: 'e5', N: 'e9' });
expect(moves(s)).toEqual(['d5', 'e4', 'e6', 'f5']);
s = position(make(), { S: 'e5', N: 'e9' }, ['h:e5']); // e5↔e6, f5↔f6 차단
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
expect(reason(tryGo(s, 'e6'))).toBe('벽이 가로막고 있어요.');
s = position(make(), { S: 'a1', N: 'e9' });
expect(moves(s)).toEqual(['a2', 'b1']);
s = position(make(), { S: 'e5', N: 'e9' }, ['v:d4']); // d4↔e4 … 세로벽 d4: (d4|e4), (d5|e5)
expect(moves(s)).toEqual(['e4', 'e6', 'f5']);
expect(reason(tryGo(s, 'd5'))).toBe('벽이 가로막고 있어요.');
expect(reason(tryGo(s, 'e7'))).toBe('한 번에 한 칸씩만 움직일 수 있어요.');
expect(reason(tryGo(s, 'f6'))).toBe('대각선으로는 상대 말을 넘을 때만 갈 수 있어요.');
expect(reason(tryGo(s, 'e9'))).toBe('다른 말이 있는 칸이에요.');
});
test('직선 점프 성공', () => {
const s = position(make(), { S: 'e5', N: 'e6' });
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
expect(seatOf(go(s, 'e7'), 'S').pos).toEqual(at('e7'));
});
test('점프할 칸 뒤가 벽 / 판 끝 / 다른 말이면 대각 2곳, 한쪽이 벽이면 1곳', () => {
// 뒤가 벽
let s = position(make(), { S: 'e5', N: 'e6' }, ['h:d6']); // d6↔d7, e6↔e7 차단
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
// 뒤가 판 끝
s = position(make(), { S: 'e8', N: 'e9' });
expect(moves(s)).toEqual(['d8', 'd9', 'e7', 'f8', 'f9']);
// 뒤가 다른 말(4인)
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
// 대각 한쪽이 벽: 북 말과 d6 사이 세로벽(d6|e6)
expect(reason(tryPut(position(make(), { S: 'e5', N: 'e6' }, ['h:d6']), 'v:d6'))).toBe('다른 벽과 겹쳐요.');
s = position(make(), { S: 'e5', N: 'e6' }, ['h:e6', 'v:d6']); // e6↔e7, f6↔f7 / (d6|e6),(d7|e7)
expect(moves(s)).toEqual(['d5', 'e4', 'f5', 'f6']);
expect(reason(tryGo(s, 'd6'))).toBe('벽이 가로막고 있어요.');
});
test('wallOnly: 판 끝·다른 말 뒤로는 대각 불가, 벽 뒤로는 가능', () => {
let s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e8', N: 'e9' });
expect(moves(s)).toEqual(['d8', 'e7', 'f8']);
expect(reason(tryGo(s, 'd9'))).toBe('이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.');
s = position(make(4, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6' }, ['h:d6']);
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
});
test('직선 점프가 되면 대각은 거부, 말 2개 연속 점프 거부', () => {
let s = position(make(), { S: 'e5', N: 'e6' });
expect(reason(tryGo(s, 'd6'))).toBe('곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.');
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(reason(tryGo(s, 'e8'))).toBe('말은 한 번에 하나만 뛰어넘을 수 있어요.');
});
test('판에서 빠진 말은 장애물이 아니에요', () => {
const s = position(make(4), { S: 'e5', N: 'e6', W: null, E: 'i1' }, [], 'S');
seatOf(s, 'W').status = 'resigned';
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
});
});
describe('walls (§5.3)', () => {
test('벽 설치: 개수 감소, 기록, 차례 넘김', () => {
const s = put(make(), 'h:e5');
expect(s.walls).toEqual([wall('h:e5')]);
expect(seatOf(s, 'S').wallsLeft).toBe(9);
expect(s.moves.at(-1)).toEqual({ kind: 'wall', seat: 'S', wall: wall('h:e5') });
expect(turnId(s)).toBe('B');
});
test('겹침: 같은 방향 반 칸 겹침·같은 앵커 십자 거부, 다른 앵커의 T자·끝 맞닿음은 허용', () => {
const s = position(make(), { S: 'e1', N: 'e9' }, ['h:d4']);
for (const w of ['h:c4', 'h:d4', 'h:e4', 'v:d4']) expect(reason(tryPut(s, w))).toBe('다른 벽과 겹쳐요.');
expect(reason(tryPut(s, 'h:b4'))).toBe('ok'); // 끝이 맞닿는 이웃
expect(reason(tryPut(s, 'v:d5'))).toBe('ok'); // T자 (d4 앵커 가로벽의 가운데에 세로벽 끝)
expect(reason(tryPut(s, 'v:e4'))).toBe('ok'); // 오른쪽 끝에 세로벽 가운데(ㅓ 모양)
const v = position(make(), { S: 'e1', N: 'e9' }, ['v:d4']);
for (const w of ['v:d3', 'v:d4', 'v:d5', 'h:d4']) expect(reason(tryPut(v, w))).toBe('다른 벽과 겹쳐요.');
expect(reason(tryPut(v, 'v:d6'))).toBe('ok');
expect(reason(tryPut(v, 'h:c5'))).toBe('ok');
});
test('판 밖 앵커(0~7 밖) 거부, 남은 벽 0개면 거부', () => {
const s = make();
for (const [c, r] of [
[-1, 0],
[8, 0],
[0, 8],
[0, -1],
]) {
expect(reason(quoridor.validate(s, 'A', { type: 'placeWall', c: c!, r: r!, o: 'h' }))).toBe('벽은 판 밖으로 나갈 수 없어요.');
}
expect(quoridor.validate(s, 'A', { type: 'placeWall', c: 7, r: 7, o: 'v' }).ok).toBe(true);
seatOf(s, 'S').wallsLeft = 0;
expect(reason(tryPut(s, 'h:a1'))).toBe('남은 벽이 없어요.');
expect(legalWallsFor(s, 0)).toEqual([]);
});
// 북(e9) 주변 상자: e8·e9를 좌우 세로벽으로 감싸고 e7↔e8 가로벽으로 닫으면 북은 1행에 못 간다.
const box = ['v:d8', 'v:e8'];
test('상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부', () => {
const s = position(make(), { S: 'a1', N: 'e9' }, box, 'S');
expect(reason(tryPut(s, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
const n = position(make(), { S: 'a1', N: 'e9' }, box, 'N');
expect(reason(tryPut(n, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
expect(reason(tryPut(n, 'h:a7'))).toBe('ok');
});
test('4인에서 제3자(서)의 길을 막는 벽 거부', () => {
const s = position(make(4), {}, ['h:a5', 'h:a4'], 'S'); // 서 a5·b5를 위아래로 감쌈
expect(reason(tryPut(s, 'v:b5'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
expect(legalWallsFor(s, 0).some((w) => w.o === 'v' && w.c === 1 && w.r === 4)).toBe(false);
});
test('말 때문에만 막히는 경우는 허용(경로 검사는 벽만 봄)', () => {
// 북의 유일한 출구 e7을 남의 말이 막고 있어도 벽 설치는 합법.
const s = position(make(), { S: 'e7', N: 'e9' }, box, 'S');
expect(reason(tryPut(s, 'h:a1'))).toBe('ok');
expect(pathLength(s.wallIndex, at('e9'), 'N')).toBe(8);
});
test('경로 검사에서 기권한 사람은 빠져요', () => {
let s = position(make(4), {}, ['h:a5', 'h:a4'], 'S');
s = act(s, 'B', { type: 'resign' });
expect(seatOf(s, 'W').pos).toBeNull();
expect(reason(tryPut(s, 'v:b5'))).toBe('ok');
});
test('legalWalls 미리보기: 상대 거리 변화량', () => {
const s = make();
const lw = quoridor.view(s, 'A').legalWalls!;
expect(lw.length).toBe(128);
// e8↔e9·f8↔f9 가로벽은 북과 남의 길을 모두 1칸 늘린다(둘 다 e열을 지나므로).
expect(lw.find((w) => w.o === 'h' && w.c === 4 && w.r === 7)).toMatchObject({ opp: 1, me: 1 });
});
});
describe('goal and game end (§6)', () => {
test('목표 줄 도착 즉시 승리', () => {
const s = go(position(make(), { S: 'c8', N: 'a5' }), 'c9');
expect(s.phase).toBe('finished');
expect(s.result).toMatchObject({ reason: 'reachedGoal' });
expect(quoridor.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
expect(s.result!.summaryKo).toBe('남 승리 — 1수 만에 도착');
expect(s.result!.perPlayer.map((p) => p.distanceLeft)).toEqual([0, 4]);
});
test('점프로 도착해도 승리', () => {
const s = go(position(make(), { S: 'e7', N: 'e8' }), 'e9');
expect(s.result?.ranking[0]).toEqual({ playerId: 'A', rank: 1 });
});
test('북도 1행에 닿으면 승리', () => {
let s = position(make(), { S: 'a1', N: 'e2' }, [], 'N');
s = go(s, 'e1');
expect(quoridor.result(s)!.ranking).toEqual([['B'], ['A']]);
});
test('4인 결과 순위: 도착자 1등, 나머지는 남은 거리 순, 같으면 공동', () => {
let s = position(make(4), { S: 'e8', W: 'f5', N: 'c4', E: 'f3' }, [], 'S');
s = go(s, 'e9');
expect(s.result!.ranking).toEqual([
{ playerId: 'A', rank: 1 },
{ playerId: 'B', rank: 2 },
{ playerId: 'C', rank: 2 },
{ playerId: 'D', rank: 4 },
]);
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B', 'C'], ['D']]);
});
test('4인 기권: 말 제거, 남은 벽 소멸, 설치한 벽 유지, 1명 남으면 그 사람 1등', () => {
let s = make(4);
s = put(s, 'h:a1'); // 남
s = put(s, 'h:c1'); // 서
s = act(s, 'B', { type: 'resign' });
expect(seatOf(s, 'W')).toMatchObject({ pos: null, wallsLeft: 0, status: 'resigned' });
expect(s.walls).toHaveLength(2);
expect(turnId(s)).toBe('C');
s = act(s, 'C', { type: 'resign' }); // 차례인 사람이 기권 → 다음(동)
expect(turnId(s)).toBe('D');
s = act(s, 'A', { type: 'resign' });
expect(s.phase).toBe('finished');
expect(quoridor.result(s)).toMatchObject({ ranking: [['D'], ['A'], ['C'], ['B']], reason: 'resign' });
expect(s.result!.summaryKo).toBe('다른 사람이 모두 기권해 동 승리');
});
test('2인 기권은 상대 승', () => {
const s = act(make(), 'A', { type: 'resign' });
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'resign', summary: '남 기권으로 북 승리' });
expect(quoridor.onLeave!(s, 'B')).toBeNull();
expect(quoridor.onLeave!(make(), 'A')).toEqual({ type: 'resign' });
});
test('continueForRanking: 도착자를 판에서 빼고 계속, 도착 순서대로 순위', () => {
let s = position(make(4, { continueForRanking: true }), { S: 'e8', W: 'h5', N: 'c2', E: 'f3' }, [], 'S');
s = go(s, 'e9');
expect(s.phase).toBe('playing');
expect(seatOf(s, 'S')).toMatchObject({ status: 'finished', finishOrder: 1, pos: null });
expect(turnId(s)).toBe('B');
s = go(s, 'i5'); // 서 도착
expect(turnId(s)).toBe('C');
expect(quoridor.validate(s, 'A', { type: 'resign' }).ok).toBe(false);
s = go(s, 'c1'); // 북 도착 → 동만 남음
expect(s.phase).toBe('finished');
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B'], ['C'], ['D']]);
expect(s.result!.summaryKo).toBe('남 → 서 → 북 순서로 도착');
});
test('무승부 합의: 남은 사람 모두 동의해야, 다음 사람이 두면 자동 취소', () => {
let s = act(make(4), 'B', { type: 'offerDraw' });
expect(quoridor.activePlayers(s)).toEqual(['A', 'C', 'D']);
s = act(s, 'A', { type: 'respondDraw', accept: true });
s = act(s, 'C', { type: 'respondDraw', accept: true });
expect(s.phase).toBe('playing');
const moved = go(s, 'e2');
expect(moved.draw.pendingBy).toBeNull();
expect(reason(quoridor.validate(moved, 'B', { type: 'offerDraw' }))).toBe('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
s = act(s, 'D', { type: 'respondDraw', accept: true });
expect(quoridor.result(s)).toMatchObject({ ranking: [['A', 'B', 'C', 'D']], reason: 'draw-agreed', summary: '합의 무승부' });
const declined = act(act(make(), 'A', { type: 'offerDraw' }), 'B', { type: 'respondDraw', accept: false });
expect(declined.draw.pendingBy).toBeNull();
expect(quoridor.validate(make(2, { drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false);
});
});
describe('undo', () => {
test('2인: 상대가 이미 뒀으면 2개 되돌림(벽 개수 포함), 거절하면 그대로, 횟수 제한', () => {
let s = put(make(2, { undo: '1' }), 'h:e5'); // 남 벽
s = go(s, 'e8'); // 북 이동
s = act(s, 'A', { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: 'S', accepted: [], plies: 2 });
expect(reason(quoridor.validate(s, 'A', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
const no = act(s, 'B', { type: 'respondUndo', accept: false });
expect(no.walls).toHaveLength(1);
expect(no.undo.pending).toBeNull();
s = act(s, 'B', { type: 'respondUndo', accept: true });
expect(s.walls).toEqual([]);
expect(seatOf(s, 'S').wallsLeft).toBe(10);
expect(seatOf(s, 'N').pos).toEqual(at('e9'));
expect(turnId(s)).toBe('A');
expect(s.moves).toEqual([]);
s = go(s, 'e2');
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('무르기 횟수를 다 썼어요.');
expect(quoridor.validate(make(2, { undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false);
expect(reason(quoridor.validate(make(), 'A', { type: 'requestUndo' }))).toBe('아직 무를 수가 없어요.');
});
test('4인: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가', () => {
let s = go(make(4), 'e2');
s = act(s, 'A', { type: 'requestUndo' });
expect(quoridor.activePlayers(s)).toEqual(['B', 'C', 'D']);
s = act(s, 'B', { type: 'respondUndo', accept: true });
s = act(s, 'C', { type: 'respondUndo', accept: true });
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
expect(reason(quoridor.validate(s, 'C', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
const declined = act(s, 'D', { type: 'respondUndo', accept: false });
expect(seatOf(declined, 'S').pos).toEqual(at('e2'));
expect(declined.undo.pending).toBeNull();
s = act(s, 'D', { type: 'respondUndo', accept: true });
expect(seatOf(s, 'S').pos).toEqual(at('e1'));
expect(turnId(s)).toBe('A');
expect(s.undo.usedBy.S).toBe(1);
// 다음 사람(서)이 행동한 뒤에는 남이 무를 수 없다.
s = go(go(s, 'e2'), 'b5');
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.');
expect(quoridor.validate(s, 'B', { type: 'requestUndo' }).ok).toBe(true);
});
});
describe('forced pass (§5.4)', () => {
// 4인: 남 a1, 서 a2(위), 북 a3(서 뒤), 동 b2. 세로벽 a1|b1·a2|b2. 남은 벽이 없으면 말·벽 모두 불가.
const stuck = (wallsLeft: number) => {
const s = position(make(4), { S: 'a1', W: 'a2', N: 'a3', E: 'b2' }, ['v:a1'], 'E');
seatOf(s, 'S').wallsLeft = wallsLeft;
return s;
};
test('말 이동도 벽도 불가하면 자동 패스', () => {
const s0 = stuck(0);
expect(pawnMovesFor(s0, 0)).toEqual([]);
const s = go(s0, 'c2'); // 동이 움직여도 남은 여전히 갇힘
expect(s.moves.slice(-2)).toEqual([
{ kind: 'move', seat: 'E', from: at('b2'), to: at('c2'), auto: false },
{ kind: 'pass', seat: 'S' },
]);
expect(turnId(s)).toBe('B');
});
test('벽이 남아 있으면 패스하지 않음', () => {
const s = go(stuck(1), 'c2');
expect(turnId(s)).toBe('A');
expect(quoridor.view(s, 'A').legalPawnMoves).toEqual([]);
expect(quoridor.view(s, 'A').legalWalls!.length).toBeGreaterThan(0);
});
});
describe('time (§8.5)', () => {
test('autoMove: 최단 거리가 줄어드는 칸으로 이동', () => {
const s = act(make(), 'A', { type: 'timeout' });
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
expect(s.moves[0]).toMatchObject({ kind: 'move', auto: true });
expect(s.clock.autoMoveStreak.S).toBe(1);
});
test('autoMove 동률은 RNG: 같은 시드 같은 결과, 시드에 따라 둘 다 나옴', () => {
// 남 e5 앞에 북 e6, 그 뒤에 서 e7 → 대각 d6·f6 모두 거리 3.
const base = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
const pick = (seed: number) => cellLabel(seatOf(act(base, 'A', { type: 'timeout' }, 1000, seed), 'S').pos!);
const seen = new Set<string>();
for (let seed = 1; seed <= 20; seed++) {
expect(pick(seed)).toBe(pick(seed));
seen.add(pick(seed));
}
expect([...seen].sort()).toEqual(['d6', 'f6']);
});
test('3번 연속 시간 초과면 시간패, 직접 두면 연속 횟수 초기화', () => {
let s = act(make(), 'A', { type: 'timeout' });
s = go(s, 'e8');
s = act(s, 'A', { type: 'timeout' });
s = go(s, 'e7');
const reset = go(s, 'd3');
expect(reset.clock.autoMoveStreak.S).toBe(0);
s = act(s, 'A', { type: 'timeout' });
expect(s.phase).toBe('finished');
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout', summary: '남 시간패로 북 승리' });
expect(seatOf(s, 'S').status).toBe('timedOut');
});
test('4인 시간패는 말 제거 후 다음 사람 차례', () => {
const s = act(make(4, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' });
expect(seatOf(s, 'S')).toMatchObject({ status: 'timedOut', pos: null, wallsLeft: 0 });
expect(turnId(s)).toBe('B');
expect(s.phase).toBe('playing');
});
test('lose 정책·idle 한도는 첫 시간 초과에 시간패; 차례인 사람만', () => {
expect(act(make(2, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).phase).toBe('finished');
expect(act(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }), 'A', { type: 'timeout' }).phase).toBe('finished');
expect(quoridor.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
let s = act(go(make(), 'e2'), 'A', { type: 'requestUndo' });
expect(quoridor.timeoutPlayers!(s)).toEqual(['B']);
s = act(s, 'B', { type: 'timeout' }); // 자동 이동이 요청을 지운다
expect(s.undo.pending).toBeNull();
});
test('deadline: perMove / none / fischer', () => {
expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000);
expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBe(300_000);
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
let s = make(2, { timeControl: tc });
expect(quoridor.deadline!(s)).toBe(60_000);
s = go(s, 'e2', 10_000);
expect(s.clock.remainingMs.S).toBe(55_000);
expect(quoridor.deadline!(s)).toBe(70_000);
const late = go(s, 'e8', 80_000);
expect(late.result).toMatchObject({ reason: 'timeLoss' });
expect(late.clock.remainingMs.N).toBe(0);
const t = act(s, 'B', { type: 'timeout' });
expect(quoridor.result(t)).toMatchObject({ ranking: [['A'], ['B']], reason: 'timeout' });
expect(quoridor.deadline!(t)).toBeNull();
});
});
describe('views (§8.3)', () => {
test('legalPawnMoves/legalWalls/myShortestPath는 차례인 사람에게만, rng 없음', () => {
const s = go(make(4), 'e2');
const v = quoridor.view(s, 'B');
expect(v.legalPawnMoves!.map(cellLabel).sort()).toEqual(['a4', 'a6', 'b5']);
expect(v.legalWalls!.length).toBe(128);
expect(v.myShortestPath!.length).toBe(8);
expect(v.mySeat).toBe('W');
for (const viewer of ['A', 'C', 'D', null]) {
const o = quoridor.view(s, viewer);
expect(o.legalPawnMoves).toBeNull();
expect(o.legalWalls).toBeNull();
expect(o.myShortestPath).toBeNull();
expect(o.seats.map((x) => x.distance)).toEqual([7, 8, 8, 8]);
}
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(quoridor.view(s, viewer))).not.toMatch(/rng/i);
expect(JSON.stringify(s)).not.toMatch(/rng/i);
expect(quoridor.view(make(2, { showPath: false }), 'A').myShortestPath).toBeNull();
expect(quoridor.view(s, null).mySeat).toBeNull();
});
test('성능: 합법 벽 목록(128 후보 × BFS 4회)이 1ms 수준', () => {
// 벽 몇 개가 있는 4인 중반 국면.
let s = make(4);
for (const w of ['h:a3', 'h:c3', 'v:e2', 'h:f6', 'v:b6', 'h:d7', 'v:g3', 'h:g1']) s = put(s, w);
const t0 = performance.now();
const N = 200;
let n = 0;
for (let k = 0; k < N; k++) n += legalWallsFor(s, s.turnIndex, true).length;
const ms = (performance.now() - t0) / N;
console.log(` legalWalls (4인, 변화량 포함): ${ms.toFixed(3)}ms / 회, 후보 ${n / N}개`);
expect(n / N).toBeGreaterThan(80);
expect(ms).toBeLessThan(5); // 부하가 있는 CI에서도 여유 있게
});
});
// ---------------------------------------------------------------- random playouts
const TOTAL_SEATS = (s: QuoridorState) => s.seats.length;
/** 매 단계 불변식: 벽 보존, 인덱스 일치, 겹침 없음, 남은 사람 모두 길 있음, 말 겹침 없음, 정보 누출 없음. */
function invariant(s: QuoridorState): void {
const per = s.seats.length === 4 ? 5 : s.options.wallsPerPlayer;
let placed = 0;
for (const x of s.seats) {
const mine = s.moves.filter((m) => m.kind === 'wall' && m.seat === x.seat).length;
placed += mine;
if (x.status === 'resigned' || x.status === 'timedOut') {
if (s.seats.length === 4 && x.wallsLeft !== 0) throw new Error('removed seat kept walls');
} else if (mine + x.wallsLeft !== per) throw new Error(`wall conservation broken for ${x.seat}`);
}
if (placed !== s.walls.length) throw new Error('walls list != wall moves');
const idx = emptyWallIndex();
for (const w of s.walls) {
if (w.c < 0 || w.c > 7 || w.r < 0 || w.r > 7) throw new Error('wall anchor out of range');
setWall(idx, w, true);
}
if (JSON.stringify(idx) !== JSON.stringify(s.wallIndex)) throw new Error('wallIndex out of sync');
const keys = new Set<string>();
for (const w of s.walls) {
// 같은 방향 반 칸 겹침, 같은 앵커 십자.
const k = w.o === 'h' ? [`h${w.c}${w.r}`, `h${w.c + 1}${w.r}`, `x${w.c}${w.r}`] : [`v${w.c}${w.r}`, `v${w.c}${w.r + 1}`, `x${w.c}${w.r}`];
for (const key of k) {
if (keys.has(key)) throw new Error('overlapping walls');
keys.add(key);
}
}
const cells = new Set<string>();
for (const x of s.seats) {
if (!x.pos) continue;
const k = cellLabel(x.pos);
if (cells.has(k)) throw new Error('two pawns on one cell');
cells.add(k);
if (x.status === 'playing' && pathLength(s.wallIndex, x.pos, x.seat) < 0) throw new Error(`${x.seat} has no path`);
}
if (s.phase === 'playing') {
const cur = s.seats[s.turnIndex]!;
if (cur.status !== 'playing') throw new Error('turn on a removed seat');
if (s.seats.filter((x) => x.status === 'playing').length < 2) throw new Error('game should have ended');
for (const p of quoridor.timeoutPlayers!(s)) {
const v = quoridor.validate(s, p, quoridor.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
}
} else if (!s.result || !quoridor.result(s)) throw new Error('finished without result');
}
function checkViews(s: QuoridorState): void {
for (const viewer of [...s.seats.map((x) => x.playerId), null]) {
const v = quoridor.view(s, viewer);
const mine = s.phase === 'playing' && viewer === s.seats[s.turnIndex]!.playerId;
if (!mine && (v.legalWalls !== null || v.legalPawnMoves !== null || v.myShortestPath !== null)) throw new Error('turn-only hint leaked');
if (/rng/i.test(JSON.stringify(v))) throw new Error('rng in view');
}
}
/** 빠른 무작위 대국: 최단 길 이동 / 무작위 이동 / 무작위 벽(거부 샘플링) / 가끔 시간 초과·무르기·무승부·기권. */
function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEvery: number) {
const r = SeededRng.fromSeed(seedN(seed));
const pick = SeededRng.fromSeed(seedN(seed + 77_777));
let now = 1_700_000_000_000;
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
let s = quoridor.setup({ players, options, rng: r, now });
const trace: { actor: string; action: QuoridorAction }[] = [];
const step = (actor: string, action: QuoridorAction) => {
const v = quoridor.validate(s, actor, action);
if (!v.ok) throw new Error(`seed ${seed}: rejected ${JSON.stringify(action)}: ${v.reason}`);
now += 1000;
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
trace.push({ actor, action });
};
for (let k = 0; k < 3000 && s.phase === 'playing'; k++) {
if (k % checkEvery === 0) {
invariant(s);
checkViews(s);
if (JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
}
// 대기 중인 요청이 있으면 응답부터.
const pend = s.undo.pending ?? (s.draw.pendingBy ? { by: s.draw.pendingBy, accepted: s.draw.accepted } : null);
if (pend) {
const responder = s.seats.find((x) => x.status === 'playing' && x.seat !== pend.by && !pend.accepted.includes(x.seat));
if (responder) {
const accept = pick.next() < 0.7;
step(responder.playerId, s.undo.pending ? { type: 'respondUndo', accept } : { type: 'respondDraw', accept: accept && pick.next() < 0.3 });
continue;
}
}
const i = s.turnIndex;
const me = s.seats[i]!;
const roll = pick.next();
if (roll < 0.03) {
step(me.playerId, { type: 'timeout' });
continue;
}
if (roll < 0.04) {
const any = s.seats.filter((x) => x.status === 'playing')[pick.int(s.seats.filter((x) => x.status === 'playing').length)]!;
const a: QuoridorAction = pick.next() < 0.5 ? { type: 'requestUndo' } : { type: 'offerDraw' };
if (quoridor.validate(s, any.playerId, a).ok) {
step(any.playerId, a);
continue;
}
}
if (roll < 0.042 && n === 4) {
step(s.seats.filter((x) => x.status === 'playing')[0]!.playerId, { type: 'resign' });
continue;
}
if (roll < 0.4 && me.wallsLeft > 0) {
let placed = false;
for (let t = 0; t < 12 && !placed; t++) {
const a: QuoridorAction = { type: 'placeWall', c: pick.int(8), r: pick.int(8), o: pick.int(2) ? 'h' : 'v' };
if (quoridor.validate(s, me.playerId, a).ok) {
step(me.playerId, a);
placed = true;
}
}
if (placed) continue;
}
const cands = pawnMovesFor(s, i);
if (cands.length === 0) {
const lw = legalWallsFor(s, i);
step(me.playerId, lw.length ? { type: 'placeWall', c: lw[0]!.c, r: lw[0]!.r, o: lw[0]!.o } : { type: 'timeout' });
continue;
}
let to = cands[pick.int(cands.length)]!;
if (pick.next() < 0.6) {
let best = Infinity;
for (const c of cands) {
const d = pathLength(s.wallIndex, c, me.seat);
if (d >= 0 && d < best) {
best = d;
to = c;
}
}
}
step(me.playerId, { type: 'move', to });
}
invariant(s);
return { state: s, trace, players };
}
describe('random playouts (docs/09 §7)', () => {
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
const t0 = performance.now();
let finished = 0;
let steps = 0;
const reasons: Record<string, number> = {};
for (let i = 0; i < 1000; i++) {
const n = i % 3 === 0 ? 4 : 2;
const options = quoridor.optionsSchema.parse({
seatOrder: i % 2 ? 'random' : 'joinOrder',
diagonalJumpTrigger: i % 5 === 0 ? 'wallOnly' : 'wallEdgePawn',
continueForRanking: i % 6 === 0,
wallsPerPlayer: 6 + (i % 5),
undo: i % 7 === 0 ? 'unlimited' : '3',
timeControl: i % 11 === 0 ? { kind: 'fischer', perMoveSec: 30, baseSec: 300, incSec: 3 } : { kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 },
});
const { state, trace } = fastPlayout(i, n, options, i < 100 ? 1 : 7);
if (state.phase === 'finished') finished++;
steps += trace.length;
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
}
console.log(` 1000 playouts: ${finished} finished, avg ${(steps / 1000).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
expect(finished).toBe(1000);
expect(reasons.reachedGoal).toBeGreaterThan(500);
}, 60_000);
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
for (let i = 0; i < 40; i++) {
const n = i % 2 ? 4 : 2;
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 4 === 1 });
const a = fastPlayout(5000 + i, n, options, 1000);
const b = fastPlayout(5000 + i, n, options, 1000);
expect(JSON.stringify(a.state)).toBe(JSON.stringify(b.state));
const r = SeededRng.fromSeed(seedN(5000 + i));
let now = 1_700_000_000_000;
let s = quoridor.setup({ players: a.players, options, rng: r, now });
for (const { actor, action } of a.trace) {
now += 1000;
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
}
expect(JSON.stringify(s)).toBe(JSON.stringify(a.state));
}
}, 60_000);
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
for (let i = 0; i < 24; i++) {
const players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
const inv = (st: unknown) => {
const s = st as QuoridorState;
if (TOTAL_SEATS(s) !== players.length) throw new Error('seat count changed');
invariant(s);
};
const rep = randomPlayout(quoridor, { players, options, seed: seedN(900 + i), invariant: inv, maxSteps: 20_000, maxApplyMs: 50 });
expect(rep.finished).toBe(true);
expect(quoridor.result(rep.finalState as QuoridorState)).not.toBeNull();
}
}, 60_000);
});

View File

@@ -0,0 +1,76 @@
/**
* 보석 상인 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card
* list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`.
*/
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;
export type Color = (typeof COLORS)[number];
export type TokenColor = Color | 'gold';
export const TOKEN_COLORS: readonly TokenColor[] = [...COLORS, 'gold'];
export type Gems = Record<Color, number>;
export type Tokens = Record<TokenColor, number>;
export type Tier = 1 | 2 | 3;
export const TIERS: readonly Tier[] = [1, 2, 3];
export interface DevCard {
id: string;
tier: Tier;
bonus: Color;
points: number;
cost: Gems;
}
export interface Noble {
id: string;
points: 3;
req: Gems;
}
const RAW_CARDS = [
// tier 1
'1W01:0:01111', '1W02:0:01211', '1W03:0:02201', '1W04:0:31001', '1W05:0:00021', '1W06:0:02002', '1W07:0:03000', '1W08:1:00400',
'1U01:0:10111', '1U02:0:10121', '1U03:0:10220', '1U04:0:01310', '1U05:0:10002', '1U06:0:00202', '1U07:0:00003', '1U08:1:00040',
'1G01:0:11011', '1G02:0:11012', '1G03:0:01022', '1G04:0:13100', '1G05:0:21000', '1G06:0:02020', '1G07:0:00030', '1G08:1:00004',
'1R01:0:11101', '1R02:0:21101', '1R03:0:20102', '1R04:0:10013', '1R05:0:02100', '1R06:0:20020', '1R07:0:30000', '1R08:1:40000',
'1K01:0:11110', '1K02:0:12110', '1K03:0:22010', '1K04:0:00131', '1K05:0:00210', '1K06:0:20200', '1K07:0:00300', '1K08:1:04000',
// tier 2
'2W01:1:00322', '2W02:1:23030', '2W03:2:00142', '2W04:2:00053', '2W05:2:00050', '2W06:3:60000', '2U01:1:02230', '2U02:1:02303',
'2U03:2:53000', '2U04:2:20014', '2U05:2:05000', '2U06:3:06000', '2G01:1:30230', '2G02:1:23002', '2G03:2:42001', '2G04:2:05300',
'2G05:2:00500', '2G06:3:00600', '2R01:1:20023', '2R02:1:03023', '2R03:2:14200', '2R04:2:30005', '2R05:2:00005', '2R06:3:00060',
'2K01:1:32200', '2K02:1:30302', '2K03:2:01420', '2K04:2:00530', '2K05:2:50000', '2K06:3:00006',
// tier 3
'3W01:3:03353', '3W02:4:00007', '3W03:4:30036', '3W04:5:30007', '3U01:3:30335', '3U02:4:70000', '3U03:4:63003', '3U04:5:73000',
'3G01:3:53033', '3G02:4:07000', '3G03:4:36300', '3G04:5:07300', '3R01:3:35303', '3R02:4:00700', '3R03:4:03630', '3R04:5:00730',
'3K01:3:33530', '3K02:4:00070', '3K03:4:00363', '3K04:5:00073',
];
const RAW_NOBLES = [
'N01:00440', 'N02:04400', 'N03:44000', 'N04:40004', 'N05:00044', 'N06:03330', 'N07:33300', 'N08:33003', 'N09:30033', 'N10:00333',
];
const gems = (digits: string): Gems => ({ W: +digits[0]!, U: +digits[1]!, G: +digits[2]!, R: +digits[3]!, K: +digits[4]! });
export const CARDS: readonly DevCard[] = RAW_CARDS.map((raw) => {
const [id, points, cost] = raw.split(':') as [string, string, string];
return { id, tier: Number(id[0]) as Tier, bonus: id[1] as Color, points: Number(points), cost: gems(cost) };
});
export const NOBLES: readonly Noble[] = RAW_NOBLES.map((raw) => {
const [id, req] = raw.split(':') as [string, string];
return { id, points: 3, req: gems(req) };
});
const CARD_BY_ID = new Map(CARDS.map((c) => [c.id, c]));
const NOBLE_BY_ID = new Map(NOBLES.map((n) => [n.id, n]));
export function cardOf(id: string): DevCard {
const c = CARD_BY_ID.get(id);
if (!c) throw new Error(`unknown card ${id}`);
return c;
}
export function nobleOf(id: string): Noble {
const n = NOBLE_BY_ID.get(id);
if (!n) throw new Error(`unknown noble ${id}`);
return n;
}
export const isCardId = (id: string): boolean => CARD_BY_ID.has(id);
export const emptyGems = (): Gems => ({ W: 0, U: 0, G: 0, R: 0, K: 0 });
export const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 });
export const sumTokens = (t: Partial<Record<TokenColor, number>>): number => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0);

View File

@@ -0,0 +1,968 @@
/**
* 보석 상인 (스플렌더 방식, docs/games/splendor.md). Pure rules module: all randomness is in setup
* (turn order, nobles, decks), state is plain JSON, the view hides deck contents and blind reservations.
*/
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
import {
CARDS,
COLORS,
NOBLES,
TIERS,
TOKEN_COLORS,
cardOf,
emptyGems,
emptyTokens,
nobleOf,
sumTokens,
type Color,
type DevCard,
type Gems,
type Noble,
type Tier,
type TokenColor,
type Tokens,
} from './cards';
export * from './cards';
const lit = <T extends number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const splendorOptionsSchema = z.object({
targetPoints: lit(15, 10)
.default(15)
.meta({ title: '끝나는 점수', labels: { 15: '15점(원작)', 10: '10점(짧은 판)' } }),
allowFewerTokens: z.boolean().default(true).meta({ title: '남은 색이 3가지보다 적으면 서로 다른 색 2개나 1개만 가져오기 허용' }),
turnSeconds: lit(30, 60, 90, null)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', null: '제한 없음' } }),
showHints: z.boolean().default(true).meta({ title: '살 수 있는 카드와 귀족까지 남은 보너스 보여 주기' }),
});
export type SplendorOptions = z.infer<typeof splendorOptionsSchema>;
const colorSchema = z.enum(COLORS);
const tierSchema = lit(1, 2, 3);
const count = z.number().int().min(0).max(10);
const tokensSchema = z.object({ W: count, U: count, G: count, R: count, K: count, gold: count });
export const splendorActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('TAKE_DIFFERENT'), colors: z.array(colorSchema).min(1).max(3) }),
z.object({ type: z.literal('TAKE_SAME'), color: colorSchema }),
z.object({
type: z.literal('RESERVE'),
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ tier: tierSchema, deck: z.literal(true) })]),
}),
z.object({
type: z.literal('BUY'),
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ reservedIndex: z.number().int().min(0).max(2) })]),
payment: tokensSchema.optional(),
}),
z.object({ type: z.literal('PASS') }),
z.object({ type: z.literal('DISCARD'), tokens: tokensSchema }),
z.object({ type: z.literal('CHOOSE_NOBLE'), nobleId: z.string().max(8) }),
z.object({ type: z.literal('CONCEDE') }),
]);
/** Client actions plus the system-only timeout action (not in the schema, docs/09 §2). */
export type SplendorAction = z.infer<typeof splendorActionSchema> | { type: 'TIMEOUT' };
type PlayAction = Extract<SplendorAction, { type: 'TAKE_DIFFERENT' | 'TAKE_SAME' | 'RESERVE' | 'BUY' | 'PASS' }>;
export type BuyFrom = Extract<SplendorAction, { type: 'BUY' }>['from'];
export type ReserveFrom = Extract<SplendorAction, { type: 'RESERVE' }>['from'];
export const TOKEN_LIMIT = 10;
export const MAX_RESERVED = 3;
export const BOARD_SLOTS = 4;
/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
/** Turn length for away players. */
export const AWAY_MS = 5000;
/** Extra time when the turn goes on to returning tokens or choosing a noble (§8.5). */
export const EXTRA_PHASE_MS = 15_000;
const LOG_MAX = 20;
export interface PlayerState {
tokens: Tokens;
/** Bought development cards. */
cards: string[];
/** `blind` = taken face down from a deck (only the owner sees it). */
reserved: { cardId: string; blind: boolean }[];
nobles: string[];
}
export type LogEntry =
| { n: number; k: 'take'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
| { n: number; k: 'reserve'; p: PlayerId; tier: Tier; cardId: string | null; gold: boolean; auto: boolean }
| { n: number; k: 'buy'; p: PlayerId; cardId: string; paid: Partial<Tokens>; fromReserve: boolean; auto: boolean }
| { n: number; k: 'pass'; p: PlayerId; auto: boolean }
| { n: number; k: 'discard'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
| { n: number; k: 'noble'; p: PlayerId; nobleId: string }
| { n: number; k: 'concede'; p: PlayerId }
| { n: number; k: 'away'; p: PlayerId }
| { n: number; k: 'end'; p: PlayerId; points: number };
export interface SplendorState {
options: SplendorOptions;
/** Turn order, order[0] = first player (shuffled at setup). */
order: PlayerId[];
current: number;
phase: 'action' | 'discard' | 'noble' | 'finished';
/** Somebody reached the target: the round is played to its end (§6). */
endTriggered: boolean;
endReason: 'target' | 'stalemate' | 'resign' | null;
supply: Tokens;
/** Hidden, index 0 = top. */
decks: Record<Tier, string[]>;
/** Four open slots per tier, null = empty (deck ran out). */
board: Record<Tier, (string | null)[]>;
/** Open nobles still waiting for a visit. */
nobles: string[];
players: Record<PlayerId, PlayerState>;
pendingNobleChoices: string[] | null;
turnsTaken: Record<PlayerId, number>;
turnSeq: number;
deadlineAt: number | null;
conceded: PlayerId[];
/** Reserved cards of players who conceded (out of the game). */
removed: { cardId: string; blind: boolean }[];
timeoutsInRow: Record<PlayerId, number>;
/** Consecutive PASS turns; a full round of passes ends the game (nothing can change any more). */
passStreak: number;
logSeq: number;
log: LogEntry[];
}
export type ReservedView = { hidden: true; tier: Tier } | (DevCard & { hidden: false; blind: boolean });
export interface BuyOption {
/** Automatic payment (colour tokens first, gold only for the rest). */
payment: Tokens;
/** Tokens per colour still needed after bonuses. */
need: Gems;
gold: number;
total: number;
}
export interface SplendorPlayerView {
id: PlayerId;
tokens: Tokens;
tokenCount: number;
bonuses: Gems;
cards: string[];
points: number;
nobles: string[];
reservedCount: number;
reserved: ReservedView[];
conceded: boolean;
away: boolean;
turnsTaken: number;
}
export interface SplendorMeView {
id: PlayerId;
myTurn: boolean;
/** Action phase only: the colours that can be picked for "different colours" and how many to pick. */
takeDifferent: { available: Color[]; count: number } | null;
takeSame: Color[];
canReserve: boolean;
reserveDecks: Tier[];
goldOnReserve: boolean;
/** Affordable cards for me, keyed `b<tier>-<slot>` (board) or `r<index>` (my reservations). */
buy: Record<string, BuyOption>;
canPass: boolean;
/** Discard phase: how many tokens must go back. */
discardCount: number;
nobleChoices: string[] | null;
/** Hints: bonuses still missing per open noble (empty when hints are off). */
nobleNeed: Record<string, number>;
}
export interface SplendorRankRow {
rank: number;
player: PlayerId;
points: number;
cards: number;
nobles: number;
bonuses: Gems;
conceded: boolean;
}
export interface SplendorResult {
ranking: SplendorRankRow[];
winners: PlayerId[];
revealedReserved: Record<PlayerId, string[]>;
summary: string;
reason: GameResult['reason'];
endReason: SplendorState['endReason'];
}
export interface SplendorView {
options: SplendorOptions;
phase: SplendorState['phase'];
order: PlayerId[];
current: PlayerId | null;
turnSeq: number;
deadlineAt: number | null;
/** Full length of the running timer (for the ring). */
turnMs: number;
endTriggered: boolean;
supply: Tokens;
board: Record<Tier, (DevCard | null)[]>;
deckCounts: Record<Tier, number>;
nobles: Noble[];
players: Record<PlayerId, SplendorPlayerView>;
me: SplendorMeView | null;
log: LogEntry[];
result: SplendorResult | null;
}
// ---------------------------------------------------------------- helpers
export const tokensPerColor = (players: number): number => (players <= 2 ? 4 : players === 3 ? 5 : 7);
export function bonusesOf(ps: PlayerState): Gems {
const b = emptyGems();
for (const id of ps.cards) b[cardOf(id).bonus]++;
return b;
}
export function pointsOf(ps: PlayerState): number {
return ps.cards.reduce((a, id) => a + cardOf(id).points, 0) + ps.nobles.length * 3;
}
/** Tokens per colour still needed for `card` after my bonuses. */
export function needFor(card: DevCard, bonuses: Gems): Gems {
const n = emptyGems();
for (const c of COLORS) n[c] = Math.max(0, card.cost[c] - bonuses[c]);
return n;
}
/** §5 "색 토큰 먼저, 부족분만 황금"; null when I can't afford the card. */
export function autoPayment(card: DevCard, ps: PlayerState, bonuses = bonusesOf(ps)): Tokens | null {
const need = needFor(card, bonuses);
const pay = emptyTokens();
let short = 0;
for (const c of COLORS) {
pay[c] = Math.min(need[c], ps.tokens[c]);
short += need[c] - pay[c];
}
if (short > ps.tokens.gold) return null;
pay.gold = short;
return pay;
}
/** A manual payment: never more than I hold, no colour overpaid, gold covers exactly the rest. */
export function paymentProblem(card: DevCard, ps: PlayerState, payment: Tokens, bonuses = bonusesOf(ps)): string | null {
const need = needFor(card, bonuses);
let short = 0;
for (const c of TOKEN_COLORS) {
const v = payment[c];
if (!Number.isInteger(v) || v < 0) return '지불 내역이 올바르지 않아요.';
if (v > ps.tokens[c]) return '보석이 부족해요.';
}
for (const c of COLORS) {
if (payment[c] > need[c]) return '필요한 것보다 많이 낼 수는 없어요.';
short += need[c] - payment[c];
}
if (payment.gold > short) return '필요한 것보다 많이 낼 수는 없어요.';
if (payment.gold < short) return '보석이 부족해요.';
return null;
}
const satisfies = (bonuses: Gems, req: Gems) => COLORS.every((c) => bonuses[c] >= req[c]);
const tokenCount = (ps: PlayerState) => sumTokens(ps.tokens);
const isActive = (s: SplendorState, p: PlayerId) => !s.conceded.includes(p);
const activePlayersOf = (s: SplendorState) => s.order.filter((p) => isActive(s, p));
const currentPlayer = (s: SplendorState): PlayerId | null => (s.phase === 'finished' ? null : s.order[s.current]!);
const compact = (t: Tokens): Partial<Tokens> => {
const out: Partial<Tokens> = {};
for (const c of TOKEN_COLORS) if (t[c]) out[c] = t[c];
return out;
};
const buyKey = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`);
/** Colours that still have tokens, and how many "different colours" must be taken (0 = not possible). */
function differentRule(s: SplendorState): { available: Color[]; count: number } {
const available = COLORS.filter((c) => s.supply[c] > 0);
if (available.length >= 3) return { available, count: 3 };
return { available, count: s.options.allowFewerTokens ? available.length : 0 };
}
function cardAt(s: SplendorState, p: PlayerId, from: BuyFrom): { id: string; reserved: boolean } | null {
if ('reservedIndex' in from) {
const r = s.players[p]!.reserved[from.reservedIndex];
return r ? { id: r.cardId, reserved: true } : null;
}
const id = s.board[from.tier]?.[from.slot];
return id ? { id, reserved: false } : null;
}
/** Every card I could buy right now (board tier 1→3, left to right, then my reservations). */
function buyables(s: SplendorState, p: PlayerId): { from: BuyFrom; card: DevCard; payment: Tokens }[] {
const ps = s.players[p]!;
const bonuses = bonusesOf(ps);
const out: { from: BuyFrom; card: DevCard; payment: Tokens }[] = [];
for (const tier of TIERS) {
s.board[tier].forEach((id, slot) => {
if (!id) return;
const card = cardOf(id);
const payment = autoPayment(card, ps, bonuses);
if (payment) out.push({ from: { tier, slot }, card, payment });
});
}
ps.reserved.forEach((r, i) => {
const card = cardOf(r.cardId);
const payment = autoPayment(card, ps, bonuses);
if (payment) out.push({ from: { reservedIndex: i }, card, payment });
});
return out;
}
function reserveTargets(s: SplendorState, p: PlayerId): ReserveFrom[] {
if (s.players[p]!.reserved.length >= MAX_RESERVED) return [];
const out: ReserveFrom[] = [];
for (const tier of TIERS) s.board[tier].forEach((id, slot) => id && out.push({ tier, slot }));
for (const tier of TIERS) if (s.decks[tier].length) out.push({ tier, deck: true });
return out;
}
/** Can the player do any of the four actions (§5 "행동 불가")? */
function canAct(s: SplendorState, p: PlayerId): boolean {
if (differentRule(s).count > 0) return true;
if (COLORS.some((c) => s.supply[c] >= 4)) return true;
if (reserveTargets(s, p).length) return true;
return buyables(s, p).length > 0;
}
function deadlineFor(s: SplendorState, p: PlayerId, now: number): number | null {
if (s.options.turnSeconds === null) return null;
if ((s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER) return now + AWAY_MS;
return now + s.options.turnSeconds * 1000;
}
type DistOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
function pushLog(s: SplendorState, e: DistOmit<LogEntry, 'n'>): void {
s.log.push({ ...e, n: ++s.logSeq } as LogEntry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
function refill(s: SplendorState, tier: Tier, slot: number, events: GameEvent[]): void {
const next = s.decks[tier].shift() ?? null;
s.board[tier][slot] = next;
events.push({ type: 'refilled', tier, slot, cardId: next });
}
// ---------------------------------------------------------------- turn steps (mutate a cloned state)
function doPlay(s: SplendorState, p: PlayerId, a: PlayAction, auto: boolean, events: GameEvent[]): void {
const ps = s.players[p]!;
s.passStreak = a.type === 'PASS' ? s.passStreak + 1 : 0;
switch (a.type) {
case 'TAKE_DIFFERENT':
case 'TAKE_SAME': {
const got = emptyTokens();
const colors = a.type === 'TAKE_SAME' ? [a.color, a.color] : a.colors;
for (const c of colors) {
if (s.supply[c] <= 0) throw new Error('supply empty');
s.supply[c]--;
ps.tokens[c]++;
got[c]++;
}
events.push({ type: 'tookTokens', player: p, tokens: compact(got) });
pushLog(s, { k: 'take', p, tokens: compact(got), auto });
break;
}
case 'RESERVE': {
if (ps.reserved.length >= MAX_RESERVED) throw new Error('reserve limit');
let cardId: string;
const blind = 'deck' in a.from;
if ('deck' in a.from) {
const top = s.decks[a.from.tier].shift();
if (!top) throw new Error('deck empty');
cardId = top;
} else {
const id = s.board[a.from.tier][a.from.slot];
if (!id) throw new Error('empty slot');
cardId = id;
}
ps.reserved.push({ cardId, blind });
const gold = s.supply.gold > 0;
if (gold) {
s.supply.gold--;
ps.tokens.gold++;
}
events.push({ type: 'reserved', player: p, tier: a.from.tier, cardId: blind ? null : cardId, gold });
pushLog(s, { k: 'reserve', p, tier: a.from.tier, cardId: blind ? null : cardId, gold, auto });
if (!blind && 'slot' in a.from) refill(s, a.from.tier, a.from.slot, events);
break;
}
case 'BUY': {
const at = cardAt(s, p, a.from);
if (!at) throw new Error('no card');
const card = cardOf(at.id);
const payment = a.payment ?? autoPayment(card, ps);
if (!payment || paymentProblem(card, ps, payment)) throw new Error('cannot pay');
for (const c of TOKEN_COLORS) {
ps.tokens[c] -= payment[c];
s.supply[c] += payment[c];
}
ps.cards.push(at.id);
if ('reservedIndex' in a.from) ps.reserved.splice(a.from.reservedIndex, 1);
events.push({ type: 'bought', player: p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved });
pushLog(s, { k: 'buy', p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved, auto });
if (!('reservedIndex' in a.from)) refill(s, a.from.tier, a.from.slot, events);
break;
}
case 'PASS':
events.push({ type: 'passed', player: p });
pushLog(s, { k: 'pass', p, auto });
break;
}
}
function doDiscard(s: SplendorState, p: PlayerId, tokens: Tokens, auto: boolean, events: GameEvent[]): void {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) {
if (tokens[c] > ps.tokens[c]) throw new Error('discard more than held');
ps.tokens[c] -= tokens[c];
s.supply[c] += tokens[c];
}
if (tokenCount(ps) !== TOKEN_LIMIT) throw new Error('discard must leave exactly 10');
events.push({ type: 'discarded', player: p, tokens: compact(tokens) });
pushLog(s, { k: 'discard', p, tokens: compact(tokens), auto });
}
function visitNoble(s: SplendorState, p: PlayerId, nobleId: string, events: GameEvent[]): void {
const i = s.nobles.indexOf(nobleId);
if (i < 0) throw new Error('noble not open');
s.nobles.splice(i, 1);
s.players[p]!.nobles.push(nobleId);
events.push({ type: 'nobleVisited', player: p, nobleId });
pushLog(s, { k: 'noble', p, nobleId });
}
/** After an action (or a discard): token limit → noble → end of turn (§8.2 "차례 마무리 순서"). */
function continueTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[], afterDiscard = false): void {
const ps = s.players[p]!;
if (!afterDiscard && tokenCount(ps) > TOKEN_LIMIT) {
s.phase = 'discard';
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
return;
}
const bonuses = bonusesOf(ps);
const eligible = s.nobles.filter((id) => satisfies(bonuses, nobleOf(id).req)).sort();
if (eligible.length === 1) visitNoble(s, p, eligible[0]!, events);
else if (eligible.length > 1) {
s.phase = 'noble';
s.pendingNobleChoices = eligible;
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
return;
}
endTurn(s, p, now, events);
}
function endTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
s.turnsTaken[p] = (s.turnsTaken[p] ?? 0) + 1;
s.pendingNobleChoices = null;
const pts = pointsOf(s.players[p]!);
if (!s.endTriggered && pts >= s.options.targetPoints) {
s.endTriggered = true;
events.push({ type: 'endTriggered', player: p, points: pts });
pushLog(s, { k: 'end', p, points: pts });
}
advance(s, now, events);
}
function finish(s: SplendorState, reason: NonNullable<SplendorState['endReason']>, events: GameEvent[]): void {
s.phase = 'finished';
s.endReason = reason;
s.deadlineAt = null;
s.pendingNobleChoices = null;
events.push({ type: 'gameOver', reason });
}
/** Next active seat; the game ends after the last seat of a triggered round, on a full round of passes, or with one player left. */
function advance(s: SplendorState, now: number, events: GameEvent[]): void {
const active = activePlayersOf(s);
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
const n = s.order.length;
const next = nextSeat(s.current, n, (i) => !isActive(s, s.order[i]!));
if (s.endTriggered && next <= s.current) return finish(s, 'target', events);
if (s.passStreak >= active.length) return finish(s, 'stalemate', events);
s.current = next;
s.phase = 'action';
s.pendingNobleChoices = null;
s.turnSeq++;
s.deadlineAt = deadlineFor(s, s.order[next]!, now);
events.push({ type: 'turnChanged', player: s.order[next]! });
}
function concede(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) {
s.supply[c] += ps.tokens[c];
ps.tokens[c] = 0;
}
s.removed.push(...ps.reserved);
ps.reserved = [];
s.conceded.push(p);
events.push({ type: 'conceded', player: p });
pushLog(s, { k: 'concede', p });
const wasCurrent = s.order[s.current] === p;
const active = activePlayersOf(s);
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
if (wasCurrent) {
// Their turn ends without an action; passes so far still count only for players who are in.
s.passStreak = 0;
advance(s, now, events);
}
}
// ---------------------------------------------------------------- automatic play (§8.5)
/** Deterministic default move: best buy → 3 different (most supply) → 2 same → reserve → pass. */
export function autoPlay(s: SplendorState, p: PlayerId): PlayAction {
const buys = buyables(s, p);
if (buys.length) {
let best = buys[0]!;
for (const b of buys) {
const better = b.card.points > best.card.points || (b.card.points === best.card.points && sumTokens(b.payment) < sumTokens(best.payment));
if (better) best = b;
}
return { type: 'BUY', from: best.from };
}
const rule = differentRule(s);
if (rule.count > 0) {
// Stable sort keeps W,U,G,R,K order among equal supplies.
const colors = [...rule.available].sort((a, b) => s.supply[b] - s.supply[a]).slice(0, rule.count);
return { type: 'TAKE_DIFFERENT', colors };
}
const same = [...COLORS].sort((a, b) => s.supply[b] - s.supply[a]).find((c) => s.supply[c] >= 4);
if (same) return { type: 'TAKE_SAME', color: same };
const res = reserveTargets(s, p)[0];
if (res) return { type: 'RESERVE', from: res };
return { type: 'PASS' };
}
const DISCARD_ORDER: Color[] = ['K', 'R', 'G', 'U', 'W'];
/** One token at a time from the colour I hold most of (ties K,R,G,U,W), gold last. */
export function autoDiscard(ps: PlayerState): Tokens {
const left = { ...ps.tokens };
const out = emptyTokens();
let extra = sumTokens(left) - TOKEN_LIMIT;
while (extra > 0) {
let pick: TokenColor = 'gold';
let most = 0;
for (const c of DISCARD_ORDER) {
if (left[c] > most) {
most = left[c];
pick = c;
}
}
left[pick]--;
out[pick]++;
extra--;
}
return out;
}
function autoTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
if (s.phase === 'action') {
doPlay(s, p, autoPlay(s, p), true, events);
continueTurn(s, p, now, events);
}
// Discard and noble choices of an automatic (or abandoned) turn are resolved right away.
for (let guard = 0; guard < 3 && s.phase !== 'finished' && s.order[s.current] === p; guard++) {
if (s.phase === 'discard') {
doDiscard(s, p, autoDiscard(s.players[p]!), true, events);
continueTurn(s, p, now, events, true);
} else if (s.phase === 'noble') {
visitNoble(s, p, [...s.pendingNobleChoices!].sort()[0]!, events);
endTurn(s, p, now, events);
} else break;
}
}
// ---------------------------------------------------------------- result
function rankRows(s: SplendorState): SplendorRankRow[] {
const rows = s.order.map((p) => {
const ps = s.players[p]!;
return { player: p, points: pointsOf(ps), cards: ps.cards.length, nobles: ps.nobles.length, bonuses: bonusesOf(ps), conceded: s.conceded.includes(p) };
});
// Conceded players rank last; then points high → fewer cards (§6).
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.points - a.points || a.cards - b.cards);
const out: SplendorRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.conceded === r.conceded && prev.points === r.points && prev.cards === r.cards;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: SplendorState): SplendorResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const top = ranking[0]!;
const detail = (r: SplendorRankRow) => `${r.points}점 (카드 ${r.cards}장${r.nobles ? `, 귀족 ${r.nobles}` : ''})`;
let summary: string;
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${detail(top)}`;
else if (ranking.length > 1) summary = `1위 ${detail(top)} · 2위 ${ranking[1]!.points}점`;
else summary = `${detail(top)}`;
if (s.endReason === 'resign') summary = `다른 사람이 모두 기권했어요. ${summary}`;
if (s.endReason === 'stalemate') summary = `더 할 수 있는 행동이 없어 끝났어요. ${summary}`;
const revealedReserved: Record<PlayerId, string[]> = {};
for (const p of s.order) revealedReserved[p] = s.players[p]!.reserved.map((r) => r.cardId);
const reason: GameResult['reason'] = s.endReason === 'resign' ? (activePlayersOf(s).length === 0 ? 'abandoned' : 'resign') : 'normal';
return { ranking, winners, revealedReserved, summary, reason, endReason: s.endReason };
}
// ---------------------------------------------------------------- view helpers
function meView(s: SplendorState, me: PlayerId): SplendorMeView {
const ps = s.players[me]!;
const myTurn = s.phase !== 'finished' && s.order[s.current] === me && isActive(s, me);
const acting = myTurn && s.phase === 'action';
const buy: Record<string, BuyOption> = {};
if (isActive(s, me) && s.phase !== 'finished') {
const bonuses = bonusesOf(ps);
for (const b of buyables(s, me)) {
buy[buyKey(b.from)] = { payment: b.payment, need: needFor(b.card, bonuses), gold: b.payment.gold, total: sumTokens(b.payment) };
}
}
const rule = differentRule(s);
const nobleNeed: Record<string, number> = {};
if (s.options.showHints) {
const bonuses = bonusesOf(ps);
for (const id of s.nobles) nobleNeed[id] = COLORS.reduce((a, c) => a + Math.max(0, nobleOf(id).req[c] - bonuses[c]), 0);
}
return {
id: me,
myTurn,
takeDifferent: acting && rule.count > 0 ? rule : null,
takeSame: acting ? COLORS.filter((c) => s.supply[c] >= 4) : [],
canReserve: acting && ps.reserved.length < MAX_RESERVED && reserveTargets(s, me).length > 0,
reserveDecks: acting && ps.reserved.length < MAX_RESERVED ? TIERS.filter((t) => s.decks[t].length > 0) : [],
goldOnReserve: s.supply.gold > 0,
buy,
canPass: acting && !canAct(s, me),
discardCount: myTurn && s.phase === 'discard' ? tokenCount(ps) - TOKEN_LIMIT : 0,
nobleChoices: myTurn && s.phase === 'noble' ? s.pendingNobleChoices : null,
nobleNeed,
};
}
// ---------------------------------------------------------------- definition
export const splendor: GameDefinition<SplendorState, SplendorAction, SplendorView, SplendorOptions> = {
id: 'splendor',
nameKo: '보석 상인',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: splendorOptionsSchema.parse({}),
optionsSchema: splendorOptionsSchema,
actionSchema: splendorActionSchema as z.ZodType<SplendorAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error('splendor: 2~4 players');
const order = rng.shuffle(players);
const nobles = rng.shuffle(NOBLES.map((n) => n.id)).slice(0, players.length + 1);
const decks = {} as Record<Tier, string[]>;
const board = {} as Record<Tier, (string | null)[]>;
for (const tier of TIERS) {
const deck = rng.shuffle(CARDS.filter((c) => c.tier === tier).map((c) => c.id));
board[tier] = deck.splice(0, BOARD_SLOTS);
decks[tier] = deck;
}
const per = tokensPerColor(players.length);
const supply: Tokens = { W: per, U: per, G: per, R: per, K: per, gold: 5 };
const ps: Record<PlayerId, PlayerState> = {};
for (const p of players) ps[p] = { tokens: emptyTokens(), cards: [], reserved: [], nobles: [] };
const s: SplendorState = {
options,
order,
current: 0,
phase: 'action',
endTriggered: false,
endReason: null,
supply,
decks,
board,
nobles,
players: ps,
pendingNobleChoices: null,
turnsTaken: Object.fromEntries(players.map((p) => [p, 0])),
turnSeq: 1,
deadlineAt: null,
conceded: [],
removed: [],
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
passStreak: 0,
logSeq: 0,
log: [],
};
s.deadlineAt = deadlineFor(s, order[0]!, now);
return s;
},
validate(s, actor, a): Validation {
const ps = s.players[actor];
if (!ps) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (s.conceded.includes(actor)) return fail('이미 기권했어요.');
if (a.type === 'CONCEDE') return ok;
if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.');
if (a.type === 'TIMEOUT') return ok;
if (a.type === 'DISCARD') {
if (s.phase !== 'discard') return fail('지금은 보석을 돌려놓을 때가 아니에요.');
for (const c of TOKEN_COLORS) {
const v = a.tokens[c];
if (!Number.isInteger(v) || v < 0) return fail('잘못된 요청이에요.');
if (v > ps.tokens[c]) return fail('가진 것보다 많이 돌려놓을 수 없어요.');
}
const extra = tokenCount(ps) - TOKEN_LIMIT;
if (sumTokens(a.tokens) !== extra) return fail(`보석이 10개가 되도록 ${extra}개를 골라 주세요.`);
return ok;
}
if (a.type === 'CHOOSE_NOBLE') {
if (s.phase !== 'noble') return fail('지금은 귀족을 고를 때가 아니에요.');
return s.pendingNobleChoices?.includes(a.nobleId) ? ok : fail('고를 수 없는 귀족이에요.');
}
if (s.phase === 'discard') return fail('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
if (s.phase === 'noble') return fail('먼저 방문할 귀족을 골라 주세요.');
switch (a.type) {
case 'TAKE_DIFFERENT': {
const colors = a.colors;
const rule = differentRule(s);
if (!Array.isArray(colors) || new Set(colors).size !== colors.length) return fail('서로 다른 색 3개를 골라 주세요.');
if (colors.some((c) => !COLORS.includes(c))) return fail('서로 다른 색 3개를 골라 주세요.');
if (colors.some((c) => s.supply[c] < 1)) return fail('남아 있지 않은 색이 있어요.');
if (rule.count === 0) return fail('서로 다른 색 3개를 가져올 수 없어요.');
if (colors.length !== rule.count) return fail(rule.count === 3 ? '서로 다른 색 3개를 골라 주세요.' : `남은 색이 ${rule.count}가지라 ${rule.count}개를 골라 주세요.`);
return ok;
}
case 'TAKE_SAME':
if (!COLORS.includes(a.color)) return fail('잘못된 요청이에요.');
return s.supply[a.color] >= 4 ? ok : fail('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
case 'RESERVE': {
if (ps.reserved.length >= MAX_RESERVED) return fail('예약은 3장까지만 할 수 있어요.');
if ('deck' in a.from) return s.decks[a.from.tier]?.length ? ok : fail('그 단계 더미에 카드가 없어요.');
return s.board[a.from.tier]?.[a.from.slot] ? ok : fail('그 자리에 카드가 없어요.');
}
case 'BUY': {
const at = cardAt(s, actor, a.from);
if (!at) return fail('그 자리에 카드가 없어요.');
const card = cardOf(at.id);
if (a.payment) {
const err = paymentProblem(card, ps, a.payment);
return err ? fail(err) : ok;
}
return autoPayment(card, ps) ? ok : fail('보석이 부족해요.');
}
case 'PASS':
return canAct(s, actor) ? fail('할 수 있는 행동이 있어요.') : ok;
}
},
apply(prev, actor, a, { now }) {
// The state is plain JSON by contract; this is much faster than structuredClone.
const s = JSON.parse(JSON.stringify(prev)) as SplendorState;
const events: GameEvent[] = [];
if (a.type !== 'TIMEOUT' && a.type !== 'CONCEDE') s.timeoutsInRow[actor] = 0;
switch (a.type) {
case 'CONCEDE':
concede(s, actor, now, events);
break;
case 'TIMEOUT': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeoutAuto', player: actor });
if (s.timeoutsInRow[actor] === AWAY_AFTER) pushLog(s, { k: 'away', p: actor });
autoTurn(s, actor, now, events);
break;
}
case 'DISCARD':
if (s.phase !== 'discard') throw new Error('not in discard phase');
doDiscard(s, actor, a.tokens, false, events);
continueTurn(s, actor, now, events, true);
break;
case 'CHOOSE_NOBLE':
if (s.phase !== 'noble' || !s.pendingNobleChoices?.includes(a.nobleId)) throw new Error('illegal noble');
visitNoble(s, actor, a.nobleId, events);
endTurn(s, actor, now, events);
break;
default:
if (s.phase !== 'action') throw new Error('not in action phase');
doPlay(s, actor, a, false, events);
continueTurn(s, actor, now, events);
}
return { state: s, events };
},
view(s, viewer) {
const finished = s.phase === 'finished';
const me = viewer && s.players[viewer] ? viewer : null;
const board = {} as Record<Tier, (DevCard | null)[]>;
const deckCounts = {} as Record<Tier, number>;
for (const t of TIERS) {
board[t] = s.board[t].map((id) => (id ? cardOf(id) : null));
deckCounts[t] = s.decks[t].length;
}
const players: Record<PlayerId, SplendorPlayerView> = {};
for (const p of s.order) {
const ps = s.players[p]!;
players[p] = {
id: p,
tokens: ps.tokens,
tokenCount: tokenCount(ps),
bonuses: bonusesOf(ps),
cards: ps.cards,
points: pointsOf(ps),
nobles: ps.nobles,
reservedCount: ps.reserved.length,
reserved: ps.reserved.map((r): ReservedView => {
const card = cardOf(r.cardId);
return r.blind && p !== me && !finished ? { hidden: true, tier: card.tier } : { ...card, hidden: false, blind: r.blind };
}),
conceded: s.conceded.includes(p),
away: (s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER,
turnsTaken: s.turnsTaken[p] ?? 0,
};
}
const cur = currentPlayer(s);
const away = cur ? (s.timeoutsInRow[cur] ?? 0) >= AWAY_AFTER : false;
const base = s.options.turnSeconds === null ? 0 : away ? AWAY_MS : s.options.turnSeconds * 1000;
return {
options: s.options,
phase: s.phase,
order: s.order,
current: cur,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
turnMs: base + (s.phase === 'discard' || s.phase === 'noble' ? EXTRA_PHASE_MS : 0),
endTriggered: s.endTriggered,
supply: s.supply,
board,
deckCounts,
nobles: s.nobles.map(nobleOf),
players,
me: me ? meView(s, me) : null,
log: s.log,
result: finished ? buildResult(s) : null,
};
},
activePlayers(s) {
const cur = currentPlayer(s);
return cur ? [cur] : [];
},
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
if (s.phase === 'finished' || !s.players[player] || s.conceded.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.points;
return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason };
},
/**
* Test helper. Buying is preferred whenever possible (with a manual all-gold variant), so random games
* make progress; otherwise every take / reserve, or PASS when nothing is possible. Concede is left out.
*/
legalActions(s, p) {
if (s.phase === 'finished' || s.order[s.current] !== p || s.conceded.includes(p)) return [];
const ps = s.players[p]!;
if (s.phase === 'noble') return (s.pendingNobleChoices ?? []).map((nobleId) => ({ type: 'CHOOSE_NOBLE', nobleId }));
if (s.phase === 'discard') return discardOptions(ps.tokens, tokenCount(ps) - TOKEN_LIMIT).map((tokens) => ({ type: 'DISCARD', tokens }));
const out: SplendorAction[] = [];
const buys = buyables(s, p);
const bonuses = bonusesOf(ps);
for (const b of buys) {
out.push({ type: 'BUY', from: b.from });
// Same card paid with as much gold as possible (manual payment path).
const need = needFor(b.card, bonuses);
const manual = { ...b.payment };
for (const c of COLORS) {
const swap = Math.min(manual[c], ps.tokens.gold - manual.gold);
manual[c] -= swap;
manual.gold += swap;
}
if (manual.gold !== b.payment.gold && COLORS.every((c) => manual[c] <= need[c])) out.push({ type: 'BUY', from: b.from, payment: manual });
}
if (out.length && s.turnSeq % 6 !== 0) return out;
const rule = differentRule(s);
if (rule.count > 0) for (const colors of combos(rule.available, rule.count)) out.push({ type: 'TAKE_DIFFERENT', colors });
for (const c of COLORS) if (s.supply[c] >= 4) out.push({ type: 'TAKE_SAME', color: c });
for (const from of reserveTargets(s, p)) out.push({ type: 'RESERVE', from });
if (!out.length) out.push({ type: 'PASS' });
return out;
},
/** Hidden from others: my blind reservations; hidden from everyone: deck contents, unused nobles. */
secretsOf(s, player) {
const out: string[] = [];
if (s.phase !== 'finished') for (const r of s.players[player]?.reserved ?? []) if (r.blind) out.push(r.cardId);
for (const r of s.removed) if (r.blind) out.push(r.cardId);
for (const t of TIERS) out.push(...s.decks[t]);
const seen = new Set([...s.nobles, ...Object.values(s.players).flatMap((x) => x.nobles)]);
for (const n of NOBLES) if (!seen.has(n.id)) out.push(n.id);
return out;
},
};
function combos<T>(arr: readonly T[], k: number): T[][] {
if (k === 0) return [[]];
const out: T[][] = [];
arr.forEach((x, i) => {
for (const rest of combos(arr.slice(i + 1), k - 1)) out.push([x, ...rest]);
});
return out;
}
/** Every way to give back `k` tokens from `held`. */
function discardOptions(held: Tokens, k: number): Tokens[] {
const out: Tokens[] = [];
const cur = emptyTokens();
const go = (i: number, left: number) => {
if (left === 0) return void out.push({ ...cur });
if (i >= TOKEN_COLORS.length) return;
const c = TOKEN_COLORS[i]!;
for (let v = Math.min(left, held[c]); v >= 0; v--) {
cur[c] = v;
go(i + 1, left - v);
}
cur[c] = 0;
};
go(0, k);
return out;
}
/** Component totals for invariants: tokens per colour and cards / nobles accounted for. */
export function componentCounts(s: SplendorState): { tokens: Tokens; cards: number; nobles: number } {
const tokens = { ...s.supply };
let cards = s.removed.length;
for (const t of TIERS) cards += s.decks[t].length + s.board[t].filter(Boolean).length;
let nobles = s.nobles.length;
for (const p of s.order) {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) tokens[c] += ps.tokens[c];
cards += ps.cards.length + ps.reserved.length;
nobles += ps.nobles.length;
}
return { tokens, cards, nobles };
}

View File

@@ -0,0 +1,135 @@
/** Random playouts for 보석 상인 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index';
const CONFIGS: Partial<SplendorOptions>[] = [
{},
{ targetPoints: 10 },
{ allowFewerTokens: false },
{ turnSeconds: null, showHints: false },
{ turnSeconds: 30, targetPoints: 10, allowFewerTokens: false },
];
function conserved(state: unknown): void {
const s = state as SplendorState;
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
const c = componentCounts(s);
const want: Tokens = { W: n, U: n, G: n, R: n, K: n, gold: 5 };
for (const k of [...COLORS, 'gold'] as const) if (c.tokens[k] !== want[k]) throw new Error(`token ${k}: ${c.tokens[k]} != ${want[k]}`);
if (c.cards !== 90) throw new Error(`cards: ${c.cards}`);
if (c.nobles !== s.order.length + 1) throw new Error(`nobles: ${c.nobles}`);
for (const p of s.order) {
const ps = s.players[p]!;
if (ps.reserved.length > 3) throw new Error('more than 3 reservations');
for (const k of [...COLORS, 'gold'] as const) if (ps.tokens[k] < 0) throw new Error('negative tokens');
const total = Object.values(ps.tokens).reduce((a, b) => a + b, 0);
if (total > 10 && !(s.phase === 'discard' && s.order[s.current] === p)) throw new Error(`${p} holds ${total} tokens outside the discard phase`);
}
for (const t of TIERS) if (s.board[t].length !== 4) throw new Error('board must keep 4 slots');
}
const ID_RE = /"(?:[123][WUGRK]\d\d|N\d\d)"/g;
/** Who may see each hidden id: '*' = nobody, otherwise the owner of a blind reservation. */
function hiddenOwners(s: SplendorState): Map<string, string> {
const m = new Map<string, string>();
for (const t of TIERS) for (const id of s.decks[t]) m.set(id, '*');
for (const r of s.removed) if (r.blind) m.set(r.cardId, '*');
const seen = new Set([...s.nobles, ...s.order.flatMap((p) => s.players[p]!.nobles)]);
for (const n of NOBLES) if (!seen.has(n.id)) m.set(n.id, '*');
if (s.phase !== 'finished') for (const p of s.order) for (const r of s.players[p]!.reserved) if (r.blind) m.set(r.cardId, p);
return m;
}
function run(seed: number): { state: SplendorState; steps: number } {
const options = splendor.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 + 700_000));
let now = 1_700_000_000_000;
let s = splendor.setup({ players, options, rng, now });
const concedeAt = seed % 13 === 0 ? 30 + (seed % 40) : -1;
for (let step = 0; step < 2000; 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);
const owners = hiddenOwners(s);
for (const viewer of [...players, null]) {
const ids = JSON.stringify(splendor.view(s, viewer)).match(ID_RE) ?? [];
for (const q of ids) {
const o = owners.get(q.slice(1, -1));
if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} (${o})`);
}
}
if (splendor.result(s)) return { state: s, steps: step };
const waiting = splendor.activePlayers(s);
if (waiting.length !== 1) throw new Error('exactly one player should be waited for');
for (const p of waiting) {
const v = splendor.validate(s, p, splendor.onTimeout(s, p));
if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`);
}
let actor = waiting[0]!;
let a: SplendorAction;
if (step === concedeAt) {
const inGame = players.filter((p) => !s.conceded.includes(p));
actor = inGame[chooser.int(inGame.length)]!;
a = splendor.onLeave!(s, actor)!;
} else {
const legal = splendor.legalActions!(s, actor);
a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : splendor.onTimeout(s, actor);
}
const v = splendor.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 = splendor.apply(s, actor, a, { rng, now });
const ms = performance.now() - t0;
if (ms > 20) throw new Error(`apply took ${ms.toFixed(1)}ms`);
const after = hiddenOwners(out.state);
for (const q of JSON.stringify(out.events).match(ID_RE) ?? []) if (after.has(q.slice(1, -1))) throw new Error(`seed ${seed}: event leaks ${q}`);
s = out.state;
}
throw new Error(`seed ${seed}: not finished within 2000 steps`);
}
describe('random playouts (docs/09 §7)', () => {
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, invariants, determinism)', () => {
for (let i = 0; i < 8; i++) {
const options = splendor.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
const a = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, invariant: conserved, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i < 2) {
const b = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, maxApplyMs: 20 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => {
const ends: Record<string, number> = {};
let maxSteps = 0;
for (let seed = 1; seed <= 1000; seed++) {
const { state, steps } = run(seed);
maxSteps = Math.max(maxSteps, steps);
const res = splendor.result(state)!;
expect(res.ranking.flat().sort()).toEqual([...state.order].sort());
expect(res.summary.length).toBeGreaterThan(0);
const k = state.endReason ?? 'none';
ends[k] = (ends[k] ?? 0) + 1;
if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state));
if (state.endReason === 'target') {
// Everyone in the game had the same number of turns (the round was played to its end).
const inGame = state.order.filter((p: PlayerId) => !state.conceded.includes(p));
const turns = new Set(inGame.map((p) => state.turnsTaken[p]));
if (!state.conceded.length) expect(turns.size).toBe(1);
}
}
expect(ends.target ?? 0).toBeGreaterThan(900);
expect(ends.resign ?? 0).toBeGreaterThan(0);
expect(maxSteps).toBeLessThan(2000);
}, 60_000);
});

View File

@@ -0,0 +1,753 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SeededRng, seedN, type GameEvent, type PlayerId } from '@bg/engine';
import {
CARDS,
COLORS,
NOBLES,
TIERS,
cardOf,
componentCounts,
emptyTokens,
pointsOf,
splendor,
splendorActionSchema,
type Color,
type SplendorAction,
type SplendorOptions,
type SplendorState,
type Tier,
type Tokens,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000_000;
const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]);
function mk(players = ['A', 'B'], opts: Partial<SplendorOptions> = {}, seed = 1): SplendorState {
const options = splendor.optionsSchema.parse(opts);
return splendor.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
}
/** Fixes the turn order (setup shuffles it) and gives the turn to the first player. */
function inOrder(s: SplendorState, order: PlayerId[]): SplendorState {
s.order = [...order];
s.current = 0;
return s;
}
function act(s: SplendorState, who: PlayerId, a: SplendorAction): SplendorState {
return actE(s, who, a).state;
}
function actE(s: SplendorState, who: PlayerId, a: SplendorAction): { state: SplendorState; events: GameEvent[] } {
const v = splendor.validate(s, who, a);
if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`);
clock += 1000;
const out = splendor.apply(s, who, a, { rng: RNG(), now: clock });
conserved(out.state);
return out;
}
function reason(s: SplendorState, who: PlayerId, a: SplendorAction): string {
const v = splendor.validate(s, who, a);
if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`);
return v.reason;
}
const cur = (s: SplendorState) => s.order[s.current]!;
const tk = (t: Partial<Tokens>): Tokens => ({ ...emptyTokens(), ...t });
/** Removes a card from wherever it is (deck or board, refilling the board slot from the deck). */
function pull(s: SplendorState, id: string): void {
const tier = cardOf(id).tier;
const di = s.decks[tier].indexOf(id);
if (di >= 0) return void s.decks[tier].splice(di, 1);
const bi = s.board[tier].indexOf(id);
if (bi < 0) throw new Error(`card ${id} not available`);
s.board[tier][bi] = s.decks[tier].shift() ?? null;
}
function giveCards(s: SplendorState, p: PlayerId, ids: string[]): void {
for (const id of ids) {
pull(s, id);
s.players[p]!.cards.push(id);
}
}
/** Moves tokens supply → player (keeps the totals). */
function giveTokens(s: SplendorState, p: PlayerId, t: Partial<Tokens>): void {
for (const [c, n] of Object.entries(t) as [keyof Tokens, number][]) {
if (s.supply[c] < n) throw new Error(`supply short of ${c}`);
s.supply[c] -= n;
s.players[p]!.tokens[c] += n;
}
}
/** Puts card `id` face up at board slot (tier, slot); the card that was there goes back to the deck top. */
function place(s: SplendorState, id: string, slot = 0): void {
const tier = cardOf(id).tier;
pull(s, id);
const old = s.board[tier][slot];
if (old) s.decks[tier].unshift(old);
s.board[tier][slot] = id;
}
/** Moves supply tokens of one colour away to a bystander so the supply shows `n`. */
function setSupply(s: SplendorState, c: Color | 'gold', n: number, holder: PlayerId): void {
const d = s.supply[c] - n;
if (d < 0) throw new Error('can only lower the supply');
s.supply[c] = n;
s.players[holder]!.tokens[c] += d;
}
function initialTokens(s: SplendorState): Tokens {
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
return { W: n, U: n, G: n, R: n, K: n, gold: 5 };
}
function conserved(s: SplendorState): void {
const c = componentCounts(s);
expect(c.tokens).toEqual(initialTokens(s));
expect(c.cards).toBe(90);
expect(c.nobles).toBe(s.order.length + 1);
for (const p of s.order) for (const k of [...COLORS, 'gold'] as const) expect(s.players[p]!.tokens[k]).toBeGreaterThanOrEqual(0);
}
// ---------------------------------------------------------------- setup & data
describe('셋업 (§4)', () => {
test.each([
[2, 4, 3, 36, 26, 16],
[3, 5, 4, 36, 26, 16],
[4, 7, 5, 36, 26, 16],
])('%i players: %i tokens per colour, gold 5, %i nobles, 12 open cards, decks %i/%i/%i', (n, per, nobles, d1, d2, d3) => {
const s = mk(['A', 'B', 'C', 'D'].slice(0, n));
expect(s.supply).toEqual({ W: per, U: per, G: per, R: per, K: per, gold: 5 });
expect(s.nobles).toHaveLength(nobles);
expect(TIERS.map((t) => s.board[t].filter(Boolean).length)).toEqual([4, 4, 4]);
expect([s.decks[1].length, s.decks[2].length, s.decks[3].length]).toEqual([d1, d2, d3]);
expect([...s.order].sort()).toEqual(['A', 'B', 'C', 'D'].slice(0, n));
expect(s.phase).toBe('action');
expect(s.deadlineAt).toBe(clock + 60_000);
conserved(s);
});
test('turn order, nobles and decks come from the seeded RNG', () => {
const a = mk(['A', 'B', 'C', 'D'], {}, 3);
const b = mk(['A', 'B', 'C', 'D'], {}, 3);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const differs = [4, 5, 6, 7].some((seed) => JSON.stringify(mk(['A', 'B', 'C', 'D'], {}, seed)) !== JSON.stringify(a));
expect(differs).toBe(true);
});
test('player count is limited to 2~4', () => {
expect(() => mk(['A'])).toThrow();
expect(() => mk(['A', 'B', 'C', 'D', 'E'])).toThrow();
expect([splendor.minPlayers, splendor.maxPlayers]).toEqual([2, 4]);
});
});
describe('카드 데이터 (§3.1, §3.2)', () => {
test('90 cards: 40/30/20, 8/6/4 per colour per tier, points as in §3.1', () => {
expect(CARDS).toHaveLength(90);
expect(new Set(CARDS.map((c) => c.id)).size).toBe(90);
const per = (t: Tier) => CARDS.filter((c) => c.tier === t);
expect(TIERS.map((t) => per(t).length)).toEqual([40, 30, 20]);
for (const t of TIERS) for (const c of COLORS) expect(per(t).filter((x) => x.bonus === c)).toHaveLength([8, 6, 4][t - 1]!);
const dist = (t: Tier) => {
const d: Record<number, number> = {};
for (const c of per(t)) d[c.points] = (d[c.points] ?? 0) + 1;
return d;
};
expect(dist(1)).toEqual({ 0: 35, 1: 5 });
expect(dist(2)).toEqual({ 1: 10, 2: 15, 3: 5 });
expect(dist(3)).toEqual({ 3: 5, 4: 10, 5: 5 });
});
test('the card and noble tables match the spec document row by row (snapshot of §3.1 / §3.2)', () => {
const doc = readFileSync(join(import.meta.dir, '../../../../docs/games/splendor.md'), 'utf8');
const num = (x: string) => (x === '·' ? 0 : Number(x));
const rows = [...doc.matchAll(/^\| ([123][WUGRK]\d\d) \| . \| (\d) \| (.) \| (.) \| (.) \| (.) \| (.) \| (\d+) \|$/gm)];
expect(rows).toHaveLength(90);
for (const m of rows) {
const card = cardOf(m[1]!);
expect(card.points).toBe(Number(m[2]));
expect(card.cost).toEqual({ W: num(m[3]!), U: num(m[4]!), G: num(m[5]!), R: num(m[6]!), K: num(m[7]!) });
expect(COLORS.reduce((a, c) => a + card.cost[c], 0)).toBe(Number(m[8]));
}
const nobles = [...doc.matchAll(/^\| (N\d\d) \| (.) \| (.) \| (.) \| (.) \| (.) \|$/gm)];
expect(nobles).toHaveLength(10);
expect(NOBLES).toHaveLength(10);
for (const m of nobles) {
const n = NOBLES.find((x) => x.id === m[1])!;
expect(n.points).toBe(3);
expect(n.req).toEqual({ W: num(m[2]!), U: num(m[3]!), G: num(m[4]!), R: num(m[5]!), K: num(m[6]!) });
}
});
});
// ---------------------------------------------------------------- taking tokens
describe('보석 가져오기 (§5)', () => {
test('TAKE_SAME: allowed with 4 in the supply, refused with 3', () => {
const s = inOrder(mk(), ['A', 'B']);
const s2 = act(s, 'A', { type: 'TAKE_SAME', color: 'R' });
expect(s2.players.A!.tokens.R).toBe(2);
expect(s2.supply.R).toBe(2);
const t = inOrder(mk(), ['A', 'B']);
setSupply(t, 'R', 3, 'B');
expect(reason(t, 'A', { type: 'TAKE_SAME', color: 'R' })).toBe('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
});
test('TAKE_DIFFERENT: duplicates and gold are refused', () => {
const s = inOrder(mk(), ['A', 'B']);
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
expect(splendorActionSchema.safeParse({ type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold'] }).success).toBe(false);
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold' as Color] })).toBe('서로 다른 색 3개를 골라 주세요.');
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
expect(s2.players.A!.tokens).toEqual(tk({ W: 1, U: 1, K: 1 }));
});
test('only 2 colours left: 2 allowed with allowFewerTokens, refused without; 3 left but only 2 taken → refused', () => {
const s = inOrder(mk(), ['A', 'B']);
setSupply(s, 'W', 0, 'B');
setSupply(s, 'U', 0, 'B');
setSupply(s, 'G', 0, 'B');
expect(act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] }).players.A!.tokens).toEqual(tk({ R: 1, K: 1 }));
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R'] })).toBe('남은 색이 2가지라 2개를 골라 주세요.');
expect(splendor.view(s, 'A').me!.takeDifferent).toEqual({ available: ['R', 'K'], count: 2 });
const strict = inOrder(mk(['A', 'B'], { allowFewerTokens: false }), ['A', 'B']);
for (const c of ['W', 'U', 'G'] as const) setSupply(strict, c, 0, 'B');
expect(reason(strict, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 가져올 수 없어요.');
expect(splendor.view(strict, 'A').me!.takeDifferent).toBeNull();
const three = inOrder(mk(), ['A', 'B']);
setSupply(three, 'W', 0, 'B');
setSupply(three, 'U', 0, 'B');
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 골라 주세요.');
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'R', 'K'] })).toBe('남아 있지 않은 색이 있어요.');
});
test('only the current player may act', () => {
const s = inOrder(mk(), ['A', 'B']);
expect(reason(s, 'B', { type: 'TAKE_SAME', color: 'W' })).toBe('지금은 내 차례가 아니에요.');
expect(reason(s, 'Z', { type: 'TAKE_SAME', color: 'W' })).toBe('이 게임의 플레이어가 아니에요.');
});
});
// ---------------------------------------------------------------- reserving
describe('예약 (§5)', () => {
test('reserving an open card gives a gold token and refills the slot from the deck', () => {
const s = inOrder(mk(), ['A', 'B']);
const id = s.board[2][1]!;
const top = s.decks[2][0]!;
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 1 } });
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: false }]);
expect(state.players.A!.tokens.gold).toBe(1);
expect(state.board[2][1]).toBe(top);
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ cardId: id, gold: true });
expect(events.find((e) => e.type === 'refilled')).toMatchObject({ tier: 2, slot: 1, cardId: top });
});
test('a fourth reservation is refused; with no gold left reserving still works (without gold)', () => {
let s = inOrder(mk(), ['A', 'B']);
for (let i = 0; i < 3; i++) {
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
if (s.phase === 'discard') s = act(s, 'B', { type: 'TIMEOUT' });
}
expect(s.players.A!.reserved).toHaveLength(3);
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } })).toBe('예약은 3장까지만 할 수 있어요.');
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('예약은 3장까지만 할 수 있어요.');
const t = inOrder(mk(), ['A', 'B']);
setSupply(t, 'gold', 0, 'B');
const t2 = act(t, 'A', { type: 'RESERVE', from: { tier: 3, slot: 0 } });
expect(t2.players.A!.reserved).toHaveLength(1);
expect(t2.players.A!.tokens.gold).toBe(0);
expect(splendor.view(t, 'A').me!.goldOnReserve).toBe(false);
});
test('a blind deck reservation: only the owner sees the card; others and spectators get the tier only; events carry no id', () => {
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
const id = s.decks[3][0]!;
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } });
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: true }]);
expect(state.board[3]).toEqual(s.board[3]);
expect(splendor.view(state, 'A').players.A!.reserved[0]).toMatchObject({ id, hidden: false, blind: true });
for (const viewer of ['B', 'C', null]) {
const v = splendor.view(state, viewer);
expect(v.players.A!.reserved).toEqual([{ hidden: true, tier: 3 }]);
expect(v.players.A!.reservedCount).toBe(1);
expect(JSON.stringify(v)).not.toContain(`"${id}"`);
}
expect(JSON.stringify(events)).not.toContain(`"${id}"`);
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ tier: 3, cardId: null });
});
});
// ---------------------------------------------------------------- buying
/** §5.1 position: bonuses W2 U0 G1 R0 K1, tokens W0 U2 G1 R0 K0 gold 1. */
function example(): SplendorState {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveCards(s, 'A', ['1W07', '1W06', '1G07', '1K07']); // bonuses W2 G1 K1, 0 points
giveTokens(s, 'A', { U: 2, G: 1, gold: 1 });
return s;
}
describe('구매 (§5, §5.1)', () => {
test('example 1: 2W04 (R5 K3) needs 7 tokens → cannot buy', () => {
const s = example();
place(s, '2W04', 0);
expect(reason(s, 'A', { type: 'BUY', from: { tier: 2, slot: 0 } })).toBe('보석이 부족해요.');
expect(splendor.view(s, 'A').me!.buy['b2-0']).toBeUndefined();
});
test('example 2: 1U05 (W1 K2) → W covered by bonus, K 1 paid with gold; gold 0 afterwards', () => {
const s = example();
place(s, '1U05', 2);
const v = splendor.view(s, 'A').me!;
expect(v.buy['b1-2']).toMatchObject({ gold: 1, total: 1, need: { W: 0, U: 0, G: 0, R: 0, K: 1 } });
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 2 } });
expect(state.players.A!.tokens.gold).toBe(0);
expect(state.players.A!.cards).toContain('1U05');
expect(state.supply.gold).toBe(5);
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: '1U05', paid: { gold: 1 } });
});
test('bonuses that cover the whole cost: the card is free', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveCards(s, 'A', ['1U07', '1U06', '1U01']); // three blue bonuses
place(s, '1W07', 0); // cost U3
const s2 = act(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
expect(s2.players.A!.tokens).toEqual(emptyTokens());
expect(s2.players.A!.cards).toContain('1W07');
});
test('manual payment: gold instead of colour tokens is allowed; paying more than needed is refused', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveTokens(s, 'A', { R: 3, gold: 2 });
place(s, '1G07', 1); // cost R3
const slot = { tier: 1 as const, slot: 1 };
const s2 = act(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, gold: 2 }) });
expect(s2.players.A!.tokens).toEqual(tk({ R: 2 }));
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 3, gold: 1 }) })).toBe('필요한 것보다 많이 낼 수는 없어요.');
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 2 }) })).toBe('보석이 부족해요.');
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, U: 1, gold: 1 }) })).toBe('보석이 부족해요.');
});
test('buying a reserved card reveals it; the board is not refilled', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
pull(s, '1R07'); // cost W3
s.decks[1].unshift('1R07');
const id = '1R07';
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
s = act(s, 'B', { type: 'TAKE_SAME', color: 'G' });
giveTokens(s, 'A', { W: 3 });
const board = JSON.stringify(s.board);
const { state, events } = actE(s, 'A', { type: 'BUY', from: { reservedIndex: 0 } });
expect(state.players.A!.cards).toEqual([id]);
expect(state.players.A!.reserved).toEqual([]);
expect(JSON.stringify(state.board)).toBe(board);
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: id, fromReserve: true });
});
});
// ---------------------------------------------------------------- token limit
describe('토큰 한도 (§5)', () => {
test('9 tokens + 3 → discard phase; anything but exactly 10 left is refused', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
expect(s2.phase).toBe('discard');
expect(cur(s2)).toBe('A');
expect(splendor.view(s2, 'A').me!.discardCount).toBe(2);
expect(reason(s2, 'A', { type: 'TAKE_SAME', color: 'U' })).toBe('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 1 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 3 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ gold: 2 }) })).toBe('가진 것보다 많이 돌려놓을 수 없어요.');
// A token taken just now may go back.
const s3 = act(s2, 'A', { type: 'DISCARD', tokens: tk({ R: 1, K: 1 }) });
expect(s3.players.A!.tokens).toEqual(tk({ W: 4, U: 3, G: 3 }));
expect(cur(s3)).toBe('B');
expect(s3.phase).toBe('action');
});
});
// ---------------------------------------------------------------- nobles
describe('귀족 (§5)', () => {
test('two nobles satisfied at once → choose one; the other comes automatically at the end of the next turn', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
s.nobles = ['N06', 'N07', 'N10']; // U3 G3 R3 / W3 U3 G3 / G3 R3 K3
// Keep the noble total (3 open for 2 players): swap in from whatever setup chose.
giveCards(s, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06']); // W3 U3 G2
place(s, '1G05', 0); // cost W2 U1 → free with bonuses; makes G3 → N07 (W3 U3 G3)
giveCards(s, 'A', ['1R07', '1R06', '1R05']); // R3 → with U3 G3 also N06
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
expect(events.some((e) => e.type === 'nobleVisited')).toBe(false);
expect(state.phase).toBe('noble');
expect(state.pendingNobleChoices).toEqual(['N06', 'N07']);
expect(splendor.view(state, 'A').me!.nobleChoices).toEqual(['N06', 'N07']);
expect(reason(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N10' })).toBe('고를 수 없는 귀족이에요.');
s = act(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N07' });
expect(s.players.A!.nobles).toEqual(['N07']);
expect(pointsOf(s.players.A!)).toBe(3);
expect(cur(s)).toBe('B');
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
const out = actE(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
expect(out.state.players.A!.nobles).toEqual(['N07', 'N06']);
expect(out.events.find((e) => e.type === 'nobleVisited')).toMatchObject({ player: 'A', nobleId: 'N06' });
expect(out.state.nobles).toEqual(['N10']);
});
test('hints: bonuses still missing per noble (only with showHints)', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
s.nobles = ['N01', 'N06', 'N10'];
giveCards(s, 'A', ['1G07', '1R07']);
expect(splendor.view(s, 'A').me!.nobleNeed).toEqual({ N01: 6, N06: 7, N10: 7 });
const off = inOrder(mk(['A', 'B'], { showHints: false }), ['A', 'B']);
expect(splendor.view(off, 'A').me!.nobleNeed).toEqual({});
});
});
// ---------------------------------------------------------------- end & ranking
describe('종료와 순위 (§6)', () => {
test('the 2nd player reaches 15 → the 3rd and 4th still play, then the game ends', () => {
let s = inOrder(mk(['A', 'B', 'C', 'D']), ['A', 'B', 'C', 'D']);
giveCards(s, 'B', ['3W04', '3U04', '3G04']); // 15 points, bonuses W1 U1 G1
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
const out = actE(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
expect(out.events.find((e) => e.type === 'endTriggered')).toMatchObject({ player: 'B', points: 15 });
s = out.state;
expect(s.endTriggered).toBe(true);
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
expect(s.phase).toBe('action');
expect(cur(s)).toBe('D');
s = act(s, 'D', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
expect(s.phase).toBe('finished');
const r = splendor.result(s)!;
expect(r.ranking[0]).toEqual(['B']);
expect(r.reason).toBe('normal');
expect(r.summary).toBe('1위 15점 (카드 3장) · 2위 0점');
});
test('the player just before the first player reaches 15 → the game ends at once', () => {
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
giveCards(s, 'C', ['3W04', '3U04', '3G04']);
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
expect(s.phase).toBe('finished');
expect(s.turnsTaken).toEqual({ A: 1, B: 1, C: 1 });
});
test('targetPoints=10 ends at 10 points', () => {
let s = inOrder(mk(['A', 'B'], { targetPoints: 10 }), ['A', 'B']);
giveCards(s, 'B', ['3W04', '3U04']);
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
expect(s.phase).toBe('finished');
});
test('ties: same points → fewer cards wins; same points and cards → shared rank (A 16/14, B 16/12, C 15)', () => {
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
const zeros = CARDS.filter((c) => c.tier === 1 && c.points === 0).map((c) => c.id);
giveCards(s, 'A', ['3W04', '3U04', '3G04', '1W08', ...zeros.slice(0, 10)]);
giveCards(s, 'B', ['3R04', '3K04', '3W03', '1U08', '1G08', ...zeros.slice(10, 17)]);
giveCards(s, 'C', ['3U03', '3G03', '3R03', '2W06']);
expect(['A', 'B', 'C'].map((p) => [pointsOf(s.players[p]!), s.players[p]!.cards.length])).toEqual([
[16, 14],
[16, 12],
[15, 4],
]);
s.phase = 'finished';
s.endReason = 'target';
expect(splendor.result(s)!.ranking).toEqual([['B'], ['A'], ['C']]);
expect(splendor.view(s, null).result!.ranking.map((x) => [x.player, x.rank])).toEqual([
['B', 1],
['A', 2],
['C', 3],
]);
giveCards(s, 'B', zeros.slice(17, 19)); // B: 16 points, 14 cards → same as A
const r = splendor.result(s)!;
expect(r.ranking).toEqual([['A', 'B'], ['C']]);
expect(r.summary).toBe('공동 1위 2명, 16점 (카드 14장)');
expect(splendor.view(s, 'A').result!.winners).toEqual(['A', 'B']);
});
});
// ---------------------------------------------------------------- pass, empty decks
describe('넘기기와 더미 소진 (§5)', () => {
/**
* Nobody can do anything: all colour tokens are held (10 each), both have 3 reservations they can't
* afford and the board is empty (its cards went back under the decks).
*/
function stuck(): SplendorState {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
const reserve = (p: PlayerId, ids: string[]) => {
for (const id of ids) {
pull(s, id);
s.players[p]!.reserved.push({ cardId: id, blind: false });
}
};
reserve('A', ['3W02', '3U02', '3G02']); // K7, W7, U7
reserve('B', ['3R02', '3K02', '3U04']); // G7, R7, W7 U3
giveTokens(s, 'A', { W: 4, U: 4, G: 2 });
giveTokens(s, 'B', { G: 2, R: 4, K: 4 });
for (const t of TIERS) {
for (const id of s.board[t]) if (id) s.decks[t].push(id);
s.board[t] = [null, null, null, null];
}
return s;
}
test('PASS is refused while any action is possible and allowed only when none is', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
expect(reason(s, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
expect(splendor.view(s, 'A').me!.canPass).toBe(false);
const t = stuck();
expect(splendor.view(t, 'A').me!.canPass).toBe(true);
expect(splendor.legalActions!(t, 'A')).toEqual([{ type: 'PASS' }]);
const t2 = act(t, 'A', { type: 'PASS' });
expect(cur(t2)).toBe('B');
expect(t2.passStreak).toBe(1);
expect(t2.phase).toBe('action');
// With one token back in the supply A could take it, so passing is refused again.
const u = stuck();
u.players.B!.tokens.K--;
u.supply.K++;
expect(reason(u, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
});
test('a full round of passes ends the game (nothing can change any more)', () => {
let s = act(stuck(), 'A', { type: 'PASS' });
expect(splendor.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' });
s = act(s, 'B', { type: 'TIMEOUT' }); // automatic move is a pass as well
expect(s.phase).toBe('finished');
expect(s.endReason).toBe('stalemate');
expect(splendor.result(s)!.summary).toMatch(/^더 할 수 있는 행동이 없어 끝났어요\./);
});
test('empty deck: the board slot stays empty after a buy, and reserving from the empty deck is refused', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
// Move the rest of the tier-3 deck out of reach: B "owns" them as bought cards (keeps the 90 total).
s.players.B!.cards.push(...s.decks[3].splice(0));
const id = s.board[3][2]!;
giveTokens(s, 'A', { gold: 5 });
const need = COLORS.reduce((a, c) => a + cardOf(id).cost[c], 0);
giveTokens(s, 'A', Object.fromEntries(COLORS.map((c) => [c, Math.min(cardOf(id).cost[c], s.supply[c])])) as Partial<Tokens>);
expect(need).toBeGreaterThan(0);
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('그 단계 더미에 카드가 없어요.');
expect(splendor.view(s, 'A').me!.reserveDecks).toEqual([1, 2]);
s = act(s, 'A', { type: 'BUY', from: { tier: 3, slot: 2 } });
if (s.phase === 'discard') s = act(s, 'A', { type: 'TIMEOUT' });
expect(s.board[3][2]).toBeNull();
expect(splendor.view(s, null).board[3][2]).toBeNull();
expect(reason(s, 'B', { type: 'BUY', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
expect(reason(s, 'B', { type: 'RESERVE', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
});
});
// ---------------------------------------------------------------- timeouts
describe('시간 초과와 자리 비움 (§8.5)', () => {
test('TIMEOUT is system-only (not in the action schema) and legal for the current player', () => {
const s = mk();
expect(splendorActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
expect(splendor.onTimeout(s, cur(s))).toEqual({ type: 'TIMEOUT' });
expect(splendor.validate(s, cur(s), { type: 'TIMEOUT' }).ok).toBe(true);
});
test('affordable card → automatic buy (most points, then fewest tokens)', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
place(s, '1W08', 0); // G4, 1 point
place(s, '1U07', 1); // K3, 0 points
giveTokens(s, 'A', { G: 4, K: 3 });
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
expect(state.players.A!.cards).toEqual(['1W08']);
expect(events.some((e) => e.type === 'timeoutAuto')).toBe(true);
expect(state.log.at(-1)).toMatchObject({ k: 'buy', cardId: '1W08', auto: true });
});
test('nothing affordable → 3 different colours with the most supply (ties W,U,G,R,K)', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
setSupply(s, 'W', 2, 'B');
const s2 = act(s, 'A', { type: 'TIMEOUT' });
expect(s2.players.A!.tokens).toEqual(tk({ U: 1, G: 1, R: 1 }));
});
test('timeouts in discard and noble phases finish the turn so the game never stalls', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
expect(s.phase).toBe('discard');
s = act(s, 'A', { type: 'TIMEOUT' });
// Most held first (W 4), then ties K,R,G,U,W: G (3) before U (3).
expect(s.players.A!.tokens).toEqual(tk({ W: 3, U: 3, G: 2, R: 1, K: 1 }));
expect(cur(s)).toBe('B');
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
t.nobles = ['N06', 'N07', 'N10'];
giveCards(t, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06', '1G05', '1R07', '1R06', '1R05']);
t = act(t, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
expect(t.phase).toBe('noble');
t = act(t, 'A', { type: 'TIMEOUT' });
expect(t.players.A!.nobles).toEqual(['N06']); // smallest id
expect(cur(t)).toBe('B');
});
test('an automatic action that goes over 10 tokens also returns them in the same timeout', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveTokens(s, 'A', { W: 2, U: 2, G: 2, R: 2, K: 2 });
for (const t of [1, 2] as const) {
for (const id of s.board[t]) if (id) s.decks[t].push(id);
s.board[t] = [null, null, null, null]; // only tier-3 cards left: none affordable with 2 of each
}
const s2 = act(s, 'A', { type: 'TIMEOUT' });
expect(s2.log.find((e) => e.k === 'take')).toMatchObject({ auto: true });
expect(s2.log.find((e) => e.k === 'discard')).toMatchObject({ auto: true });
expect(cur(s2)).toBe('B');
expect(componentCounts(s2).tokens).toEqual(initialTokens(s2));
expect(Object.values(s2.players.A!.tokens).reduce((a, b) => a + b, 0)).toBe(10);
});
test('3 timeouts in a row → away (short deadline); acting again clears it', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
for (let i = 0; i < 3; i++) {
s = act(s, 'A', { type: 'TIMEOUT' });
s = act(s, 'B', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
}
expect(splendor.view(s, 'B').players.A!.away).toBe(true);
expect(s.deadlineAt).toBe(clock + 5000);
expect(s.log.some((e) => e.k === 'away' && e.p === 'A')).toBe(true);
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
if (s.phase === 'discard') s = act(s, 'A', { type: 'DISCARD', tokens: tk({ gold: 1 }) });
expect(splendor.view(s, 'B').players.A!.away).toBe(false);
expect(s.deadlineAt).toBe(clock + 60_000);
});
test('discard / noble phases get 15 extra seconds; no time limit → no deadline', () => {
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
const before = s.deadlineAt!;
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
expect(s2.deadlineAt).toBe(before + 15_000);
const free = mk(['A', 'B'], { turnSeconds: null });
expect(free.deadlineAt).toBeNull();
expect(splendor.deadline!(free)).toBeNull();
});
});
// ---------------------------------------------------------------- concede / leave
describe('기권 (§8.2)', () => {
test('3 players: tokens go back, reservations leave the game, cards stay for the result (ranked last)', () => {
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
giveCards(s, 'B', ['3W04']);
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
giveTokens(s, 'B', { R: 2 });
s = act(s, 'B', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
expect(splendor.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
s = act(s, 'B', { type: 'CONCEDE' });
expect(s.players.B!.tokens).toEqual(emptyTokens());
expect(s.players.B!.reserved).toEqual([]);
expect(s.players.B!.cards).toEqual(['3W04']);
expect(s.removed).toHaveLength(1);
expect(cur(s)).toBe('C');
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
expect(cur(s)).toBe('A'); // B is skipped
expect(reason(s, 'B', { type: 'CONCEDE' })).toBe('이미 기권했어요.');
expect(splendor.onLeave!(s, 'B')).toBeNull();
});
test('the current player concedes → next player; 2 players → the game ends, the other one wins', () => {
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
s = act(s, 'A', { type: 'CONCEDE' });
expect(cur(s)).toBe('B');
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
giveCards(t, 'A', ['3W04']);
t = act(t, 'A', { type: 'CONCEDE' });
expect(t.phase).toBe('finished');
const r = splendor.result(t)!;
expect(r.ranking).toEqual([['B'], ['A']]);
expect(r.reason).toBe('resign');
expect(r.summary).toMatch(/^다른 사람이 모두 기권했어요\./);
});
});
// ---------------------------------------------------------------- info hiding, determinism, options
describe('정보 공개 (§8.3)', () => {
test('views and events never contain deck contents, unused nobles, blind reservations of others or an rng', () => {
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
const allEvents: GameEvent[] = [];
const step = (who: PlayerId, a: SplendorAction) => {
const out = actE(s, who, a);
allEvents.push(...out.events);
s = out.state;
};
step('A', { type: 'RESERVE', from: { tier: 1, deck: true } });
step('B', { type: 'RESERVE', from: { tier: 2, deck: true } });
step('C', { type: 'RESERVE', from: { tier: 3, deck: true } });
const blind = { A: s.players.A!.reserved[0]!.cardId, B: s.players.B!.reserved[0]!.cardId, C: s.players.C!.reserved[0]!.cardId };
for (const viewer of ['A', 'B', 'C', null] as const) {
const json = JSON.stringify(splendor.view(s, viewer));
for (const [owner, id] of Object.entries(blind)) {
if (owner === viewer) expect(json).toContain(`"${id}"`);
else expect(json).not.toContain(`"${id}"`);
}
for (const t of TIERS) for (const id of s.decks[t]) expect(json).not.toContain(`"${id}"`);
const used = new Set(s.nobles);
for (const n of NOBLES) if (!used.has(n.id)) expect(json).not.toContain(`"${n.id}"`);
expect(json).not.toMatch(/"rng"|"decks"/);
}
const ev = JSON.stringify(allEvents);
for (const id of Object.values(blind)) expect(ev).not.toContain(`"${id}"`);
// After the game all reservations are shown.
s.phase = 'finished';
s.endReason = 'target';
const res = splendor.view(s, null);
expect(res.result!.revealedReserved).toEqual({ A: [blind.A], B: [blind.B], C: [blind.C] });
expect(res.players.A!.reserved[0]).toMatchObject({ id: blind.A, hidden: false });
});
test('secretsOf: own blind reservations + every deck card + the unused nobles', () => {
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, deck: true } });
const sec = splendor.secretsOf!(s, 'A');
expect(sec).toContain(s.players.A!.reserved[0]!.cardId);
expect(sec.filter((x) => x.startsWith('N'))).toHaveLength(7);
expect(sec.length).toBe(1 + s.decks[1].length + s.decks[2].length + s.decks[3].length + 7);
});
});
describe('결정성과 옵션', () => {
test('replay: same seed + same actions → same states and events', () => {
const play = () => {
let s = mk(['A', 'B', 'C'], {}, 42);
const log: string[] = [];
for (let i = 0; i < 60 && s.phase !== 'finished'; i++) {
const p = cur(s);
const legal = splendor.legalActions!(s, p);
const a = legal[(i * 7) % legal.length]!;
const out = splendor.apply(s, p, a, { rng: RNG(), now: 5000 + i });
log.push(JSON.stringify(out.events));
s = out.state;
}
return JSON.stringify(s) + log.join('|');
};
expect(play()).toBe(play());
});
test('every option has a Korean title (and labels for choices); defaults follow §2', () => {
expect(splendor.defaultOptions).toEqual({ targetPoints: 15, allowFewerTokens: true, turnSeconds: 60, showHints: true });
const shape = splendor.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } }> };
for (const [key, field] of Object.entries(shape.shape)) {
const m = field.meta();
expect(m?.title, key).toMatch(/[가-힣]/);
if (key === 'targetPoints' || key === 'turnSeconds') expect(Object.keys(m.labels ?? {}).length).toBeGreaterThan(1);
}
expect(splendor.optionsSchema.safeParse({ targetPoints: 12 }).success).toBe(false);
expect(splendor.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull();
});
});

View File

@@ -28,6 +28,12 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true }, { id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, { id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
{ id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true },
{ id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true },
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', 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);