Compare commits

..

21 Commits

Author SHA1 Message Date
EJClaw
c27cb0a11a M8: 마피아 E2E 밤 단계 플레키 수정
밤은 행동을 다 해도 일찍 끝나지 않고 타이머로만 넘어가는데, E2E가 밤을
15초로 둬서 부하가 걸리면 채팅+지목 3번이 15초를 넘겨 타이머가 먼저 발동,
진행 중이던 지목의 aria-pressed 대기가 5초에서 실패했다.

- 밤 시간 15초 → 45초(채팅+지목 3번에 충분한 여유)
- 낮 토론 배너 대기 30초 → 60초(길어진 밤 커버)
- 지목 확정·채팅 반영 대기 5초 → 15초(부하 시 왕복 지연 흡수)

검증: 마피아 E2E 단독 3회 + 8코어 전부 포화 상태 1회 모두 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 01:21:59 +09:00
EJClaw
6f462b8cb1 M8: 파티 게임 3종 합치기, 그림 맞히기도 게임 중 방 채팅 닫기
- 도블·그림 맞히기·마피아 브랜치 병합 (validate ctx 타입은 ValidateContext로 통일)
- 그림 맞히기에 ownChat 적용: 출제자 채팅 금지(§4), 정답이 방 채팅으로 새는 문제 차단
- '곧 나와요' 목록이 비면 숨김, 로드맵 M8 결과 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:58:59 +09:00
EJClaw
9bfb30a5e7 Merge branch 'm8/mafia' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/server/src/rooms/room.ts
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/engine/src/types.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-06 00:39:35 +09:00
EJClaw
f99784331f Merge branch 'm8/catchmind' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-06 00:39:17 +09:00
EJClaw
16d3c86486 M8: 그림 맞히기 규칙·화면·테스트
- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
  정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
  힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
  단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
  (재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
  disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
  전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
  정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:38:30 +09:00
EJClaw
953ae90546 M8: 도블 규칙·화면·테스트
- packages/games/src/dobble: 7차 사영평면 덱(57장·57그림, 55장 모드), 카드별 레이아웃 6템플릿,
  미니게임 4종(탑 쌓기·우물·뜨거운 감자·독이 든 선물), 서버 도착 순서 판정과 "늦었어요",
  틀림 잠금(serverTs 기준), 지연 보정 light(60ms 창·보정 상한 80ms), 45초 정체 힌트,
  자리 비움 3회 → 우물·뜨거운 감자 기권, 봇 의심(방장에게만), 순위 점수 3·2·1
- 테스트: §10 체크리스트 전부, 시드 1,000판 + 엔진 randomPlayout(카드 보존·비공개 누출·결정성·onTimeout 합법),
  방 단위 테스트(같은 ms 두 정답, 시스템 행동 거절, 지연 보정 창, 끊긴 사람 기권)
- 화면 apps/web/src/games/dobble: 자체 제작 SVG 그림 57종, 두 원형 카드(PC 좌우·휴대폰 위아래),
  누르는 즉시 판정 표시(찾았어요·틀렸어요 흔들림+잠금 원·늦었어요), 카드가 막 바뀐 직후 탭은 늦음 처리,
  상대 고르기(뜨거운 감자·독이 든 선물), 핑 표시, 키보드 조작, 라이트/다크, 움직임 줄이기
- 등록: GAMES, CATALOG(도블), 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구, 탑 쌓기 55장을 실제 탭으로 끝까지(오답 1회 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:33:29 +09:00
EJClaw
c682725968 M8: 마피아 규칙·화면·테스트
- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
  서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
  최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
  채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
  나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
  엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
  그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
  방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:21:32 +09:00
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
61 changed files with 27371 additions and 34 deletions

View File

@@ -0,0 +1,191 @@
/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
type Msgs = ServerMessage[];
type StateMsg = Extract<ServerMessage, { t: 'state' }>;
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const games = { catchmind: GAMES.catchmind! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'catchmind',
options: games.catchmind.defaultOptions,
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conn = (p: string) => {
const msgs: Msgs = [];
const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
return c;
};
const conns: Record<string, Conn & { msgs: Msgs }> = {};
for (const p of players) {
conns[p] = conn(p);
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
const state = () => room.game!.state as CatchmindState;
let cs = 100;
const act = (p: string, a: unknown) => {
room.act(p, ++cs, a);
room.clearTimer();
return cs;
};
return { room, db, clock, conns, conn, state, act };
}
const drawOps = (stroke: number): CanvasOp[] => [
{ k: 'b', s: stroke, c: 3, w: 1, e: 0 },
{ k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] },
{ k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] },
{ k: 'e', s: stroke },
];
const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!;
const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0);
describe('그림 맞히기 room flow', () => {
test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => {
const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']);
const d = state().turn.drawer;
const [g1, g2] = state().order.filter((p) => p !== d) as [string, string];
act(d, { type: 'chooseWord', index: 0 });
expect(state().turn.phase).toBe('drawing');
const before = conns[g1]!.msgs.length;
const ops = [...drawOps(1), ...drawOps(2)];
for (const op of ops) {
clock.advance(50);
act(d, { type: 'draw', op });
}
const got = conns[g1]!.msgs.slice(before);
expect(got.every((m) => m.t === 'stream')).toBe(true);
expect(got).toHaveLength(ops.length);
const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[];
expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i));
expect(relayed.map((e) => e.op)).toEqual(ops);
// The drawer's echo carries its cs (ack).
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) });
// Regular views carry only the count.
act(g1, { type: 'guess', text: '모르겠어요' });
const v = lastState(conns[g2]!.msgs).view as CatchmindView;
expect(v.canvas).toBeNull();
expect(v.canvasCount).toBe(ops.length);
// g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing.
room.disconnect(conns[g2]!);
room.clearTimer();
for (const op of drawOps(3)) {
clock.advance(50);
act(d, { type: 'draw', op });
}
const back = conn(g2);
room.join(back, 'player');
room.clearTimer();
const sv = lastState(back.msgs).view as CatchmindView;
expect(sv.canvas).toEqual(state().turn.canvas);
expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3);
expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas));
expect(sv.canvasCount).toBe(12);
// Explicit resync too (client gap detection).
room.sendSync(back);
expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12);
// Streamed actions are logged in batches with the next snapshot.
const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n;
expect(drawLogs()).toBe(12); // the join wrote the pending ones
clock.advance(50);
act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } });
expect(drawLogs()).toBe(12); // not yet written
room.flushStream();
expect(drawLogs()).toBe(13);
const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq);
expect(new Set(seqs).size).toBe(seqs.length);
const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!;
expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13);
});
test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => {
const { clock, conns, state, act } = harness(['A', 'B']);
const d = state().turn.drawer;
const g = state().order.find((p) => p !== d)!;
act(d, { type: 'chooseWord', index: 1 });
const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } });
expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' });
for (let s = 1; s <= 30; s++) {
clock.advance(10);
act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } });
}
expect(state().turn.canvas).toHaveLength(30);
const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') });
clock.advance(1000);
act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
expect(state().turn.canvas).toHaveLength(31);
});
test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => {
const { room, clock, conns, state } = harness(['A', 'B', 'C']);
const d = state().turn.drawer;
const g = state().order.find((p) => p !== d)!;
room.disconnect(conns[d]!);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now());
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
const word = state().turn.word!.word;
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
clock.advance(9_999);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
clock.advance(1);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('reveal');
expect(state().turn.endReason).toBe('drawerAway');
expect(state().scores[d]).toBe(0);
expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word);
clock.advance(5_000);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('choosing');
expect(state().turn.drawer).not.toBe(d);
expect(state().away).toEqual([d]);
});
test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => {
const { room, clock, state, act } = harness(['A', 'B']);
const d = state().turn.drawer;
act(d, { type: 'chooseWord', index: 0 });
const t0 = state().turn.startedAt;
const ends = state().turn.endsAt;
expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2);
clock.advance(20_000);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
clock.advance(ends - t0);
for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) {
room.onTimer();
room.clearTimer();
}
expect(state().turn.endReason).toBe('time');
});
});

View File

@@ -0,0 +1,120 @@
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { dobble: GAMES.dobble! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'dobble',
options: games.dobble.optionsSchema.parse(opts),
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as DobbleState;
/** The correct claim of `p` (first pair), as the client would send it. */
const right = (p: string) => {
const pair = pairsFor(state(), p)[0]!;
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
};
return { room, clock, conns, state, right };
}
describe('도블 room flow', () => {
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
const { room, conns, state, right } = harness(['A', 'B', 'C']);
const a = right('A');
const b = right('B');
const towerB = state().piles.B!.length;
room.act('B', 1, b);
room.act('A', 1, a);
room.clearTimer();
expect(state().piles.B).toHaveLength(towerB + 1);
expect(state().piles.A).toHaveLength(1);
expect(state().lockedUntil.A).toBe(0);
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
// Nobody's view carries the answer or hidden cards; the hit is public.
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
});
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
const { room, conns } = harness(['A', 'B']);
room.act('A', 1, { type: 'tick' });
room.act('A', 2, { type: 'leave' });
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
});
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
const s = state();
s.rtt.A = 20;
s.rtt.B = 200;
const a = right('A');
const b = right('B');
room.act('A', 1, a);
clock.advance(40);
room.act('B', 1, b);
expect(state().pendingWindow?.claims).toHaveLength(2);
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
clock.set(room.effectiveDeadline()!);
room.onTimer();
room.clearTimer();
expect(state().pendingWindow).toBeNull();
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
expect(state().lastHit).toMatchObject({ player: 'B' });
});
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
const t0 = clock.now();
room.disconnect(conns.C!);
room.clearTimer();
let fired = 0;
while (state().status.C === 'playing' && fired < 50) {
const d = room.effectiveDeadline()!;
if (d > clock.now()) clock.set(d);
room.onTimer();
room.clearTimer();
fired++;
// The others keep finding pictures now and then.
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
room.clearTimer();
}
expect(state().status.C).toBe('forfeit');
const took = clock.now() - t0;
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
expect(fired).toBeLessThan(20);
expect(room.status).toBe('playing');
expect(state().phase).toBe('playing');
});
});

View File

@@ -0,0 +1,70 @@
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import type { MafiaState, MafiaView } from '@bg/games/mafia';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const games = { mafia: GAMES.mafia! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'mafia',
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
const state = () => room.game!.state as MafiaState;
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
return { room, clock, conns, state, lastView };
}
describe('마피아 room flow', () => {
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
expect(room.chat('A', '대기실 인사')).toBeNull();
expect(room.start('A')).toBeNull();
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
// Role reveal → night on the server clock.
clock.advance(8_000);
room.onTimer();
expect(state().phase.kind).toBe('night');
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
for (const t of town) {
const all = JSON.stringify(conns[t]!.msgs);
expect(all).not.toContain('ROOM-MAFIA-SECRET');
expect(all).not.toContain(`"role":"mafia"`);
}
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
room.act(town[0]!, 3, { type: 'phaseTimeout' });
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
expect(state().phase.kind).toBe('night');
clock.advance(30_000);
room.onTimer();
expect(state().phase.kind).toBe('discussion');
expect(state().day).toBe(2);
});
});

View File

@@ -6,7 +6,7 @@ import { ulid } from 'ulid';
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
import { sha256Hex } from '../auth/sessions';
import type { RoomStore } from './store';
import type { LogEntry, RoomStore } from './store';
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
export interface Conn {
@@ -83,6 +83,8 @@ export interface RoomDeps {
const CHAT_HISTORY = 30;
const ERROR_LIMIT = 3;
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
const STREAM_FLUSH_MS = 1000;
export class Room {
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
@@ -104,6 +106,9 @@ export class Room {
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
lastActiveAt: number;
private timer: ReturnType<typeof setTimeout> | null = null;
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
private pendingLogs: LogEntry[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private recentCs = new Map<string, number[]>();
private lastChatAt = new Map<string, number>();
private consecutiveErrors = 0;
@@ -217,17 +222,39 @@ export class Room {
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
const nextSeq = this.seq + 1;
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
this.deps.store.save(
this.id,
nextSeq,
JSON.stringify(this.snapshot()),
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null,
logs,
);
this.pendingLogs = [];
if (this.flushTimer) clearTimeout(this.flushTimer);
this.flushTimer = null;
this.seq = nextSeq;
this.lastActiveAt = this.now();
}
/**
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
*/
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
this.seq += 1;
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
this.lastActiveAt = this.now();
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
}
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
flushStream(): void {
this.flushTimer = null;
if (this.pendingLogs.length) this.persist(null);
}
// ---------------------------------------------------------------- views
roomView(): RoomView {
@@ -264,13 +291,14 @@ export class Room {
};
}
private stateMessage(viewer: string, cs?: number): ServerMessage | null {
/** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null {
if (!this.game) return null;
const def = this.def;
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
let view: unknown;
try {
view = def.view(this.game.state, asPlayer);
view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer);
} catch (err) {
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
@@ -345,7 +373,7 @@ export class Room {
}
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
this.broadcastRoom();
const st = this.stateMessage(u);
const st = this.stateMessage(u, undefined, true);
if (st) conn.send(st);
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
for (const m of this.chatLog) conn.send(m);
@@ -536,6 +564,7 @@ export class Room {
chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
const now = this.now();
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
this.lastChatAt.set(userId, now);
@@ -553,8 +582,13 @@ export class Room {
return null;
}
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
private gameChatOnly(): boolean {
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
}
emote(userId: string, id: string): void {
if (!this.isParticipant(userId)) return;
if (!this.isParticipant(userId) || this.gameChatOnly()) return;
const now = this.now();
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
this.lastChatAt.set(`e:${userId}`, now);
@@ -670,10 +704,11 @@ export class Room {
const def = this.def;
let next: { state: unknown; events: unknown[] };
const rng = new SeededRng(g.rng);
const now = this.now();
try {
const v = def.validate(g.state, userId, action);
const v = def.validate(g.state, userId, action, { now });
if (!v.ok) return v.reason;
next = def.apply(g.state, userId, action, { rng, now: this.now() });
next = def.apply(g.state, userId, action, { rng, now });
} catch (err) {
this.consecutiveErrors++;
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
@@ -690,8 +725,15 @@ export class Room {
g.state = next.state;
g.rng = rng.state();
g.deadlineFloor = null;
if (!system && def.isStreamAction?.(action)) {
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
this.persistStream({ actorId: userId, kind: 'act', data: action });
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
} else {
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
this.broadcastState(next.events, userId, cs);
}
const result = def.result(g.state);
if (result) this.finishGame(result);
else this.scheduleTimer();
@@ -768,12 +810,13 @@ export class Room {
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
let best: number | null = null;
for (const u of timed) {
let d = base;
const p = this.presence.get(u);
if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000;
const grace = p.since + graceMs;
d = d === null ? grace : Math.min(d, grace);
}
if (d === null) continue;
@@ -807,11 +850,12 @@ export class Room {
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
for (const u of timed) {
let d = base;
const p = this.presence.get(u);
if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000;
const grace = p.since + graceMs;
d = d === null ? grace : Math.min(d, grace);
}
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
@@ -833,7 +877,7 @@ export class Room {
sendSync(conn: Conn): void {
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
const st = this.stateMessage(conn.userId);
const st = this.stateMessage(conn.userId, undefined, true);
if (st) conn.send(st);
}
@@ -852,6 +896,7 @@ export class Room {
}
shutdown(): void {
this.flushStream();
this.clearTimer();
for (const c of this.conns.values()) {
c.send({ t: 'bye', reason: 'restart' });

View File

@@ -44,20 +44,20 @@ export class RoomStore {
})();
}
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */
/** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
save(
roomId: string,
seq: number,
stateJson: string,
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
log: LogEntry | null,
log: LogEntry | LogEntry[] | null,
now = Date.now(),
): void {
this.db.transaction(() => {
if (log) {
for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
this.db
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at);
.run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at);
}
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
this.db

File diff suppressed because it is too large Load Diff

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,8 @@
import { expect, test } from 'bun:test';
import { SYMBOL_NAMES } from '@bg/games/dobble';
import { ICONS, ICON_NAMES } from './art';
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
expect(ICONS).toHaveLength(57);
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
});

View File

@@ -0,0 +1,556 @@
/**
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
*/
import type { ReactNode } from 'react';
const O = '#1f2937';
/** Outline props for filled shapes. */
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
/** Thick stroked lines (no fill). */
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
Array.from({ length: n }, (_, i) => {
const a = a0 + (i * 2 * Math.PI) / n;
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
}).join(' ');
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
Array.from({ length: n * 2 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / n;
const r = i % 2 ? ri : ro;
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
}).join(' ');
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
export const ICON_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
];
export const ICONS: (() => ReactNode)[] = [
// 0 사과
() => (
<>
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
</>
),
// 1 해
() => (
<>
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
</>
),
// 2 달
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
// 3 별
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
// 4 하트
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
// 5 물방울
() => (
<>
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
</>
),
// 6 불꽃
() => (
<>
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
</>
),
// 7 눈송이
() => (
<>
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
const a = Math.PI / 2 + (i * Math.PI) / 3;
const bx = 50 + Math.cos(a) * 26;
const by = 50 + Math.sin(a) * 26;
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
}).join(' ')} {...W('#0284c7', 5)} />
</>
),
// 8 번개
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
// 9 구름
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
// 10 나무 (pine)
() => (
<>
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
</>
),
// 11 꽃
() => (
<>
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
})}
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
</>
),
// 12 나뭇잎
() => (
<>
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
</>
),
// 13 선인장
() => (
<>
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
</>
),
// 14 버섯
() => (
<>
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
<circle cx="32" cy="34" r="6" fill="#fff" />
<circle cx="56" cy="24" r="5" fill="#fff" />
<circle cx="70" cy="42" r="6" fill="#fff" />
<circle cx="46" cy="44" r="4" fill="#fff" />
</>
),
// 15 당근
() => (
<>
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
</>
),
// 16 레몬
() => (
<>
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
</>
),
// 17 체리
() => (
<>
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
<circle cx="22" cy="66" r="4" fill="#fecaca" />
<circle cx="66" cy="64" r="4" fill="#fecaca" />
</>
),
// 18 고양이
() => (
<>
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
</>
),
// 19 강아지
() => (
<>
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
<circle cx="40" cy="50" r="4.5" fill={O} />
<circle cx="60" cy="50" r="4.5" fill={O} />
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
</>
),
// 20 물고기
() => (
<>
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
</>
),
// 21 새
() => (
<>
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
<circle cx="74" cy="30" r="3.5" fill={O} />
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
</>
),
// 22 거북이
() => (
<>
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
</>
),
// 23 무당벌레
() => (
<>
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
<circle cx="50" cy="28" r="16" fill={O} />
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
<path d="M50 26 L50 92" {...W(O, 3.5)} />
<circle cx="34" cy="48" r="6" fill={O} />
<circle cx="66" cy="48" r="6" fill={O} />
<circle cx="30" cy="70" r="5" fill={O} />
<circle cx="70" cy="70" r="5" fill={O} />
</>
),
// 24 거미
() => (
<>
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
<circle cx="46" cy="36" r="2.5" fill="#fff" />
<circle cx="54" cy="36" r="2.5" fill="#fff" />
</>
),
// 25 공룡
() => (
<path
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
fill="#65a30d"
{...L}
/>
),
// 26 토끼
() => (
<>
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
<circle cx="40" cy="60" r="4" fill={O} />
<circle cx="60" cy="60" r="4" fill={O} />
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
</>
),
// 27 펭귄
() => (
<>
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
<circle cx="42" cy="34" r="4" fill="#fff" />
<circle cx="58" cy="34" r="4" fill="#fff" />
<circle cx="42" cy="34" r="2" fill={O} />
<circle cx="58" cy="34" r="2" fill={O} />
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
</>
),
// 28 열쇠
() => (
<>
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
</>
),
// 29 자물쇠
() => (
<>
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill={O} />
<path d="M50 64 L50 80" {...W(O, 6)} />
</>
),
// 30 시계
() => (
<>
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d={rays(12, 27, 31)} {...W(O, 3)} />
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
<circle cx="50" cy="50" r="4" fill="#dc2626" />
</>
),
// 31 전구
() => (
<>
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
</>
),
// 32 연필
() => (
<g transform="rotate(-45 50 50)">
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
</g>
),
// 33 가위
() => (
<>
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="50" cy="45" r="4" fill={O} />
</>
),
// 34 안경
() => (
<>
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
</>
),
// 35 우산
() => (
<>
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
</>
),
// 36 모자 (top hat)
() => (
<>
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
</>
),
// 37 왕관
() => (
<>
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
</>
),
// 38 종
() => (
<>
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
<path d="M50 14 L50 6" {...W(O, 6)} />
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
</>
),
// 39 닻
() => (
<>
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
</>
),
// 40 자전거
() => (
<>
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
<circle cx="50" cy="66" r="4" fill={O} />
</>
),
// 41 자동차
() => (
<>
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 46" {...W(O, 2.5)} />
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
</>
),
// 42 로켓
() => (
<>
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
</>
),
// 43 배 (sailboat)
() => (
<>
<path d="M50 8 L50 66" {...W(O, 4)} />
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
</>
),
// 44 공 (soccer ball)
() => (
<>
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
})}
</>
),
// 45 주사위
() => (
<>
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
{[
[30, 30],
[70, 30],
[50, 50],
[30, 70],
[70, 70],
].map(([x, y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
))}
</>
),
// 46 음표
() => (
<>
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
</>
),
// 47 카메라
() => (
<>
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
<circle cx="46" cy="49" r="3" fill="#fff" />
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
</>
),
// 48 집
() => (
<>
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
</>
),
// 49 성
() => (
<>
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
<rect x="18" y="44" width="8" height="12" fill={O} />
<rect x="74" y="44" width="8" height="12" fill={O} />
</>
),
// 50 선물상자
() => (
<>
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
</>
),
// 51 촛불
() => (
<>
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 40" {...W(O, 3)} />
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
</>
),
// 52 망치
() => (
<g transform="rotate(-35 50 50)">
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
</g>
),
// 53 느낌표
() => (
<>
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
</>
),
// 54 물음표
() => (
<>
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
</>
),
// 55 눈사람
() => (
<>
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="44" cy="26" r="2.6" fill={O} />
<circle cx="56" cy="26" r="2.6" fill={O} />
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
<circle cx="50" cy="62" r="3" fill={O} />
<circle cx="50" cy="74" r="3" fill={O} />
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
</>
),
// 56 무지개
() => (
<>
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
))}
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
</>
),
];
/** One icon in its own 100×100 SVG. */
export function SymbolArt({ art, className }: { art: number; className?: string }) {
const draw = ICONS[art];
return (
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
{draw ? draw() : null}
</svg>
);
}

View File

@@ -0,0 +1,854 @@
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
import { ICON_NAMES, SymbolArt } from './art';
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
const TAP_GRACE_MS = 250;
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
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 who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
const CSS = `
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
button.db-seat { cursor: pointer; }
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.db-seat[data-out='true'] { opacity: .55; }
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
.db-tag.ok { background: #15803d; }
.db-tag.warn { background: #f59e0b; color: #111827; }
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.db-sym:disabled { cursor: default; }
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
.db-sym .ic.in { animation: db-in .25s ease-out; }
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
.db-thumb .db-sym { cursor: inherit; }
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
.db-count small { font-size: 1.1rem; padding: 0 12px; }
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
.db-bubble.ok { border: 4px solid #16a34a; }
.db-bubble.bad { border: 4px solid #dc2626; }
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
.db-bubble.info { border: 4px solid #2563eb; }
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.db-sus { color: var(--warning); font-weight: 800; }
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
@media (max-width: 719px) {
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
.db-slot { position: relative; }
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
.db-pill { font-size: .8rem; padding: 2px 8px; }
.db-strip { justify-content: flex-start; }
.db-seat { padding: 3px 8px; gap: 6px; }
.db-seat .meta.rtt, .db-pill.mode { display: none; }
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
.db-seat .nm { max-width: 72px; font-size: .8rem; }
.db-thumb { width: 38px; height: 38px; }
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
.db-bubble .bi { width: 26px; height: 26px; }
.db-count { font-size: 4rem; }
}
@media (prefers-reduced-motion: reduce) {
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
.db-sym[data-hit='true'] .ic { transition: none; }
}
`;
// ---------------------------------------------------------------- card
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
/**
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
*/
function DobbleCard({
card,
label,
side,
onPick,
hint,
hit,
shake,
children,
}: {
card: CardView;
label: string;
side: string;
onPick: ((art: number) => void) | null;
hint?: number[];
hit?: number | null;
shake?: boolean;
children?: ReactNode;
}) {
return (
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<button
key={i}
type="button"
className="db-sym"
data-art={it.art}
data-hint={!!hint?.includes(it.art)}
data-hit={hit === it.art}
disabled={!onPick}
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (!onPick || e.button !== 0) return;
e.preventDefault();
onPick(it.art);
}}
onClick={(e) => {
// Keyboard activation; pointer presses were handled on pointerdown.
if (onPick && e.detail === 0) onPick(it.art);
}}
>
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</button>
);
})}
{children}
</div>
);
}
/** Small look-only card (target picker, rules). */
function Thumb({ card }: { card: CardView | null }) {
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
return (
<span className="db-thumb" aria-hidden>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</span>
);
})}
</span>
);
}
// ---------------------------------------------------------------- seats
function countText(mode: MiniGame, s: SeatView): string {
switch (mode) {
case 'tower':
return `탑 ${s.cards}장`;
case 'well':
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
case 'poisonedGift':
return `받은 카드 ${s.cards}장`;
case 'hotPotato':
return `${s.cards}장 · 벌점 ${s.penalty}`;
}
}
function statusTag(s: SeatView): { text: string; cls: string } | null {
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
if (s.status === 'forfeit') return { text: '기권', cls: '' };
if (s.status === 'left') return { text: '나감', cls: '' };
return null;
}
function Rtt({ ms }: { ms: number | null }) {
if (ms === null) return null;
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
return (
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
<i className="db-dot" style={{ background: color }} aria-hidden />
{ms}ms
</span>
);
}
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
const tag = statusTag(s);
if (tag) parts.push(tag.text);
return parts.join(', ');
}
// ---------------------------------------------------------------- board
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late' | 'info';
text: string;
art?: number | null;
}
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [sel, setSel] = useState<string | null>(null);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [shake, setShake] = useState(false);
const [hitArt, setHitArt] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const prev = useRef<DobbleView | null>(null);
const changed = useRef({ sig: '', at: 0 });
const mode = v.options.miniGame;
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
const mine = v.seats.find((s) => s.id === me) ?? null;
const others = v.seats.filter((s) => s.id !== me);
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
// The opponent whose card is shown big (also for spectators / while waiting).
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
// Clamped to the lock length: the client clock estimate may be a little off.
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
const playing = v.phase === 'playing';
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
const claim = (art: number) => {
if (!pair) return;
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
return;
}
if (!canTap) {
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
return;
}
haptic(15);
act({
type: 'claim',
base: pair.base,
symbol: art,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...(rtt !== null ? { rtt } : {}),
});
};
// "늦었어요" rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: my judgement, other people's finds, rounds.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const f = v.feedback;
if (f && f.seq !== p.feedback?.seq) {
if (f.kind === 'ok') {
sounds.ding();
haptic([30, 30, 60]);
setHitArt(f.art);
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
setBubble(b);
setAnnounce(`찾았어요! ${artName(f.art)}`);
} else if (f.kind === 'wrong') {
sounds.buzz();
haptic([90, 50, 90]);
setShake(true);
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
setBubble(b);
} else if (f.kind === 'late') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
setAnnounce('늦었어요');
} else if (f.kind === 'locked') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
}
return;
}
const h = v.lastHit;
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
sounds.stone();
let text = `${nick(players, h.player)}님이 찾았어요`;
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
setAnnounce(`${text} · ${artName(h.art)}`);
}
const r = v.lastRound;
if (r && r.round !== p.lastRound?.round) {
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
setAnnounce(text);
}
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
return () => clearTimeout(t);
}, [bubble]);
useEffect(() => {
if (!shake) return;
const t = setTimeout(() => setShake(false), 450);
return () => clearTimeout(t);
}, [shake]);
useEffect(() => {
if (hitArt === null) return;
const t = setTimeout(() => setHitArt(null), 500);
return () => clearTimeout(t);
}, [hitArt]);
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
const hint = v.hintUntil > now ? v.hint : [];
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
let a: { card: CardView | null; cap: string; side: string };
let b: { card: CardView | null; cap: string; side: string };
if (mode === 'hotPotato') {
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
} else if (mode === 'poisonedGift') {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
} else {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
}
const emptyText = (side: string): string => {
if (v.phase === 'countdown') return '곧 공개돼요';
if (side === 'mine') {
if (!mine) return '관전 중이에요';
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
if (mine.status === 'escaped') return '이번 라운드 탈출!';
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
}
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
return '카드가 없어요';
};
const slot = (x: typeof a, withLock: boolean) => (
<div className="db-slot">
<span className="cap">{x.cap}</span>
{x.card ? (
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
{withLock && lockedLeft > 0 && (
<span
className="db-lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
>
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
</span>
)}
</DobbleCard>
) : (
<div className="db-card empty" data-card={x.side}>
{emptyText(x.side)}
</div>
)}
</div>
);
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
const pickable = new Set(v.pairs.map((p) => p.target));
return (
<div className="db">
<style>{CSS}</style>
<div className="db-top">
<div className="grp">
<span className="db-pill mode">{MINI_KO[mode]}</span>
{remaining && <span className="db-pill">{remaining}</span>}
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
</div>
<div className="grp">
{v.options.showRtt && rtt !== null && (
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
</span>
)}
</div>
</div>
{others.length > 0 && (
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
{others.map((s) => {
const tag = statusTag(s);
const canPick = targetMode && pickable.has(s.id);
const inner = (
<>
{targetMode && <Thumb card={s.top} />}
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
<span className="who">
<span className="nm">
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
</span>
<span className="meta">
{countText(mode, s)}
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
</span>
{v.options.showRtt && <Rtt ms={s.rtt} />}
</span>
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
</>
);
return (
<li key={s.id}>
{canPick ? (
<button
type="button"
className="db-seat"
data-seat={s.id}
aria-pressed={targetId === s.id}
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
onClick={() => setSel(s.id)}
>
{inner}
</button>
) : (
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
{inner}
</div>
)}
</li>
);
})}
</ul>
)}
<div className="db-table" role="region" aria-label="카드 테이블">
{slot(a, false)}
{slot(b, true)}
{bubble && (
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
<span>{bubble.text}</span>
</div>
)}
{countdown > 0 && (
<div className="db-count" aria-hidden>
{countdown}
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
</div>
)}
</div>
{targetMode && v.pairs.length > 1 && playing && (
<div className="db-panel small" style={{ fontWeight: 600 }}>
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
</div>
)}
{!phone && pair && playing && (
<span className="small muted" style={{ textAlign: 'center' }}>
Tab 키로 그림을 고르고 Enter 키로 눌러요
</span>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
switch (e.t) {
case 'hit': {
const n = who(players, me, e.p);
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
}
case 'done':
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
case 'round':
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
case 'out':
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
case 'hint':
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
}
}
function rulesLine(o: DobbleOptions): string {
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
if (o.latencyComp === 'light') parts.push('지연 보정');
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
return parts.join(' · ');
}
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
const mode = v.options.miniGame;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => {
const sus = v.suspects.includes(s.id);
const tag = statusTag(s);
return (
<li
key={s.id}
className="row"
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{tag ? ` · ${tag.text}` : ''}
</span>
{sus && (
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
⚠ 의심
</span>
)}
{v.options.showRtt && s.rtt !== null && (
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
{s.rtt}
</span>
)}
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
</strong>
</li>
);
})}
</ol>
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
</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="db-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, mode, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DobbleView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.pairs.length > 0;
}
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
const mine = v.seats.find((s) => s.id === me);
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) {
return '지금 찾아요! 같은 그림을 눌러요';
}
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
}
// ---------------------------------------------------------------- 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>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
const MINI_HELP: Record<MiniGame, string> = {
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
};
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
<Seg
label="미니게임"
value={o.miniGame}
options={[
['tower', '탑 쌓기'],
['well', '우물'],
['hotPotato', '뜨거운 감자'],
['poisonedGift', '독이 든 선물'],
]}
onChange={(miniGame) => set({ miniGame })}
/>
</Field>
{o.miniGame === 'hotPotato' && (
<Field label="뜨거운 감자 라운드 수">
<Seg
label="뜨거운 감자 라운드 수"
value={o.hotPotatoRounds}
options={[
[3, '3라운드'],
[5, '5라운드'],
[7, '7라운드'],
]}
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
/>
</Field>
)}
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
<Seg
label="틀린 그림을 눌렀을 때 잠금"
value={o.wrongLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
/>
</Field>
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
<Seg
label="그림 크기·회전"
value={o.symbolStyle}
options={[
['classic', '원작처럼'],
['easy', '쉽게'],
]}
onChange={(symbolStyle) => set({ symbolStyle })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드 장수">
<Seg
label="카드 장수"
value={o.deckSize}
options={[
[57, '57장'],
[55, '55장'],
]}
onChange={(deckSize) => set({ deckSize })}
/>
</Field>
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="각자 핑 표시">
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
{o.miniGame === 'well' && (
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
</Field>
)}
{o.miniGame === 'tower' && (
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
</Field>
)}
</>
)}
</div>
);
}
function optionsSummary(o: DobbleOptions): string {
return rulesLine(o);
}
// ---------------------------------------------------------------- rules
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
function Rules({ options }: { options?: DobbleOptions }) {
const lock = options?.wrongLockoutMs ?? 1000;
return (
<div className="stack">
<style>{CSS}</style>
<p>
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
</p>
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
</span>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
</span>
<span>→ 같은 그림은 고양이!</span>
</div>
<ul>
<li>
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
</li>
<li>
<strong>우물</strong>: {MINI_HELP.well}
</li>
<li>
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
</li>
<li>
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
</li>
</ul>
<p>
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
</p>
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
</div>
);
}
const ui: GameUI<DobbleView, DobbleOptions> = {
Board: DobbleBoard,
Side: DobbleSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

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;

File diff suppressed because it is too large Load Diff

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,15 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'),
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'),
dobble: () => import('./dobble'),
catchmind: () => import('./catchmind'),
mafia: () => import('./mafia'),
};
const cache = new Map<string, GameUI>();

File diff suppressed because it is too large Load Diff

View File

@@ -27,4 +27,6 @@ export interface GameUI<V = any, O = any> {
Options: ComponentType<OptionsProps<O>>;
optionsSummary(options: O): string;
Rules: ComponentType<{ options?: O }>;
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
ownChat?: boolean;
}

View File

@@ -16,7 +16,16 @@ export const GAME_ICON: Record<string, string> = {
marble: '🏙️',
bang: '🤠',
'halli-galli': '🔔',
dalmuti: '👑',
splendor: '💎',
quoridor: '🧱',
'las-vegas': '⚅',
'cockroach-poker': '🐞',
'ninety-nine': '💯',
dobble: '👀',
catchmind: '🖍️',
mafia: '🌙',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['달무티', '보석 상인', '쿼리도', '라스베가스', '바퀴벌레 포커', '구십구', '도블', '그림 맞히기', '마피아'];
export const UPCOMING: string[] = [];

View File

@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
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][] = [
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],

View File

@@ -9,10 +9,12 @@ export default function GamesPage() {
<main className="page">
<h1>게임 목록</h1>
<GameGrid games={catalog.filter((g) => g.available)} />
{UPCOMING.length > 0 && (
<section className="stack">
<h2>곧 나와요</h2>
<p className="muted">{UPCOMING.join(' · ')}</p>
</section>
)}
</main>
);
}

View File

@@ -86,7 +86,7 @@ export function Home() {
<section className="stack" style={{ gap: 16 }}>
<h2>게임 고르기</h2>
<GameGrid games={available} />
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</section>
<aside className="stack" style={{ gap: 12 }}>
<h2>지금 열린 방</h2>

View File

@@ -136,6 +136,8 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
const g = { nameKo: gameName(room.gameId) };
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
const isHost = room.hostId === me.id;
const doLeave = () => {
socket.leave();
@@ -157,9 +159,11 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} />
{!roomChatClosed && (
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />
</span>
)}
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙
</button>
@@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
)}
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
<EmoteBubbles room={room} />
{chatOpen && (
{chatOpen && !roomChatClosed && (
<Sheet onClose={() => setChatOpen(false)} label="채팅">
<ChatPanel room={room} me={me} />
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
@@ -549,7 +553,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
<aside className="game-side" aria-label="게임 정보">
{wide && status}
<ui.Side {...props} />
{wide && <ChatPanel room={room} me={me} title={false} />}
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
</aside>
</div>
);

View File

@@ -34,12 +34,16 @@ export interface GameDefinition<S, A, V, O> {
actionSchema: ZodType<A>; // 서버 입력 검증(모양만), 규칙 검증은 validate
playersFor?(options: O): { min: number; max: number }; // 모드별 인원이 다를 때
setup(ctx: { players: PlayerId[]; options: O; rng: Rng; now: number; gameNo?: number }): S; // players는 방장 자리부터 시계 방향
validate(state: S, actor: PlayerId, action: A): { ok: true } | { ok: false; reason: string };
validate(state: S, actor: PlayerId, action: A, ctx?: { now: number }): { ok: true } | { ok: false; reason: string }; // now: 서버가 넘김(레이트 리밋용)
apply(state: S, actor: PlayerId, action: A, ctx: { rng: Rng; now: number }): { state: S; events: GameEvent[] };
view(state: S, viewer: PlayerId | null): V;
activePlayers(state: S): PlayerId[];
deadline?(state: S): number | null;
timeoutPlayers?(state: S): PlayerId[]; // 마감이 적용되는 사람(기본: activePlayers)
timeoutNotBefore?(state: S): number | null; // 자리 비움이어도 이 시각 전에는 시간 초과 처리 안 함(실시간 게임)
disconnectGraceMs?(state: S): number | null; // 연결 끊긴 마감 대상의 유예 시간(ms), null이면 방의 graceSec
isStreamAction?(action: A): boolean; // 그림 획처럼 잦은 행동: 이벤트만 'stream' 메시지로 중계, 저장은 묶어서
syncView?(state: S, viewer: PlayerId | null): V; // (재)접속·sync 때 보내는 전체 view(기본: view)
onTimeout(state: S, player: PlayerId): A;
onLeave?(state: S, player: PlayerId): A | null; // 기권·자리 비움 처리
result(state: S): GameResult | null;

View File

@@ -54,7 +54,7 @@
## M6. 뱅 + 실시간 반응 + 땅따먹기
- 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기.
- **진행 결과(2026-10-05)**: 세 게임 + 할리갈리 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
- **진행 결과(2026-10-05)**: 세 게임 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인).
엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음).
뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기.
@@ -62,9 +62,22 @@
## 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. 파티 게임
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).
- **진행 결과(2026-10-06)**: 세 게임 규칙 + 화면 완료. 규칙 테스트 도블 47(+방 단위 4·그림 1)·그림 맞히기 34(+방 단위 4)·마피아 43(+방 단위 1),
게임마다 무작위 플레이 1,000판(구성물·점수 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법).
엔진 선택 훅 추가(기존 게임 영향 없음): `validate`의 `ctx.now`(채팅·추측 속도 제한), 붓질 스트리밍용 `isStreamAction`·`syncView`
(그림 획은 view 재계산 없이 `stream` 메시지로 중계, 1초마다 묶어 저장, 재접속 시 그림 전체 전송), `disconnectGraceMs`,
`ownChat`(게임 진행 중 방 채팅·빠른 반응을 닫음 — 마피아의 밤 대화, 그림 맞히기의 정답이 방 채팅으로 새지 않게).
E2E: 도블 탑 쌓기 한 판(정답 53번·오답 잠금), 그림 맞히기 두 턴(실제 마우스 획 전달·오답·아까워요·정답) → 결과,
마피아 4인(PC 방장 + 휴대폰 3명) 밤 행동 → 아침 발표 → 투표·반론·찬반 → 시민 팀 승리(마피아·죽은 사람 채팅이 다른 화면에 안 보임).
## M9. 다듬기
- PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화.

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,11 @@ export interface SetupContext<O> {
gameNo?: number;
}
/** Optional server context for `validate` (time-based checks such as chat rate limits). Absent in pure tests. */
export interface ValidateContext {
now: number;
}
export interface ApplyContext {
rng: Rng;
now: number;
@@ -47,12 +52,22 @@ export interface GameDefinition<S, A, V, O> {
optionsSchema: ZodType<O>;
actionSchema: ZodType<A>;
playersFor?(options: O): { min: number; max: number };
/** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */
ownChat?: boolean;
/** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */
playerCountError?(options: O, count: number): string | null;
setup(ctx: SetupContext<O>): S;
validate(state: S, actor: PlayerId, action: A): Validation;
/** `ctx.now` is passed by the server (real-time games may rate-limit with it); tests may omit it. */
validate(state: S, actor: PlayerId, action: A, ctx?: ValidateContext): Validation;
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
view(state: S, viewer: PlayerId | null): V;
/**
* Streaming actions (e.g. drawing strokes): the server relays only their events (`stream` message) instead of
* recomputing and broadcasting every view, and persists them in batches. Their events must be public.
*/
isStreamAction?(action: A): boolean;
/** View sent on (re)join and resync; defaults to `view`. Lets `view` omit bulky data that streams as events. */
syncView?(state: S, viewer: PlayerId | null): V;
activePlayers(state: S): PlayerId[];
deadline?(state: S): number | null;
/** Players the deadline applies to (default: activePlayers). */
@@ -62,6 +77,8 @@ export interface GameDefinition<S, A, V, O> {
* pulls forward to the end of the grace period). Real-time games use it to keep a minimum pace.
*/
timeoutNotBefore?(state: S): number | null;
/** Grace period (ms) for disconnected timed players in this state, overriding the room's `graceSec`; null = room default. */
disconnectGraceMs?(state: S): number | null;
onTimeout(state: S, player: PlayerId): A;
onLeave?(state: S, player: PlayerId): A | null;
result(state: S): GameResult | null;

View File

@@ -0,0 +1,669 @@
/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
BUILTIN_WORDS,
CHOOSE_MS,
REVEAL_MS,
buildPool,
catchmind as cm,
catchmindOptionsSchema,
chosung,
judge,
normalize,
type CanvasOp,
type CatchmindAction,
type CatchmindOptions,
type CatchmindState,
type CatchmindView,
type WordEntry,
} from './index';
// ---------------------------------------------------------------- helpers
const T0 = 1_000_000;
const rng = () => SeededRng.fromSeed([3, 1, 4, 1]);
const P = (n: number) => Array.from({ length: n }, (_, i) => `p${i + 1}`);
const opts = (o: Partial<CatchmindOptions> = {}) => catchmindOptionsSchema.parse(o);
function make(n = 4, o: Partial<CatchmindOptions> = {}, seed = 1): CatchmindState {
return cm.setup({ players: P(n), options: opts(o), rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
}
function applyE(s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number): { state: CatchmindState; events: GameEvent[] } {
const v = cm.validate(s, p, a, { now });
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return cm.apply(s, p, a, { rng: rng(), now });
}
const act = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number) => applyE(s, p, a, now).state;
const reason = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now?: number) => {
const v = cm.validate(s, p, a, now === undefined ? undefined : { now });
return v.ok ? null : v.reason;
};
const entry = (word: string): WordEntry =>
BUILTIN_WORDS.find((w) => w.word === word) ?? { word, aliases: [], category: 'custom', difficulty: 'normal' };
/** Puts `word` in front of the current drawer and picks it; returns the drawing state. */
function drawWith(s0: CatchmindState, word: string, now = T0 + 1000): CatchmindState {
const s = structuredClone(s0);
expect(s.turn.phase).toBe('choosing');
s.turn.choices = [entry(word), ...s.turn.choices.slice(1)];
return act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, now);
}
const guessers = (s: CatchmindState) => s.order.filter((p) => p !== s.turn.drawer);
const guess = (text: string) => ({ type: 'guess', text }) as const;
const draw = (op: CanvasOp) => ({ type: 'draw', op }) as const;
const view = (s: CatchmindState, p: PlayerId | null): CatchmindView => cm.view(s, p);
// ---------------------------------------------------------------- §5.3 text
describe('정답 판정(§5.3)', () => {
const ice = ['아이스크림'];
test('정규화: 띄어쓰기·문장부호, 끝말, NFD, 전각 영문, 제로폭 문자 모두 정답', () => {
expect(judge('아이스 크림!', ice, true)).toBe('correct');
expect(judge('아이스크림요', ice, true)).toBe('correct');
expect(judge('아이스크림'.normalize('NFD'), ice, true)).toBe('correct');
expect('아이스크림'.normalize('NFD')).not.toBe('아이스크림');
expect(judge('ABC', ['ABC'], true)).toBe('correct');
expect(judge('abc', ['ABC'], true)).toBe('correct');
expect(judge('아이​스‍크림', ice, true)).toBe('correct');
expect(judge(' 아이스크림입니다~ ', ice, true)).toBe('correct');
expect(judge('아이스크림인가요?', ice, true)).toBe('correct');
expect(judge('🍦아이스크림🍦', ice, true)).toBe('correct');
expect(normalize('A B·C…')).toBe('abc');
});
test('초성만(ㅇㅇㅅㅋㄹ)은 정답 아님 + 비공개, 낱자 ㅅㅏㄱㅘ도 정답 아님', () => {
expect(judge('ㅇㅇㅅㅋㄹ', ice, true)).toBe('chosung');
expect(judge('ㅇ ㅇ ㅅ ㅋ ㄹ', ice, true)).toBe('chosung');
expect('ㅅㅏㄱㅘ'.normalize('NFKC')).toBe('사과'); // why the raw check must come first
expect(judge('ㅅㅏㄱㅘ', ['사과'], true)).not.toBe('correct');
expect(judge('ㅋㅋㅋ', ['사과'], true)).toBe('plain');
expect(chosung('아이스크림')).toBe('ㅇㅇㅅㅋㄹ');
});
test('끝말 허용이 정답 자체를 깎지 않음: 바다 ← "바" 오답, "바다요" 정답', () => {
expect(judge('바', ['바다'], true)).not.toBe('correct');
expect(judge('바다요', ['바다'], true)).toBe('correct');
expect(judge('바다다', ['바다'], true)).toBe('correct');
expect(judge('바다야', ['바다'], true)).not.toBe('correct');
});
test('별칭도 정답', () => {
expect(judge('핸드폰', ['휴대폰', '핸드폰', '휴대전화', '스마트폰'], true)).toBe('correct');
});
test('정답이 들어간 말은 contains, 아까워요는 자모 거리 1(3글자 이하)/2(4글자 이상)', () => {
expect(judge('사과나무', ['사과'], true)).toBe('contains');
expect(judge('사과나무 아님', ['사과'], true)).toBe('contains');
expect(judge('코끼라', ['코끼리'], true)).toBe('close');
expect(judge('코끼라', ['코끼리'], false)).toBe('plain');
expect(judge('코기리', ['코끼리'], true)).toBe('close');
expect(judge('고기리', ['코끼리'], true)).toBe('plain'); // distance 2 on a 3-letter word
expect(judge('아이스크룸', ['아이스크림'], true)).toBe('close');
expect(judge('아이스크름', ['아이스크림'], true)).toBe('close');
expect(judge('하마', ['코끼리'], true)).toBe('plain');
});
});
// ---------------------------------------------------------------- chat visibility
describe('채팅 공개 범위(§5.3, §8.3)', () => {
test('초성 입력은 본인만 보고, 정답 포함 메시지는 막고 안내, 아까워요는 본인에게만', () => {
let s = drawWith(make(4), '아이스크림');
const [a, b, c] = guessers(s) as [string, string, string];
s = act(s, a, guess('ㅇㅇㅅㅋㄹ'), T0 + 2000);
s = act(s, b, guess('아이스크림 케이크'), T0 + 2100);
s = act(s, c, guess('아이스크룸'), T0 + 2200);
const va = view(s, a);
const vb = view(s, b);
const vc = view(s, c);
const vd = view(s, s.turn.drawer);
// a sees own blocked chosung line; nobody else does.
expect(va.chat.find((m) => m.from === a)).toMatchObject({ kind: 'blocked', note: 'chosung' });
for (const v of [vb, vc, vd, view(s, null)]) expect(v.chat.some((m) => m.from === a)).toBe(false);
expect(vb.chat.find((m) => m.from === b)).toMatchObject({ kind: 'blocked', note: 'contains', text: '아이스크림 케이크' });
for (const v of [va, vc, vd]) expect(v.chat.some((m) => m.from === b)).toBe(false);
// close: sender sees the "아까워요" kind, others see a normal chat line.
expect(vc.chat.find((m) => m.from === c)).toMatchObject({ kind: 'close', text: '아이스크룸' });
for (const v of [va, vb, vd, view(s, null)]) expect(v.chat.find((m) => m.from === c)).toMatchObject({ kind: 'chat', text: '아이스크룸' });
expect(s.turn.solvers).toHaveLength(0);
});
test('정답은 "맞혔어요" 줄로 바뀌고, 맞힌 사람의 채팅은 출제자·맞힌 사람에게만', () => {
let s = drawWith(make(4), '사과');
const [a, b, c] = guessers(s) as [string, string, string];
s = act(s, a, guess('사과요'), T0 + 2000);
expect(s.turn.solvers.map((x) => x.id)).toEqual([a]);
expect(view(s, b).chat.at(-1)).toMatchObject({ from: a, kind: 'correct', text: '' });
s = act(s, a, guess('빨간 거 맞죠'), T0 + 2500);
expect(view(s, b).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false);
expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false);
expect(view(s, s.turn.drawer).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true });
expect(view(s, a).word).toBe('사과');
expect(view(s, b).word).toBeNull();
expect(view(s, a).role).toBe('solver');
s = act(s, b, guess('사과'), T0 + 3000);
expect(view(s, b).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true });
expect(view(s, c).word).toBeNull();
s = act(s, c, guess('사과'), T0 + 3500);
// Everyone got it → reveal: everyone sees the word and the solvers' chat.
expect(s.turn.phase).toBe('reveal');
expect(view(s, null).word).toBe('사과');
expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(true);
});
test('출제자는 그리는 동안 정답 입력·채팅 불가, 반응 버튼만', () => {
const s = drawWith(make(3), '사과');
expect(reason(s, s.turn.drawer, guess('사과'))).toContain('출제자');
expect(reason(s, s.turn.drawer, { type: 'chat', text: '안녕' })).not.toBeNull();
const r = act(s, s.turn.drawer, { type: 'reaction', code: 'almost' }, T0 + 2000);
expect(view(r, guessers(r)[0]!).chat.at(-1)).toMatchObject({ kind: 'reaction', text: 'almost' });
});
test('글자 수 제한·초당 3개 제한', () => {
const s = drawWith(make(3), '사과');
const g = guessers(s)[0]!;
expect(reason(s, g, guess('가'.repeat(41)))).toContain('40자');
expect(reason(s, g, guess(' '))).not.toBeNull();
let t = s;
for (let i = 0; i < 3; i++) t = act(t, g, guess(`배${i}`), T0 + 2000 + i * 100);
expect(reason(t, g, guess('배3'), T0 + 2300)).toContain('천천히');
expect(reason(t, g, guess('배3'), T0 + 3000)).toBeNull();
});
});
// ---------------------------------------------------------------- §6 scores
describe('점수(§6)', () => {
test('multi: 4명 순서대로 맞히면 10/8/6/4, 출제자 8점', () => {
let s = drawWith(make(5), '기린');
const d = s.turn.drawer;
const gs = guessers(s);
gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 500)));
expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4]);
expect(s.scores[d]).toBe(8);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('allSolved');
expect(view(s, null).turnPoints).toMatchObject({ [d]: 8, [gs[0]!]: 10, [gs[3]!]: 4 });
});
test('multi: 다섯 번째부터 4점, 출제자 최대 12점', () => {
let s = drawWith(make(8), '기린');
const d = s.turn.drawer;
const gs = guessers(s);
gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 400)));
expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4, 4, 4, 4]);
expect(s.scores[d]).toBe(12);
});
test('multi: 아무도 못 맞히면 출제자 0점, 시간 만료로 공개', () => {
let s = drawWith(make(3), '기린');
const d = s.turn.drawer;
s = act(s, d, { type: 'tick' }, s.turn.endsAt);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('time');
expect(s.scores[d]).toBe(0);
expect(view(s, guessers(s)[0]!).word).toBe('기린');
});
test('firstOnly: 첫 정답 즉시 턴 종료, 정답자 10·출제자 5', () => {
let s = drawWith(make(4, { guessMode: 'firstOnly' }), '기린');
const d = s.turn.drawer;
const [a, b] = guessers(s) as [string, string];
s = act(s, a, guess('기린'), T0 + 2000);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('first');
expect(s.scores[a]).toBe(10);
expect(s.scores[d]).toBe(5);
expect(reason(s, b, guess('기린'))).not.toBeNull();
});
test('speedBonus: 남은 시간 비율 × 5점 추가', () => {
let s = drawWith(make(3, { speedBonus: true }), '기린', T0);
const g = guessers(s)[0]!;
const dur = s.turn.endsAt - s.turn.startedAt;
s = act(s, g, guess('기린'), T0 + dur / 2);
expect(s.scores[g]).toBe(10 + 3); // round(2.5)
});
test('신고 과반 → 즉시 종료, 출제자 0점(맞힌 사람 점수 유지)', () => {
let s = drawWith(make(5), '기린');
const d = s.turn.drawer;
const [a, b, c] = guessers(s) as [string, string, string];
s = act(s, a, guess('기린'), T0 + 2000);
// 4 voters → 3 needed (more than half), solvers count too.
expect(view(s, b).reportsNeeded).toBe(3);
s = act(s, a, { type: 'report' }, T0 + 2100);
s = act(s, b, { type: 'report' }, T0 + 2200);
expect(reason(s, b, { type: 'report' })).toContain('이미');
expect(s.turn.phase).toBe('drawing');
s = act(s, c, { type: 'report' }, T0 + 2300);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('report');
expect(s.scores[d]).toBe(0);
expect(s.scores[a]).toBe(10);
expect(reason(drawWith(make(3, { letterReport: false }), '기린'), 'p1', { type: 'report' })).not.toBeNull();
});
});
// ---------------------------------------------------------------- §5.4 hints
describe('힌트(§5.4)', () => {
test('50%에 초성, 75%에 한 글자(RNG 위치)', () => {
let s = drawWith(make(3, { drawSeconds: 80 }), '아이스크림', T0);
const g = guessers(s)[0]!;
const d = s.turn.drawer;
expect(view(s, g).hint).toEqual(['○', '○', '○', '○', '○']);
expect(cm.deadline!(s)).toBe(T0 + 40_000);
s = act(s, d, { type: 'tick' }, T0 + 40_000);
expect(view(s, g).hint).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ']);
expect(cm.deadline!(s)).toBe(T0 + 60_000);
s = act(s, d, { type: 'tick' }, T0 + 60_000);
const h = view(s, g).hint!;
const i = s.turn.revealIndex!;
expect(h[i]).toBe('아이스크림'[i]);
expect(h.filter((x, k) => k !== i)).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ'].filter((_, k) => k !== i));
expect(cm.deadline!(s)).toBe(T0 + 80_000);
expect(s.turn.phase).toBe('drawing');
});
test('2글자 단어는 75% 단계 생략, 띄어쓰기 위치 표시', () => {
let s = drawWith(make(3), '기린', T0);
s = act(s, s.turn.drawer, { type: 'tick' }, T0 + 40_000);
expect(view(s, null).hint).toEqual(['ㄱ', 'ㄹ']);
expect(cm.deadline!(s)).toBe(s.turn.endsAt);
const sp = drawWith(make(3), '사과 나무', T0);
expect(view(sp, null).hint).toEqual(['○', '○', ' ', '○', '○']);
});
test('basic은 글자 수만, none은 없음', () => {
let s = drawWith(make(3, { hints: 'basic' }), '아이스크림', T0);
expect(cm.deadline!(s)).toBe(s.turn.endsAt);
expect(view(s, null).hint).toEqual(['○', '○', '○', '○', '○']);
s = drawWith(make(3, { hints: 'none' }), '아이스크림', T0);
expect(view(s, null).hint).toBeNull();
});
});
// ---------------------------------------------------------------- §5.5 drawing
describe('그림 검증(§5.5)', () => {
const begin = (s: number): CanvasOp => ({ k: 'b', s, c: 0, w: 1, e: 0 });
test('출제자 아닌 사람, 좌표 1001, 점 65개, 획 번호 건너뛰기, 초당 31번째 메시지 거부', () => {
let s = drawWith(make(3), '기린', T0);
const d = s.turn.drawer;
const g = guessers(s)[0]!;
expect(reason(s, g, draw(begin(1)))).toContain('출제자');
expect(reason(s, d, draw(begin(2)))).toContain('획 번호');
s = act(s, d, draw(begin(1)), T0 + 10);
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1001, 5] }))).not.toBeNull();
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [999, 5, 2, 0] }))).not.toBeNull(); // 1001 via delta
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(130).fill(1) }))).toContain('점 개수');
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(128).fill(1) }))).toBeNull();
expect(reason(s, d, draw({ k: 'p', s: 1, q: 1, d: [5, 5] }))).toContain('순서');
expect(reason(s, d, draw({ k: 'p', s: 2, q: 0, d: [5, 5] }))).toContain('획 번호');
s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [0, 1000, 3, -4] }), T0 + 20);
expect(s.turn.totalPoints).toBe(2);
s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 30);
// 30 messages within one second are fine, the 31st is not.
let t = s;
let k = 2;
for (; t.turn.drawTimes.length < 30; k++) t = act(t, d, draw(begin(k)), T0 + 100 + k);
expect(reason(t, d, draw(begin(k)), T0 + 1009)).toContain('너무 빨리');
expect(reason(t, d, draw(begin(k)), T0 + 1010)).toBeNull();
// Not while choosing / reveal.
expect(reason(make(3), 'p1', draw(begin(1)))).not.toBeNull();
});
test('턴당 누적 점 30,000 초과 거부', () => {
let s = drawWith(make(3), '기린', T0);
const d = s.turn.drawer;
s = act(s, d, draw(begin(1)), T0);
s = { ...s, turn: { ...s.turn, totalPoints: 29_999 } };
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1, 1, 1] }))).toContain('다 썼어요');
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1] }))).toBeNull();
});
test('되돌리기·전체 지우기, canvasOp 이벤트만 나가고 view에는 개수만(전체 그림은 syncView)', () => {
let s = drawWith(make(3), '기린', T0);
const d = s.turn.drawer;
expect(reason(s, d, draw({ k: 'u' }))).not.toBeNull();
const before = s;
const r = applyE(s, d, draw(begin(1)), T0 + 1);
expect(r.events).toEqual([{ type: 'canvasOp', turn: s.turn.no, n: 0, op: begin(1) }]);
expect(before.turn.canvas).toHaveLength(0); // previous state untouched
s = r.state;
s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [10, 10, 5, 5] }), T0 + 2);
s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 3);
s = act(s, d, draw(begin(2)), T0 + 4);
s = act(s, d, draw({ k: 'u' }), T0 + 5);
expect(s.turn.live).toBe(1);
s = act(s, d, draw({ k: 'x' }), T0 + 6);
expect(s.turn.live).toBe(0);
expect(reason(s, d, draw({ k: 'x' }))).not.toBeNull();
expect(cm.isStreamAction!(draw({ k: 'x' }))).toBe(true);
expect(cm.isStreamAction!(guess('a'))).toBe(false);
const g = guessers(s)[0]!;
expect(view(s, g).canvas).toBeNull();
expect(view(s, g).canvasCount).toBe(6);
expect(cm.syncView!(s, g).canvas).toEqual(s.turn.canvas);
expect(cm.syncView!(s, null).canvas).toHaveLength(6);
});
});
// ---------------------------------------------------------------- flow
describe('진행(§5.1, §5.2, §8.5)', () => {
test('고르기 10초 초과 → 첫 단어, 그리기·공개·다음 출제자, 라운드 진행과 종료', () => {
let s = make(2, { rounds: 1 });
const first = s.turn.drawer;
expect(s.turn.choices).toHaveLength(3);
expect(cm.deadline!(s)).toBe(T0 + CHOOSE_MS);
const c0 = s.turn.choices[0]!.word;
s = act(s, first, { type: 'tick' }, T0 + CHOOSE_MS);
expect(s.turn.phase).toBe('drawing');
expect(s.turn.word!.word).toBe(c0);
expect(s.away).toEqual([]);
s = act(s, first, { type: 'tick' }, s.turn.endsAt);
expect(s.turn.phase).toBe('reveal');
expect(cm.deadline!(s)).toBe(s.turn.startedAt + REVEAL_MS);
s = act(s, first, { type: 'tick' }, s.turn.endsAt);
expect(s.turn.drawer).not.toBe(first);
expect(s.turn.no).toBe(2);
const second = s.turn.drawer;
s = act(s, second, { type: 'chooseWord', index: 2 }, s.turn.startedAt + 1000);
s = act(s, second, { type: 'tick' }, s.turn.endsAt);
s = act(s, second, { type: 'tick' }, s.turn.endsAt);
expect(s.finished).toBe(true);
const r = cm.result(s)!;
expect(r.ranking.flat().sort()).toEqual(['p1', 'p2']);
expect(r.summary).toContain('1등');
expect(s.final!.turns.map((t) => t.drawer)).toEqual([first, second]);
});
test('wordChoice off → 바로 그리기(무작위 1개)', () => {
const s = make(3, { wordChoice: false });
expect(s.turn.phase).toBe('drawing');
expect(s.turn.word).not.toBeNull();
expect(view(s, s.turn.drawer).choices).toBeNull();
});
test('출제자 연결 끊김 10초 → 턴 종료(출제자 0점, 정답 공개), 다음 출제 차례는 건너뜀', () => {
let s = make(3, { rounds: 2 });
const d = s.turn.drawer;
// The room times out a disconnected drawer at once while choosing (disconnectGraceMs 0) → auto pick + away mark.
expect(cm.disconnectGraceMs!(s)).toBe(0);
s = act(s, d, { type: 'tick' }, T0 + 500);
expect(s.turn.phase).toBe('drawing');
expect(s.away).toEqual([d]);
expect(cm.disconnectGraceMs!(s)).toBe(10_000);
expect(cm.timeoutNotBefore!(s)).toBe(T0 + 500 + 10_000);
s = act(s, guessers(s)[0]!, guess(s.turn.word!.word), T0 + 3000);
s = act(s, d, { type: 'tick' }, T0 + 10_500);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('drawerAway');
expect(s.scores[d]).toBe(0);
expect(view(s, guessers(s)[1]!).word).toBe(s.turn.word!.word);
// Skipped while away: the next drawers are the others.
const seen: string[] = [];
for (let i = 0; i < 2; i++) {
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
seen.push(s.turn.drawer);
s = act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, s.turn.startedAt + 100);
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
}
expect(seen).not.toContain(d);
// "돌아왔어요" clears the mark.
expect(reason(s, d, { type: 'back' })).toBeNull();
s = act(s, d, { type: 'back' }, s.turn.endsAt - 1);
expect(s.away).toEqual([]);
expect(reason(s, d, { type: 'back' })).not.toBeNull();
});
test('나간 사람: 출제자가 나가면 턴 종료, 2명 미만이면 게임 종료', () => {
let s = drawWith(make(3), '기린');
const d = s.turn.drawer;
expect(cm.onLeave!(s, d)).toEqual({ type: 'leave' });
s = act(s, d, { type: 'leave' }, T0 + 3000);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('drawerLeft');
expect(cm.onLeave!(s, d)).toBeNull();
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
expect(s.turn.drawer).not.toBe(d);
const other = s.order.find((p) => p !== d && p !== s.turn.drawer)!;
s = act(s, other, { type: 'leave' }, s.turn.startedAt + 100);
expect(s.finished).toBe(true);
const r = cm.result(s)!;
expect(r.ranking.at(-1)).toContain(d);
});
test('남은 맞히는 사람이 모두 맞힌 상태에서 못 맞힌 사람이 나가면 바로 공개', () => {
let s = drawWith(make(4), '기린');
const [a, b, c] = guessers(s) as [string, string, string];
s = act(s, a, guess('기린'), T0 + 2000);
s = act(s, b, guess('기린'), T0 + 2100);
s = act(s, c, { type: 'leave' }, T0 + 2200);
expect(s.turn.phase).toBe('reveal');
expect(s.turn.endReason).toBe('allSolved');
});
test('answererDraws(firstOnly): 정답자가 다음 출제자', () => {
let s = drawWith(make(4, { guessMode: 'firstOnly', answererDraws: true }), '기린');
const g = guessers(s)[2]!;
s = act(s, g, guess('기린'), T0 + 2000);
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
expect(s.turn.drawer).toBe(g);
});
test('최종 순위: 총점 → 정답 횟수 → 공동 순위', () => {
const s = structuredClone(make(4));
s.scores = { p1: 20, p2: 20, p3: 20, p4: 5 };
s.correctCounts = { p1: 1, p2: 2, p3: 1, p4: 0 };
s.turn.phase = 'reveal';
s.turnsPlayed = s.totalTurns - 1;
s.round = s.options.rounds;
s.turn.drawer = s.order.at(-1)!;
const r = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
expect(r.finished).toBe(true);
expect(cm.result(r)!.ranking.map((g) => [...g].sort())).toEqual([['p2'], ['p1', 'p3'], ['p4']]);
expect(r.final!.ranking.map((x) => x.rank)).toEqual([1, 2, 2, 4]);
});
test('단어 풀: 분야·난이도·추가 단어(필터), 부족하면 시작 거절', () => {
expect(buildPool(opts()).length).toBe(BUILTIN_WORDS.length);
expect(BUILTIN_WORDS.length).toBeGreaterThanOrEqual(600);
const animalsEasy = buildPool(opts({ categories: { ...opts().categories, food: false, object: false, vehicle: false, nature: false, place: false, job: false, sport: false, body: false, season: false }, difficulty: 'easy' }));
expect(animalsEasy.every((w) => w.category === 'animal' && w.difficulty === 'easy')).toBe(true);
const custom = opts({ customOnly: true, customWords: '우리집 강아지\n돌멩이\n돌멩이\nABC\n시발\n가나다라마바사아자차카\n붕어빵' });
expect(buildPool(custom).map((w) => w.word)).toEqual(['돌멩이', 'ABC', '붕어빵']);
expect(cm.playerCountError!(custom, 3)).toContain('추가 단어');
expect(cm.playerCountError!(opts(), 10)).toBeNull();
expect(cm.view(make(2, { customWords: '돌멩이' }), 'p1').options.customWordCount).toBe(1);
});
});
// ---------------------------------------------------------------- leaks
/** Secrets a non-solver guesser / spectator must never see during drawing (§8.3, §10). */
function hiddenFrom(s: CatchmindState): string[] {
const t = s.turn;
// The next few pool words (checking all ~700 on every step would dominate the run time).
const out = s.wordPool.slice(0, 12).map((w) => w.word);
if (t.phase === 'choosing') out.push(...t.choices.map((w) => w.word));
if (t.phase === 'drawing' && t.word) out.push(t.word.word, ...t.word.aliases);
return out;
}
function checkLeaks(s: CatchmindState, events: GameEvent[] = []): void {
if (s.finished || s.turn.phase === 'reveal') return;
const secret = hiddenFrom(s);
const solversChat = s.chat.filter((m) => m.turn === s.turn.no && m.audience === 'solvers').map((m) => m.id);
const evJson = JSON.stringify(events);
for (const w of secret) if (evJson.includes(JSON.stringify(w))) throw new Error(`event leaks ${w}`);
const outsiders: (PlayerId | null)[] = [null, ...s.order.filter((p) => p !== s.turn.drawer && !s.turn.solvers.some((x) => x.id === p))];
for (const p of outsiders) {
for (const v of [cm.view(s, p), cm.syncView!(s, p)]) {
const json = JSON.stringify(v);
for (const w of secret) {
// Their own chat lines may legitimately contain anything they typed.
if (json.includes(JSON.stringify(w))) throw new Error(`view of ${p} leaks "${w}"`);
}
if (v.word !== null || v.choices !== null || v.category !== null) throw new Error(`view of ${p} has the answer`);
if (v.chat.some((m) => solversChat.includes(m.id))) throw new Error(`view of ${p} has solvers chat`);
if (v.chat.some((m) => m.from !== p && (m.kind === 'close' || m.kind === 'blocked'))) throw new Error(`view of ${p} has others' private lines`);
}
}
}
// ---------------------------------------------------------------- random playouts
interface Run {
state: CatchmindState;
trace: { p: PlayerId; a: CatchmindAction; now: number }[];
steps: number;
}
const CHEAT = ['ㅇ', '아까', '모르겠어요', '정답?'];
/** Fast loop: random actor (anyone, not just active), random legal action incl. near-misses; 0.2–3s per step. */
function fastPlayout(players: PlayerId[], options: CatchmindOptions, seed: RngState, maxSteps: number, leaks: boolean): Run {
const rng = SeededRng.fromSeed(seed);
const pick = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = cm.setup({ players, options, rng, now });
const poolSize = s.wordPool.length + s.turn.choices.length + (s.turn.word ? 1 : 0);
const trace: Run['trace'] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s, poolSize);
if (cm.result(s)) return { state: s, trace, steps: step };
// Timer: the drawer's tick when due (as the room would), otherwise a random actor.
const due = cm.deadline!(s)!;
let p: PlayerId;
let a: CatchmindAction;
if (now >= due || pick.int(12) === 0) {
p = cm.timeoutPlayers!(s)[0]!;
a = cm.onTimeout(s, p);
now = Math.max(now, Math.min(due, now + 12_000));
} else {
p = players[pick.int(players.length)]!;
const legal = cm.legalActions!(s, p);
if (legal.length === 0) continue;
a = legal[pick.int(legal.length)]!;
if (a.type === 'guess' && pick.int(3) === 0) a = { type: 'guess', text: CHEAT[pick.int(CHEAT.length)]! };
}
const v = cm.validate(s, p, a, { now });
if (!v.ok) {
// Rate limits / time-up may reject; nothing else should.
if (/천천히|너무 빨리|시간이 다/.test(v.reason)) {
now += 400;
continue;
}
throw new Error(`rejected ${p} ${JSON.stringify(a)}: ${v.reason}`);
}
const r = cm.apply(s, p, a, { rng, now });
if (leaks) checkLeaks(r.state, r.events);
s = r.state;
trace.push({ p, a, now });
now += 200 + pick.int(2800);
}
return { state: s, trace, steps: maxSteps };
}
function invariants(s: CatchmindState, poolSize: number): void {
const t = s.turn;
// Word conservation: every pool word is in exactly one place.
const used = s.history.map((h) => h.word).filter(Boolean);
const current = t.phase !== 'reveal' && t.word ? [t.word.word] : [];
const all = [...s.wordPool.map((w) => w.word), ...s.spare.map((w) => w.word), ...t.choices.map((w) => w.word), ...current, ...used];
if (all.length !== poolSize) throw new Error(`word count ${all.length} != ${poolSize}`);
if (new Set(all).size !== all.length) throw new Error('word duplicated');
// Score conservation.
const total = Object.values(s.scores).reduce((x, y) => x + y, 0);
let expected = t.phase === 'reveal' || s.finished ? 0 : t.solvers.reduce((x, y) => x + y.points, 0);
for (const h of s.history) expected += h.drawerPoints + h.solverPoints.reduce((x, y) => x + y, 0);
if (total !== expected) throw new Error(`scores ${total} != ${expected}`);
const correct = Object.values(s.correctCounts).reduce((x, y) => x + y, 0);
if (correct !== s.history.reduce((x, h) => x + h.solvers.length, 0) + (t.phase === 'reveal' || s.finished ? 0 : t.solvers.length)) throw new Error('correct count');
if (t.totalPoints > 30_000 || t.live < 0) throw new Error('canvas limits');
if (s.round > s.options.rounds) throw new Error('round overflow');
}
describe('무작위 대국(§10, docs/09 §7)', () => {
test('1,000판: 예외 없음, 단어·점수 보존, 종료, 결정성', () => {
const t0 = performance.now();
let finished = 0;
for (let i = 0; i < 1000; i++) {
const pk = SeededRng.fromSeed(seedN(9000 + i));
const n = 2 + pk.int(9);
const options = opts({
guessMode: pk.int(3) === 0 ? 'firstOnly' : 'multi',
rounds: (1 + pk.int(3)) as 1 | 2 | 3,
drawSeconds: ([60, 80, 100, 120] as const)[pk.int(4)]!,
wordChoice: pk.int(4) !== 0,
hints: (['none', 'basic', 'full'] as const)[pk.int(3)]!,
letterReport: pk.int(3) !== 0,
answererDraws: pk.int(2) === 0,
speedBonus: pk.int(3) === 0,
difficulty: (['easy', 'normal', 'mixed'] as const)[pk.int(3)]!,
});
const run = fastPlayout(P(n), options, seedN(i), 4000, i < 150);
if (run.state.finished) finished++;
else throw new Error(`seed ${i}: not finished after ${run.steps} steps`);
if (i % 100 === 0) {
const again = fastPlayout(P(n), options, seedN(i), 4000, false);
expect(JSON.stringify(again.state)).toBe(JSON.stringify(run.state));
}
}
expect(finished).toBe(1000);
expect(performance.now() - t0).toBeLessThan(55_000);
}, 60_000);
test('엔진 randomPlayout(매 단계 직렬화·secretsOf 누출 검사)', () => {
for (let i = 0; i < 12; i++) {
const n = 2 + (i % 9);
const r = randomPlayout(cm, { players: P(n), options: opts({ rounds: 1 }), seed: seedN(700 + i), maxSteps: 5000, maxApplyMs: 20 });
expect(r.finished).toBe(true);
}
}, 60_000);
test('같은 시드 + 액션 로그 재생 → 점수·그림 기록 동일', () => {
const options = opts({ rounds: 2 });
const run = fastPlayout(P(5), options, seedN(77), 4000, false);
const rngR = SeededRng.fromSeed(seedN(77));
let s = cm.setup({ players: P(5), options, rng: rngR, now: 1_700_000_000_000 });
const canvases: string[] = [];
for (const { p, a, now } of run.trace) {
expect(cm.validate(s, p, a, { now }).ok).toBe(true);
s = cm.apply(s, p, a, { rng: rngR, now }).state;
canvases.push(JSON.stringify(s.turn.canvas));
}
expect(s.scores).toEqual(run.state.scores);
expect(JSON.stringify(s)).toBe(JSON.stringify(run.state));
expect(canvases.some((c) => c.length > 50)).toBe(true);
});
test('같은 단어는 한 게임에서 두 번 나오지 않음(풀이 딱 맞을 때 포함)', () => {
const words = Array.from({ length: 6 }, (_, i) => `단어${'가나다라마바'[i]}`).join('\n');
for (let i = 0; i < 40; i++) {
const run = fastPlayout(P(3), opts({ customOnly: true, customWords: words, rounds: 2 }), seedN(300 + i), 4000, false);
const answers = run.state.history.map((h) => h.word).filter(Boolean);
expect(new Set(answers).size).toBe(answers.length);
expect(run.state.finished).toBe(true);
}
});
test('onTimeout은 모든 상태에서 합법', () => {
for (let i = 0; i < 40; i++) {
const run = fastPlayout(P(2 + (i % 5)), opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), seedN(50 + i), 4000, false);
const rngR = SeededRng.fromSeed(seedN(50 + i));
let s = cm.setup({ players: P(2 + (i % 5)), options: opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), rng: rngR, now: 1_700_000_000_000 });
for (const { p, a, now } of run.trace) {
for (const q of cm.timeoutPlayers!(s)) expect(cm.validate(s, q, cm.onTimeout(s, q), { now }).ok).toBe(true);
s = cm.apply(s, p, a, { rng: rngR, now }).state;
}
}
}, 30_000);
});

View File

@@ -0,0 +1,791 @@
/** 그림 맞히기 (docs/games/catchmind.md). Pure and deterministic; state is JSON; time only via ctx.now. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import WORDS from './words.ko.json';
import { cleanText, initialOf, judge, maskBanned, normalize, parseCustomWords } from './text';
import {
BOARD,
CATEGORIES,
COLOR_COUNT,
DRAW_MSGS_PER_SEC,
GUESS_MSGS_PER_SEC,
MAX_BATCH_POINTS,
MAX_TURN_OPS,
MAX_TURN_POINTS,
WIDTHS,
catchmindActionSchema,
catchmindOptionsSchema,
type CanvasOp,
type CatchmindAction,
type CatchmindFinal,
type CatchmindOptions,
type CatchmindState,
type CatchmindView,
type ChatEntry,
type ChatView,
type RankRow,
type Turn,
type TurnEndReason,
type WordEntry,
} from './types';
export * from './types';
export { judge, normalize, chosung, jamo, levenshtein, parseCustomWords, isCompatJamoOnly } from './text';
type S = CatchmindState;
type A = CatchmindAction;
export const CHOOSE_MS = 10_000;
export const REVEAL_MS = 5_000;
/** §5.2 / §8.5: a disconnected drawer ends the turn after this long. */
export const DRAWER_AWAY_MS = 10_000;
export const GUESS_MAX = 40;
export const CHAT_MAX = 100;
const CHAT_KEEP = 80;
const CHAT_VIEW = 50;
export const BUILTIN_WORDS = WORDS as WordEntry[];
// ---------------------------------------------------------------- word pool
/** §4.2: the words a game may use with these options (built-in filtered + host words), before shuffling. */
export function buildPool(o: CatchmindOptions): WordEntry[] {
const custom: WordEntry[] = parseCustomWords(o.customWords).map((word) => ({ word, aliases: [], category: 'custom', difficulty: 'normal' }));
if (o.customOnly) return custom;
const anyCat = CATEGORIES.some((c) => o.categories[c]);
const builtin = BUILTIN_WORDS.filter(
(w) => (!anyCat || o.categories[w.category as (typeof CATEGORIES)[number]]) && (o.difficulty === 'mixed' || w.difficulty === o.difficulty),
);
const seen = new Set(builtin.flatMap((w) => [w.word, ...w.aliases].map(normalize)));
return [...builtin, ...custom.filter((w) => !seen.has(normalize(w.word)))];
}
const turnsFor = (o: CatchmindOptions, players: number): number => o.rounds * players;
function takeWords(s: S, n: number): WordEntry[] {
const out: WordEntry[] = [];
while (out.length < n && s.wordPool.length > 0) out.push(s.wordPool.shift()!);
while (out.length < n && s.spare.length > 0) out.push(s.spare.shift()!);
if (out.length === 0) throw new Error('catchmind: word pool exhausted');
return out;
}
// ---------------------------------------------------------------- helpers
const answersOf = (w: WordEntry): string[] => [w.word, ...w.aliases];
const isSolver = (t: Turn, p: PlayerId): boolean => t.solvers.some((x) => x.id === p);
const letters = (word: string): number => [...word].filter((c) => !/\s/u.test(c)).length;
function clone(s: S): S {
const t = s.turn;
return {
...s,
scores: { ...s.scores },
correctCounts: { ...s.correctCounts },
wordPool: [...s.wordPool],
spare: [...s.spare],
turn: { ...t, choices: [...t.choices], solvers: [...t.solvers], reports: [...t.reports] },
chat: [...s.chat],
msgTimes: { ...s.msgTimes },
away: [...s.away],
left: [...s.left],
history: [...s.history],
};
}
interface Ctx {
now: number;
rng: Rng;
events: GameEvent[];
}
function addChat(s: S, e: Omit<ChatEntry, 'id' | 'turn'>): void {
s.chatSeq += 1;
s.chat.push({ id: s.chatSeq, turn: s.turn.no, ...e });
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
}
function markAway(s: S, p: PlayerId): void {
if (!s.away.includes(p)) s.away.push(p);
}
/** Players whose guesses the drawing waits for (§5.2 multi; disconnected/away and left players excluded, §8.5). */
function waitingGuessers(s: S): PlayerId[] {
return s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p) && !s.away.includes(p));
}
export function reportsNeeded(s: S): number {
const voters = s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p)).length;
return Math.floor(voters / 2) + 1;
}
/** Next hint time while drawing (`hints: full` only): 50% → initials, 75% → one letter (skipped for ≤ 2 letters). */
export function nextHintAt(s: S): number | null {
const t = s.turn;
if (s.finished || t.phase !== 'drawing' || s.options.hints !== 'full' || !t.word) return null;
const dur = t.endsAt - t.startedAt;
if (t.hintStage === 0) return t.startedAt + Math.round(dur * 0.5);
if (t.hintStage === 1 && letters(t.word.word) > 2) return t.startedAt + Math.round(dur * 0.75);
return null;
}
function rateOk(times: readonly number[] | undefined, perSec: number, now: number | undefined): boolean {
if (now === undefined || !times || times.length < perSec) return true;
return now - times[times.length - perSec]! >= 1000;
}
function stamp(s: S, p: PlayerId, now: number): void {
s.msgTimes[p] = [...(s.msgTimes[p] ?? []).slice(-(GUESS_MSGS_PER_SEC - 1)), now];
}
// ---------------------------------------------------------------- turn flow
function newTurn(no: number, drawer: PlayerId, choices: WordEntry[], now: number): Turn {
return {
no,
phase: 'choosing',
drawer,
choices,
word: null,
startedAt: now,
endsAt: now + CHOOSE_MS,
hintStage: 0,
revealIndex: null,
solvers: [],
reports: [],
canvas: [],
totalPoints: 0,
lastStroke: 0,
lastBatch: -1,
open: false,
live: 0,
drawTimes: [],
drawerPoints: 0,
endReason: null,
};
}
function startTurn(s: S, drawer: PlayerId, ctx: Ctx): void {
const choices = takeWords(s, s.options.wordChoice ? 3 : 1);
s.turn = newTurn(s.turnsPlayed + 1, drawer, choices, ctx.now);
ctx.events.push({ type: 'turn', no: s.turn.no, drawer, round: s.round });
if (!s.options.wordChoice) beginDrawing(s, 0, ctx);
}
function beginDrawing(s: S, index: number, ctx: Ctx): void {
const t = s.turn;
t.word = t.choices[index]!;
s.spare.push(...t.choices.filter((_, i) => i !== index));
t.choices = [];
t.phase = 'drawing';
t.startedAt = ctx.now;
t.endsAt = ctx.now + s.options.drawSeconds * 1000;
t.hintStage = 0;
ctx.events.push({ type: 'drawing', no: t.no, drawer: t.drawer });
}
function advanceHint(s: S, ctx: Ctx): void {
const t = s.turn;
if (t.hintStage === 0) {
t.hintStage = 1;
} else if (t.hintStage === 1) {
const idx = [...t.word!.word].map((c, i) => (/\s/u.test(c) ? -1 : i)).filter((i) => i >= 0);
t.revealIndex = idx[ctx.rng.int(idx.length)]!;
t.hintStage = 2;
}
ctx.events.push({ type: 'hint', stage: t.hintStage });
}
/** Drawing (or choosing) is over: drawer points, reveal for 5 s (§5.1, §6). */
function endDrawing(s: S, reason: TurnEndReason, ctx: Ctx): void {
const t = s.turn;
const o = s.options;
let dp = 0;
if (reason !== 'report' && reason !== 'drawerAway' && reason !== 'drawerLeft') {
dp = o.guessMode === 'multi' ? Math.min(12, 2 * t.solvers.length) : t.solvers.length > 0 ? 5 : 0;
}
if (t.phase === 'choosing') {
// Nobody saw these candidates being drawn; keep them for later turns.
s.spare.push(...t.choices);
t.choices = [];
}
s.scores[t.drawer] = (s.scores[t.drawer] ?? 0) + dp;
t.drawerPoints = dp;
t.endReason = reason;
t.phase = 'reveal';
t.open = false;
t.startedAt = ctx.now;
t.endsAt = ctx.now + REVEAL_MS;
s.history.push({ no: t.no, drawer: t.drawer, word: t.word?.word ?? '', solvers: t.solvers.map((x) => x.id), solverPoints: t.solvers.map((x) => x.points), drawerPoints: dp, reason });
ctx.events.push({ type: 'reveal', no: t.no, word: t.word?.word ?? null, reason, drawerPoints: dp });
}
/** Next drawer clockwise after `from`, skipping players who left and (if anyone else is there) away players. */
function nextInOrder(s: S, from: PlayerId): { id: PlayerId; wrapped: boolean } | null {
const n = s.order.length;
const i = s.order.indexOf(from);
for (const skipAway of [true, false]) {
for (let k = 1; k <= n; k++) {
const q = s.order[(i + k) % n]!;
if (s.left.includes(q) || (skipAway && s.away.includes(q))) continue;
return { id: q, wrapped: i + k >= n };
}
}
return null;
}
function nextTurn(s: S, ctx: Ctx): void {
const o = s.options;
s.turnsPlayed += 1;
const remaining = s.order.filter((p) => !s.left.includes(p));
if (remaining.length < 2) return finish(s, ctx);
let next: PlayerId;
if (o.guessMode === 'firstOnly' && o.answererDraws) {
if (s.turnsPlayed >= s.totalTurns) return finish(s, ctx);
const first = s.turn.solvers[0]?.id;
next = first && !s.left.includes(first) && !s.away.includes(first) ? first : nextInOrder(s, s.turn.drawer)!.id;
s.round = Math.min(o.rounds, Math.floor(s.turnsPlayed / s.order.length) + 1);
} else {
const r = nextInOrder(s, s.turn.drawer)!;
if (r.wrapped) {
if (s.round >= o.rounds) return finish(s, ctx);
s.round += 1;
}
next = r.id;
}
startTurn(s, next, ctx);
}
function finish(s: S, ctx: Ctx): void {
if (s.finished) return;
s.finished = true;
s.turn.open = false;
const by = (a: PlayerId, b: PlayerId) => (s.scores[b] ?? 0) - (s.scores[a] ?? 0) || (s.correctCounts[b] ?? 0) - (s.correctCounts[a] ?? 0);
const stay = s.order.filter((p) => !s.left.includes(p)).sort(by);
const gone = s.order.filter((p) => s.left.includes(p)).sort(by);
const ranking: RankRow[] = [];
for (const group of [stay, gone]) {
group.forEach((id, i) => {
const prev = ranking[ranking.length - 1];
const tie = i > 0 && prev && prev.score === (s.scores[id] ?? 0) && prev.correct === (s.correctCounts[id] ?? 0);
ranking.push({ rank: tie ? prev.rank : ranking.length + 1, id, score: s.scores[id] ?? 0, correct: s.correctCounts[id] ?? 0 });
});
}
const top = ranking.filter((r) => r.rank === 1);
const most = Math.max(0, ...ranking.map((r) => r.correct));
let summary = top.length > 1 ? `공동 1등 ${top.length}명, ${top[0]!.score}점이에요!` : `1등은 ${top[0]?.score ?? 0}점이에요!`;
if (most > 0) summary += top.length === 1 && top[0]!.correct === most ? ` 1등이 가장 많이 맞혔어요(${most}번).` : ` 가장 많이 맞힌 횟수는 ${most}번이에요.`;
else summary += ' 이번 게임에서는 아무도 맞히지 못했어요.';
s.final = {
ranking,
turns: s.history.map((h) => ({ drawer: h.drawer, word: h.word, solvers: h.solvers })),
summary,
} satisfies CatchmindFinal;
ctx.events.push({ type: 'gameEnd' });
}
// ---------------------------------------------------------------- actions
function guess(s: S, p: PlayerId, raw: string, ctx: Ctx): void {
const t = s.turn;
const text = cleanText(raw);
stamp(s, p, ctx.now);
if (isSolver(t, p)) {
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'solvers', kind: 'chat' });
return;
}
const j = judge(text, answersOf(t.word!), s.options.closeHint);
if (j === 'correct') {
const k = t.solvers.length + 1;
let points = s.options.guessMode === 'multi' ? Math.max(10 - 2 * (k - 1), 4) : 10;
if (s.options.speedBonus) points += Math.round((5 * Math.max(0, t.endsAt - ctx.now)) / Math.max(1, t.endsAt - t.startedAt));
t.solvers.push({ id: p, at: ctx.now, points });
s.scores[p] = (s.scores[p] ?? 0) + points;
s.correctCounts[p] = (s.correctCounts[p] ?? 0) + 1;
addChat(s, { from: p, text: '', at: ctx.now, audience: 'all', kind: 'correct' });
ctx.events.push({ type: 'correct', player: p, points, rank: k });
if (s.options.guessMode === 'firstOnly') endDrawing(s, 'first', ctx);
else if (waitingGuessers(s).every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
return;
}
if (j === 'chosung' || j === 'contains') {
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'self', kind: 'blocked', note: j });
return;
}
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'all', kind: j === 'close' ? 'close' : 'chat' });
}
function tick(s: S, ctx: Ctx): void {
const t = s.turn;
if (t.phase === 'choosing') {
// Only a disconnected drawer is timed out before the 10 s are up (room grace hook): skip them next time.
if (ctx.now < t.endsAt) markAway(s, t.drawer);
beginDrawing(s, 0, ctx);
return;
}
if (t.phase === 'drawing') {
let hinted = false;
for (let h = nextHintAt(s); h !== null && ctx.now >= h; h = nextHintAt(s)) {
advanceHint(s, ctx);
hinted = true;
}
if (ctx.now >= t.endsAt) return endDrawing(s, 'time', ctx);
if (hinted) return;
// Nothing was due: the drawer has been disconnected for DRAWER_AWAY_MS (§5.2) → 0 points, answer revealed.
markAway(s, t.drawer);
return endDrawing(s, 'drawerAway', ctx);
}
nextTurn(s, ctx);
}
function leave(s: S, p: PlayerId, ctx: Ctx): void {
const t = s.turn;
s.left.push(p);
s.away = s.away.filter((q) => q !== p);
ctx.events.push({ type: 'left', player: p });
const remaining = s.order.filter((q) => !s.left.includes(q));
if (remaining.length < 2) {
if (t.phase !== 'reveal') endDrawing(s, p === t.drawer ? 'drawerLeft' : 'time', ctx);
return finish(s, ctx);
}
if (t.phase === 'reveal') return;
if (p === t.drawer) return endDrawing(s, 'drawerLeft', ctx);
if (t.phase === 'drawing') {
if (s.options.letterReport && t.reports.length >= reportsNeeded(s)) return endDrawing(s, 'report', ctx);
const waiting = waitingGuessers(s);
if (s.options.guessMode === 'multi' && t.solvers.length > 0 && waiting.every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
}
}
// ---------------------------------------------------------------- drawing ops
/** Absolute points of a `p` op (first absolute, then deltas), or null when malformed / off the board. */
export function decodePoints(d: readonly number[]): number[] | null {
if (d.length < 2 || d.length % 2 !== 0 || d.length > MAX_BATCH_POINTS * 2) return null;
const out: number[] = [];
let x = 0;
let y = 0;
for (let i = 0; i < d.length; i += 2) {
x = i === 0 ? d[0]! : x + d[i]!;
y = i === 0 ? d[1]! : y + d[i + 1]!;
if (x < 0 || x > BOARD || y < 0 || y > BOARD) return null;
out.push(x, y);
}
return out;
}
function validateDraw(s: S, actor: PlayerId, op: CanvasOp, now: number | undefined): Validation {
const t = s.turn;
if (actor !== t.drawer) return fail('그림은 출제자만 그릴 수 있어요.');
if (t.phase !== 'drawing') return fail('지금은 그릴 수 없어요.');
if (!rateOk(t.drawTimes, DRAW_MSGS_PER_SEC, now)) return fail('그림을 너무 빨리 보내고 있어요. 잠시 뒤에 이어서 그려 주세요.');
if (t.canvas.length >= MAX_TURN_OPS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
switch (op.k) {
case 'b':
if (op.s !== t.lastStroke + 1) return fail('획 번호가 맞지 않아요.');
if (op.c < 0 || op.c >= COLOR_COUNT || op.w < 0 || op.w >= WIDTHS.length) return fail('없는 색이나 굵기예요.');
return ok;
case 'p': {
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
if (op.q !== t.lastBatch + 1) return fail('점 묶음 순서가 맞지 않아요.');
const pts = decodePoints(op.d);
if (!pts) return fail(op.d.length > MAX_BATCH_POINTS * 2 || op.d.length % 2 !== 0 ? '한 번에 보낼 수 있는 점 개수를 넘었어요.' : '그림판 밖의 점이에요.');
if (t.totalPoints + pts.length / 2 > MAX_TURN_POINTS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
return ok;
}
case 'e':
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
return ok;
case 'u':
case 'x':
if (t.live <= 0) return fail('지울 그림이 없어요.');
return ok;
}
}
/** Shallow update: the canvas grows by one op; nothing else is deep-copied (§5.5 "깊은 복사하지 않는다"). */
function applyDraw(s0: S, actor: PlayerId, op: CanvasOp, now: number): { state: S; events: GameEvent[] } {
const t0 = s0.turn;
const t: Turn = { ...t0, canvas: [...t0.canvas, op], drawTimes: [...t0.drawTimes.slice(-(DRAW_MSGS_PER_SEC - 1)), now] };
switch (op.k) {
case 'b':
t.lastStroke = op.s;
t.lastBatch = -1;
t.open = true;
t.live += 1;
break;
case 'p':
t.lastBatch = op.q;
t.totalPoints += op.d.length / 2;
break;
case 'e':
t.open = false;
break;
case 'u':
t.live -= 1;
t.open = false;
break;
case 'x':
t.live = 0;
t.open = false;
break;
}
const state: S = { ...s0, turn: t, away: s0.away.includes(actor) ? s0.away.filter((p) => p !== actor) : s0.away };
return { state, events: [{ type: 'canvasOp', turn: t.no, n: t0.canvas.length, op }] };
}
// ---------------------------------------------------------------- view helpers
function hintFor(s: S): string[] | null {
const t = s.turn;
if (s.options.hints === 'none' || !t.word) return null;
const full = s.options.hints === 'full';
return [...t.word.word].map((ch, i) => {
if (/\s/u.test(ch)) return ' ';
if (full && t.hintStage >= 2 && i === t.revealIndex) return ch;
if (full && t.hintStage >= 1) {
const ini = initialOf(ch);
return ini === ch ? '○' : ini;
}
return '○';
});
}
function chatFor(s: S, me: PlayerId | null): ChatView[] {
const t = s.turn;
const insider = !!me && (me === t.drawer || isSolver(t, me));
const open = t.phase === 'reveal' || s.finished;
const out: ChatView[] = [];
for (const e of s.chat) {
if (e.audience === 'self' && e.from !== me) continue;
if (e.audience === 'solvers' && e.turn === t.no && !open && !insider) continue;
const v: ChatView = {
id: e.id,
from: e.from,
text: e.text,
at: e.at,
kind: e.kind === 'close' && e.from !== me ? 'chat' : e.kind,
solversOnly: e.audience === 'solvers',
};
if (e.note) v.note = e.note;
out.push(v);
}
return out.slice(-CHAT_VIEW);
}
function buildView(s: S, viewer: PlayerId | null, withCanvas: boolean): CatchmindView {
const t = s.turn;
const o = s.options;
const me = viewer && s.order.includes(viewer) ? viewer : null;
const isDrawer = me === t.drawer;
const solved = !!me && isSolver(t, me);
const revealed = t.phase === 'reveal' || s.finished;
const knows = isDrawer || solved || revealed;
const active = !!me && !s.left.includes(me) && !s.finished;
const drawing = t.phase === 'drawing' && !s.finished;
const { customWords, ...rest } = o;
return {
options: { ...rest, customWordCount: parseCustomWords(customWords).length },
me,
role: !me ? 'spectator' : isDrawer ? 'drawer' : solved ? 'solver' : 'guesser',
order: s.order,
scores: s.scores,
correctCounts: s.correctCounts,
round: s.round,
rounds: o.rounds,
turnNo: t.no,
turnsPlayed: s.turnsPlayed,
totalTurns: s.totalTurns,
phase: s.finished ? 'ended' : t.phase,
drawer: t.drawer,
startedAt: t.startedAt,
endsAt: t.endsAt,
phaseMs: t.endsAt - t.startedAt,
choices: isDrawer && t.phase === 'choosing' && !s.finished ? t.choices.map((w) => ({ word: w.word, category: w.category, difficulty: w.difficulty })) : null,
word: knows && t.word ? t.word.word : null,
category: knows && t.word ? t.word.category : null,
hint: hintFor(s),
hintStage: t.hintStage,
nextHintAt: nextHintAt(s),
solvers: t.solvers.map((x) => ({ id: x.id, points: x.points })),
reports: t.reports.length,
reportsNeeded: reportsNeeded(s),
reportedByMe: !!me && t.reports.includes(me),
canGuess: active && drawing && !isDrawer,
canChat: active && (t.phase === 'reveal' || (t.phase === 'choosing' && !isDrawer)),
canDraw: active && drawing && isDrawer,
canReport: active && drawing && o.letterReport && !isDrawer && !t.reports.includes(me),
canReact: active && drawing && isDrawer,
canvas: withCanvas ? t.canvas : null,
canvasCount: t.canvas.length,
nextStroke: t.lastStroke + 1,
openStroke: t.open,
lastBatch: t.lastBatch,
liveStrokes: t.live,
pointsUsed: t.totalPoints,
pointLimit: MAX_TURN_POINTS,
chat: chatFor(s, me),
away: s.away,
left: s.left,
turnPoints:
t.phase === 'reveal' || s.finished
? Object.fromEntries([...t.solvers.map((x) => [x.id, x.points] as const), [t.drawer, t.drawerPoints] as const])
: null,
drawerPoints: t.drawerPoints,
endReason: t.endReason,
history: s.history,
result: s.final,
};
}
// ---------------------------------------------------------------- definition
export const catchmind: GameDefinition<S, A, CatchmindView, CatchmindOptions> = {
id: 'catchmind',
nameKo: '그림 맞히기',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
defaultOptions: catchmindOptionsSchema.parse({}),
optionsSchema: catchmindOptionsSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['optionsSchema'],
actionSchema: catchmindActionSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['actionSchema'],
/** Guesses and talk go through the game's own filtered chat (§4·§7); the open room chat would leak answers. */
ownChat: true,
playerCountError(options, count) {
const have = buildPool(options).length;
const need = turnsFor(options, count);
if (have >= need) return null;
return options.customOnly
? `추가 단어가 부족해요. ${need}개 이상 넣어 주세요(지금 ${have}개).`
: `단어가 부족해요(${have}개). 분야나 난이도를 넓히거나 라운드를 줄여 주세요.`;
},
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 10) throw new Error(`catchmind: ${players.length} players not allowed`);
// §4: drawing order and the word pool come from the seeded RNG.
const order = rng.shuffle(players);
const pool = rng.shuffle(buildPool(options));
if (pool.length < turnsFor(options, order.length)) throw new Error('catchmind: not enough words');
const zero = Object.fromEntries(order.map((p) => [p, 0])) as Record<PlayerId, number>;
const s: S = {
options,
order,
scores: { ...zero },
correctCounts: { ...zero },
wordPool: pool,
spare: [],
round: 1,
turnsPlayed: 0,
totalTurns: turnsFor(options, order.length),
turn: newTurn(0, order[0]!, [], now),
chat: [],
chatSeq: 0,
msgTimes: {},
away: [],
left: [],
history: [],
finished: false,
final: null,
};
startTurn(s, order[0]!, { now, rng, events: [] });
return s;
},
validate(s, actor, a, vctx) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.finished) return fail('게임이 이미 끝났어요.');
const t = s.turn;
const now = vctx?.now;
if (a.type === 'tick') return actor === t.drawer ? ok : fail('시간 초과 대상이 아니에요.');
if (a.type === 'leave') return s.left.includes(actor) ? fail('이미 게임에서 나갔어요.') : ok;
if (s.left.includes(actor)) return fail('게임에서 나갔어요.');
switch (a.type) {
case 'chooseWord':
if (actor !== t.drawer) return fail('출제자만 단어를 고를 수 있어요.');
if (t.phase !== 'choosing') return fail('지금은 단어를 고를 때가 아니에요.');
if (a.index >= t.choices.length) return fail('없는 단어예요.');
return ok;
case 'draw':
return validateDraw(s, actor, a.op, now);
case 'guess': {
if (t.phase !== 'drawing') return fail('지금은 맞힐 때가 아니에요.');
if (actor === t.drawer) return fail('출제자는 그림만 그릴 수 있어요. 반응 버튼을 써 주세요.');
const len = [...cleanText(a.text)].length;
if (len < 1) return fail('내용을 입력해 주세요.');
if (len > GUESS_MAX) return fail(`${GUESS_MAX}자까지 쓸 수 있어요.`);
if (now !== undefined && now >= t.endsAt) return fail('시간이 다 됐어요.');
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
}
case 'chat': {
if (t.phase === 'drawing') return fail('그리는 동안에는 정답 입력으로 보내 주세요.');
if (t.phase === 'choosing' && actor === t.drawer) return fail('단어를 고르는 동안에는 채팅할 수 없어요.');
const len = [...cleanText(a.text)].length;
if (len < 1) return fail('내용을 입력해 주세요.');
if (len > CHAT_MAX) return fail(`${CHAT_MAX}자까지 쓸 수 있어요.`);
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
}
case 'reaction':
if (actor !== t.drawer || t.phase !== 'drawing') return fail('그리는 동안 출제자만 쓸 수 있어요.');
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
case 'back':
return s.away.includes(actor) ? ok : fail('이미 게임에 참여하고 있어요.');
case 'report':
if (!s.options.letterReport) return fail('이 방은 신고를 쓰지 않아요.');
if (t.phase !== 'drawing') return fail('그리는 동안에만 신고할 수 있어요.');
if (actor === t.drawer) return fail('출제자는 신고할 수 없어요.');
if (t.reports.includes(actor)) return fail('이미 신고했어요.');
return ok;
}
},
apply(prev, actor, a, { now, rng }) {
if (a.type === 'draw') return applyDraw(prev, actor, a.op, now);
const s = clone(prev);
const ctx: Ctx = { now, rng, events: [] };
if (a.type !== 'tick' && a.type !== 'leave' && s.away.includes(actor)) s.away = s.away.filter((p) => p !== actor);
switch (a.type) {
case 'tick':
tick(s, ctx);
break;
case 'leave':
leave(s, actor, ctx);
break;
case 'chooseWord':
beginDrawing(s, a.index, ctx);
break;
case 'guess':
guess(s, actor, a.text, ctx);
break;
case 'chat':
stamp(s, actor, now);
addChat(s, { from: actor, text: maskBanned(cleanText(a.text)), at: now, audience: 'all', kind: 'chat' });
break;
case 'reaction':
stamp(s, actor, now);
addChat(s, { from: actor, text: a.code, at: now, audience: 'all', kind: 'reaction' });
break;
case 'back':
ctx.events.push({ type: 'back', player: actor });
break;
case 'report':
s.turn.reports.push(actor);
ctx.events.push({ type: 'report', count: s.turn.reports.length, need: reportsNeeded(s) });
if (s.turn.reports.length >= reportsNeeded(s)) endDrawing(s, 'report', ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
return buildView(s, viewer, false);
},
/** (Re)joining or resyncing clients get the whole drawing once (§5.5). */
syncView(s, viewer) {
return buildView(s, viewer, true);
},
isStreamAction(a) {
return a.type === 'draw';
},
activePlayers(s) {
if (s.finished) return [];
const t = s.turn;
if (t.phase !== 'drawing') return [t.drawer];
return [t.drawer, ...s.order.filter((p) => p !== t.drawer && !s.left.includes(p) && !isSolver(t, p))];
},
deadline(s) {
if (s.finished) return null;
const h = nextHintAt(s);
return h === null ? s.turn.endsAt : Math.min(h, s.turn.endsAt);
},
/** The drawer's clock drives every server tick (choosing, hints, time up, reveal). */
timeoutPlayers(s) {
return s.finished ? [] : [s.turn.drawer];
},
/** A drawer who was already away when drawing began still gets DRAWER_AWAY_MS; reveal always lasts its 5 s. */
timeoutNotBefore(s) {
if (s.finished) return null;
const t = s.turn;
if (t.phase === 'drawing') return t.startedAt + DRAWER_AWAY_MS;
if (t.phase === 'reveal') return t.endsAt;
return null;
},
/** §8.5: disconnected drawer → pick at once while choosing, end the turn after 10 s while drawing. */
disconnectGraceMs(s) {
if (s.finished) return null;
if (s.turn.phase === 'choosing') return 0;
if (s.turn.phase === 'drawing') return DRAWER_AWAY_MS;
return null;
},
onTimeout() {
return { type: 'tick' };
},
onLeave(s, player) {
if (s.finished || s.left.includes(player)) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores: { ...s.scores },
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, p) {
if (s.finished || !s.order.includes(p)) return [];
const t = s.turn;
const out: A[] = [];
if (p === t.drawer) out.push({ type: 'tick' });
if (s.away.includes(p)) out.push({ type: 'back' });
if (t.phase === 'choosing') {
if (p === t.drawer) t.choices.forEach((_, index) => out.push({ type: 'chooseWord', index }));
else out.push({ type: 'chat', text: '안녕하세요' });
} else if (t.phase === 'drawing') {
if (p === t.drawer) {
if (!t.open) out.push({ type: 'draw', op: { k: 'b', s: t.lastStroke + 1, c: t.lastStroke % COLOR_COUNT, w: t.lastStroke % WIDTHS.length, e: t.lastStroke % 5 === 4 ? 1 : 0 } });
else {
const x = 100 + ((t.lastBatch * 37 + t.lastStroke * 11) % 800);
out.push({ type: 'draw', op: { k: 'p', s: t.lastStroke, q: t.lastBatch + 1, d: [x, 900 - (x % 700), 3, -4, 5, 2, -1, 6] } });
out.push({ type: 'draw', op: { k: 'e', s: t.lastStroke } });
}
if (t.live > 0) out.push({ type: 'draw', op: { k: 'u' } }, { type: 'draw', op: { k: 'x' } });
out.push({ type: 'reaction', code: 'almost' });
} else {
out.push({ type: 'guess', text: t.word!.word }, { type: 'guess', text: '모르겠어요' }, { type: 'guess', text: `${t.word!.word} 맞나` }, { type: 'report' });
}
} else {
out.push({ type: 'chat', text: '잘 그렸어요' });
}
return out.filter((a) => catchmind.validate(s, p, a).ok);
},
/**
* Leak-check secrets (§8.3): upcoming pool words are nobody's to see; the drawer's candidates and — until someone
* else legitimately knows it — the answer belong to the drawer.
*/
secretsOf(s, player) {
const t = s.turn;
const out = s.wordPool.slice(0, 8).map((w) => w.word);
if (player === t.drawer && t.phase === 'choosing') out.push(...t.choices.map((w) => w.word));
if (player === t.drawer && t.phase === 'drawing' && t.solvers.length === 0 && t.word) out.push(...answersOf(t.word));
return out;
},
};

View File

@@ -0,0 +1,173 @@
/** 그림 맞히기 text rules: answer normalisation, initial consonants, "아까워요" distance (docs/games/catchmind.md §5.3). */
const CHO = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
const JUNG = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
const JONG = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
/** Compound vowels and double finals split into their parts (§5.3 "겹받침·이중모음도 분해"). */
const SPLIT: Record<string, string> = {
ㅘ: 'ㅗㅏ',
ㅙ: 'ㅗㅐ',
ㅚ: 'ㅗㅣ',
ㅝ: 'ㅜㅓ',
ㅞ: 'ㅜㅔ',
ㅟ: 'ㅜㅣ',
ㅢ: 'ㅡㅣ',
ㄳ: 'ㄱㅅ',
ㄵ: 'ㄴㅈ',
ㄶ: 'ㄴㅎ',
ㄺ: 'ㄹㄱ',
ㄻ: 'ㄹㅁ',
ㄼ: 'ㄹㅂ',
ㄽ: 'ㄹㅅ',
ㄾ: 'ㄹㅌ',
ㄿ: 'ㄹㅍ',
ㅀ: 'ㄹㅎ',
ㅄ: 'ㅂㅅ',
};
const SYL_FIRST = 0xac00;
const SYL_LAST = 0xd7a3;
const isSyllable = (ch: string): boolean => {
const c = ch.codePointAt(0)!;
return c >= SYL_FIRST && c <= SYL_LAST;
};
/** Text without spaces, punctuation and symbols (raw, before NFKC). */
const bare = (raw: string): string => raw.replace(/[\s\p{P}\p{S}\u200B-\u200D\uFEFF]/gu, '');
/** Hangul compatibility jamo only (U+3131–U+318E), ignoring spaces and punctuation. */
export function isCompatJamoOnly(raw: string): boolean {
const t = bare(raw);
return t.length > 0 && /^[ㄱ-ㆎ]+$/u.test(t);
}
const ENDINGS = ['요', '다', '이다', '입니다', '인가', '인가요'];
/** §5.3 normalize(): NFKC, zero-width, spaces, punctuation/symbols/emoji, lower case. */
export function normalize(s: string): string {
return s
.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF\u2060\uFE0F\u20E3]/gu, '')
.replace(/\s+/gu, '')
.replace(/[\p{P}\p{S}\p{Extended_Pictographic}]/gu, '')
.toLowerCase();
}
/** Initial consonants of every syllable (other characters kept as they are). */
export function chosung(word: string): string {
return [...word]
.map((ch) => (isSyllable(ch) ? CHO[Math.floor((ch.codePointAt(0)! - SYL_FIRST) / 588)]! : ch))
.join('');
}
export function initialOf(ch: string): string {
return isSyllable(ch) ? CHO[Math.floor((ch.codePointAt(0)! - SYL_FIRST) / 588)]! : ch;
}
/** Syllables → compatibility jamo with compound vowels / double finals split. */
export function jamo(s: string): string {
let out = '';
for (const ch of s) {
if (!isSyllable(ch)) {
out += SPLIT[ch] ?? ch;
continue;
}
const i = ch.codePointAt(0)! - SYL_FIRST;
const l = CHO[Math.floor(i / 588)]!;
const v = JUNG[Math.floor((i % 588) / 28)]!;
const t = JONG[i % 28]!;
out += l + (SPLIT[v] ?? v) + (SPLIT[t] ?? t);
}
return out;
}
export function levenshtein(a: string, b: string): number {
const x = [...a];
const y = [...b];
let prev = Array.from({ length: y.length + 1 }, (_, j) => j);
for (let i = 1; i <= x.length; i++) {
const cur = [i];
for (let j = 1; j <= y.length; j++) {
cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (x[i - 1] === y[j - 1] ? 0 : 1));
}
prev = cur;
}
return prev[y.length]!;
}
export type Judgement = 'correct' | 'chosung' | 'contains' | 'close' | 'plain';
/**
* Judges one guess against the answer and its aliases (§5.3).
* - Compatibility-jamo-only text is never correct; it is held back when it spells the answer's initials or the answer itself.
* - correct: exact match after normalize, optionally followed by a polite ending (요/다/이다/입니다/인가/인가요).
* - contains: the answer inside a longer message → held back.
* - close: jamo edit distance ≤ 1 (answers up to 3 letters) or ≤ 2 (4+ letters).
*/
export function judge(text: string, answers: string[], closeHint: boolean): Judgement {
const cands = answers.map(normalize).filter((c) => c.length > 0);
if (isCompatJamoOnly(text)) {
const t = bare(text);
const spelled = normalize(text);
if (cands.some((c) => chosung(c) === t || spelled === c || spelled.includes(c))) return 'chosung';
return 'plain';
}
const n = normalize(text);
if (!n) return 'plain';
for (const c of cands) {
if (n === c) return 'correct';
if (n.startsWith(c) && ENDINGS.includes(n.slice(c.length))) return 'correct';
}
if (cands.some((c) => n.includes(c))) return 'contains';
if (closeHint) {
const jn = jamo(n);
for (const c of cands) {
const limit = [...c].length <= 3 ? 1 : 2;
if (levenshtein(jn, jamo(c)) <= limit) return 'close';
}
}
return 'plain';
}
// ---------------------------------------------------------------- filters
/** Small built-in list (same spirit as the nickname filter). Matching ignores spaces and punctuation. */
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', '섹스', '자살', '살인', 'fuck', 'shit', 'bitch', 'sex'];
export function isBanned(text: string): boolean {
const squashed = text.normalize('NFKC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
return BANNED.some((w) => squashed.includes(w));
}
export function maskBanned(text: string): string {
let out = text;
for (const w of BANNED) {
const re = new RegExp(w.split('').join('[\\s_.\\-*]*'), 'gi');
out = out.replace(re, (m) => '*'.repeat(m.length));
}
return out;
}
/** Control characters out, trimmed. */
export function cleanText(text: string): string {
return text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
}
export const CUSTOM_WORD_RE = /^[가-힣A-Za-z0-9]{1,10}$/;
export const MAX_CUSTOM_WORDS = 200;
/** §3.2 customWords: one per line, 1–10 Hangul/Latin/digits, filtered, deduplicated, at most 200. */
export function parseCustomWords(raw: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const line of raw.split(/\r?\n/)) {
const w = line.normalize('NFC').trim();
if (!CUSTOM_WORD_RE.test(w) || isBanned(w)) continue;
const key = normalize(w);
if (seen.has(key)) continue;
seen.add(key);
out.push(w);
if (out.length >= MAX_CUSTOM_WORDS) break;
}
return out;
}

View File

@@ -0,0 +1,286 @@
/** 그림 맞히기 options, canvas ops, actions, state and view types (docs/games/catchmind.md §2, §3, §5.5, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
export const CATEGORIES = ['animal', 'food', 'object', 'vehicle', 'nature', 'place', 'job', 'sport', 'body', 'season'] as const;
export type Category = (typeof CATEGORIES)[number];
export const CATEGORY_KO: Record<Category, string> = {
animal: '동물',
food: '음식',
object: '물건',
vehicle: '탈것',
nature: '자연',
place: '장소',
job: '직업',
sport: '운동·놀이',
body: '몸',
season: '계절·명절',
};
export interface WordEntry {
word: string;
aliases: string[];
category: Category | 'custom';
difficulty: 'easy' | 'normal';
}
/** §3.1: 12 colours (index into the client palette), 4 widths in logical units on the 1000×1000 board. */
export const COLOR_COUNT = 12;
export const WIDTHS = [4, 8, 16, 32] as const;
export const BOARD = 1000;
/** §5.5 server limits. */
export const MAX_BATCH_POINTS = 64;
export const MAX_TURN_POINTS = 30_000;
/** Upper bound on canvas ops per turn (keeps one turn's record bounded even with 1-point batches). */
export const MAX_TURN_OPS = 8_000;
export const DRAW_MSGS_PER_SEC = 30;
export const GUESS_MSGS_PER_SEC = 3;
export const REACTIONS = ['almost', 'no', 'similar'] as const;
export type Reaction = (typeof REACTIONS)[number];
export const REACTION_KO: Record<Reaction, string> = { almost: '거의 다 왔어요', no: '아니에요', similar: '비슷해요' };
const lit = <T extends string | number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
const allCategories = Object.fromEntries(CATEGORIES.map((c) => [c, true])) as Record<Category, boolean>;
export const catchmindOptionsSchema = z.object({
guessMode: z
.enum(['multi', 'firstOnly'])
.default('multi')
.meta({ title: '맞히기 방식', labels: { multi: '여러 명이 맞힐 때까지', firstOnly: '첫 정답자가 나오면 바로 끝' } }),
rounds: lit<1 | 2 | 3>(1, 2, 3)
.default(2)
.meta({ title: '라운드 수(모두 한 번씩 그리면 1라운드)', labels: { 1: '1라운드', 2: '2라운드', 3: '3라운드' } }),
drawSeconds: lit<60 | 80 | 100 | 120>(60, 80, 100, 120)
.default(80)
.meta({ title: '그리기 제한 시간', labels: { 60: '60초', 80: '80초', 100: '100초', 120: '120초' } }),
wordChoice: z.boolean().default(true).meta({ title: '출제자가 단어 3개 중 고르기(끄면 무작위 1개)' }),
categories: z
.object(Object.fromEntries(CATEGORIES.map((c) => [c, z.boolean().default(true).meta({ title: CATEGORY_KO[c] })])) as Record<Category, z.ZodDefault<z.ZodBoolean>>)
.default(allCategories)
.meta({ title: '단어 분야(모두 끄면 전체)' }),
difficulty: z
.enum(['easy', 'normal', 'mixed'])
.default('mixed')
.meta({ title: '단어 난이도', labels: { easy: '쉬운 단어만', normal: '보통 단어만', mixed: '섞어서' } }),
customWords: z.string().max(3000).default('').meta({ title: '추가 단어(줄마다 하나, 최대 200개, 1~10자 한글·영문·숫자)' }),
customOnly: z.boolean().default(false).meta({ title: '추가 단어만 쓰기' }),
hints: z
.enum(['none', 'basic', 'full'])
.default('full')
.meta({ title: '힌트', labels: { none: '없음(고수 방)', basic: '글자 수만', full: '글자 수 + 초성 + 한 글자' } }),
closeHint: z.boolean().default(true).meta({ title: '"아까워요" 알림(보낸 사람에게만)' }),
letterReport: z.boolean().default(true).meta({ title: '"글자를 썼어요" 신고(과반이면 출제자 0점)' }),
answererDraws: z.boolean().default(false).meta({ title: '정답자가 다음 출제자(첫 정답자 방식에서만)' }),
speedBonus: z.boolean().default(false).meta({ title: '빨리 맞히면 보너스(남은 시간 비율 × 5점)' }),
});
export type CatchmindOptions = z.infer<typeof catchmindOptionsSchema>;
/** §5.5 canvas ops (short keys). Shape only here; `validate` checks numbering, ranges and limits. */
export type CanvasOp =
| { k: 'b'; s: number; c: number; w: number; e: 0 | 1 }
| { k: 'p'; s: number; q: number; d: number[] }
| { k: 'e'; s: number }
| { k: 'u' }
| { k: 'x' };
const int = z.number().int();
export const canvasOpSchema = z.discriminatedUnion('k', [
z.object({ k: z.literal('b'), s: int.min(0).max(1_000_000), c: int.min(0).max(99), w: int.min(0).max(9), e: z.union([z.literal(0), z.literal(1)]) }),
z.object({ k: z.literal('p'), s: int.min(0).max(1_000_000), q: int.min(0).max(1_000_000), d: z.array(int.min(-2000).max(2000)).min(1).max(256) }),
z.object({ k: z.literal('e'), s: int.min(0).max(1_000_000) }),
z.object({ k: z.literal('u') }),
z.object({ k: z.literal('x') }),
]);
export const catchmindActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chooseWord'), index: int.min(0).max(2) }),
z.object({ type: z.literal('draw'), op: canvasOpSchema }),
z.object({ type: z.literal('guess'), text: z.string().max(200) }),
z.object({ type: z.literal('chat'), text: z.string().max(200) }),
z.object({ type: z.literal('reaction'), code: z.enum(REACTIONS) }),
z.object({ type: z.literal('report') }),
/** "돌아왔어요": clears the away mark (players marked away are skipped as drawer). */
z.object({ type: z.literal('back') }),
]);
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
export type CatchmindAction = z.infer<typeof catchmindActionSchema> | { type: 'tick' } | { type: 'leave' };
export type ChatKind = 'chat' | 'correct' | 'close' | 'blocked' | 'reaction';
export interface ChatEntry {
id: number;
from: PlayerId;
text: string;
at: number;
/** all: everyone · solvers: drawer + players who already got it (everyone once the turn is revealed) · self: sender only. */
audience: 'all' | 'solvers' | 'self';
kind: ChatKind;
/** Turn number the message belongs to. */
turn: number;
/** Why a message was held back (blocked only). */
note?: 'contains' | 'chosung';
}
export interface Solver {
id: PlayerId;
at: number;
points: number;
}
export type TurnEndReason = 'time' | 'allSolved' | 'first' | 'report' | 'drawerAway' | 'drawerLeft';
export interface Turn {
/** 1-based turn counter (also the canvas key). */
no: number;
phase: 'choosing' | 'drawing' | 'reveal';
drawer: PlayerId;
choices: WordEntry[];
word: WordEntry | null;
startedAt: number;
endsAt: number;
/** 0 letter count, 1 initial consonants, 2 one letter (§5.4). */
hintStage: 0 | 1 | 2;
revealIndex: number | null;
solvers: Solver[];
reports: PlayerId[];
/** Append-only drawing record of this turn. */
canvas: CanvasOp[];
totalPoints: number;
lastStroke: number;
lastBatch: number;
/** A stroke was begun and not ended yet. */
open: boolean;
/** Strokes currently visible (undo / clear aware). */
live: number;
/** Server times of the last draw messages (rate limit, §5.5). */
drawTimes: number[];
drawerPoints: number;
endReason: TurnEndReason | null;
}
export interface TurnRecord {
no: number;
drawer: PlayerId;
word: string;
solvers: PlayerId[];
/** Points of each solver, same order as `solvers`. */
solverPoints: number[];
drawerPoints: number;
reason: TurnEndReason;
}
export interface RankRow {
rank: number;
id: PlayerId;
score: number;
correct: number;
}
export interface CatchmindFinal {
ranking: RankRow[];
turns: { drawer: PlayerId; word: string; solvers: PlayerId[] }[];
summary: string;
}
export interface CatchmindState {
options: CatchmindOptions;
order: PlayerId[];
scores: Record<PlayerId, number>;
correctCounts: Record<PlayerId, number>;
/** Words still to come (shuffled). Never sent to anyone. */
wordPool: WordEntry[];
/** Candidates a drawer saw but did not pick (only used when the pool runs dry; never an answer before). */
spare: WordEntry[];
round: number;
/** Turns finished so far. */
turnsPlayed: number;
totalTurns: number;
turn: Turn;
chat: ChatEntry[];
chatSeq: number;
/** Server times of each player's recent guesses/chats (rate limit). */
msgTimes: Record<PlayerId, number[]>;
/** Players timed out as drawer and not back yet: skipped as drawer, not waited for in "everyone got it". */
away: PlayerId[];
left: PlayerId[];
history: TurnRecord[];
finished: boolean;
final: CatchmindFinal | null;
}
export interface ChatView {
id: number;
from: PlayerId;
text: string;
at: number;
kind: ChatKind;
/** Shown with the "맞힌 사람끼리 대화" label. */
solversOnly: boolean;
note?: 'contains' | 'chosung';
}
export interface ChoiceView {
word: string;
category: WordEntry['category'];
difficulty: WordEntry['difficulty'];
}
export interface CatchmindView {
options: Omit<CatchmindOptions, 'customWords'> & { customWordCount: number };
me: PlayerId | null;
role: 'drawer' | 'solver' | 'guesser' | 'spectator';
order: PlayerId[];
scores: Record<PlayerId, number>;
correctCounts: Record<PlayerId, number>;
round: number;
rounds: number;
turnNo: number;
turnsPlayed: number;
totalTurns: number;
phase: Turn['phase'] | 'ended';
drawer: PlayerId;
startedAt: number;
endsAt: number;
/** Length of the current phase (ms), for timer rings. */
phaseMs: number;
/** Drawer only, while choosing. */
choices: ChoiceView[] | null;
/** Drawer, solvers and (after the turn) everyone. */
word: string | null;
category: WordEntry['category'] | null;
/** Per character: '○' hidden, an initial consonant, the letter itself, or ' ' (space). Null when hints are off. */
hint: string[] | null;
hintStage: 0 | 1 | 2;
/** Next hint time (ms), or null. */
nextHintAt: number | null;
solvers: { id: PlayerId; points: number }[];
reports: number;
reportsNeeded: number;
reportedByMe: boolean;
canGuess: boolean;
canChat: boolean;
canDraw: boolean;
canReport: boolean;
canReact: boolean;
/** Full canvas only in the (re)join / resync view; regular views carry just the count (§5.5). */
canvas: CanvasOp[] | null;
canvasCount: number;
nextStroke: number;
openStroke: boolean;
lastBatch: number;
liveStrokes: number;
pointsUsed: number;
pointLimit: number;
chat: ChatView[];
away: PlayerId[];
left: PlayerId[];
/** Points each player got this turn (reveal only). */
turnPoints: Record<PlayerId, number> | null;
drawerPoints: number;
endReason: TurnEndReason | null;
history: TurnRecord[];
result: CatchmindFinal | null;
}

View File

@@ -0,0 +1,742 @@
[
{"word":"고양이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"강아지","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"기린","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"코끼리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"펭귄","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"거북이","aliases":["거북"],"category":"animal","difficulty":"easy"},
{"word":"토끼","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"사자","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"문어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"나비","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"호랑이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"원숭이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"돼지","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"오리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"병아리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"여우","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"판다","aliases":["팬더"],"category":"animal","difficulty":"easy"},
{"word":"다람쥐","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"고래","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"상어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"돌고래","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"개구리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"악어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"하마","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"낙타","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"얼룩말","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"캥거루","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"코알라","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"부엉이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"독수리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"앵무새","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"참새","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"비둘기","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"거미","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"개미","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"꿀벌","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"잠자리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"무당벌레","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"달팽이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"지렁이","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"사슴","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"염소","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"젖소","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"생쥐","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"햄스터","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"고슴도치","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"타조","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"물개","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"해파리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"오징어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"꽃게","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"새우","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"불가사리","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"조개","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"금붕어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"공룡","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"코뿔소","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"고릴라","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"애벌레","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"북극곰","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"매미","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"모기","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"송아지","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"망아지","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"수탉","aliases":["장닭"],"category":"animal","difficulty":"easy"},
{"word":"암탉","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"고등어","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"펠리컨","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"순록","aliases":[],"category":"animal","difficulty":"easy"},
{"word":"공작새","aliases":["공작"],"category":"animal","difficulty":"normal"},
{"word":"홍학","aliases":["플라밍고"],"category":"animal","difficulty":"normal"},
{"word":"메뚜기","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"조랑말","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"너구리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"박쥐","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"가재","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"늑대","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"표범","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"수달","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"두더지","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"카멜레온","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"백조","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"까마귀","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"딱따구리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"미어캣","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"나무늘보","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"해마","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"사마귀","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"반딧불이","aliases":["반딧불"],"category":"animal","difficulty":"normal"},
{"word":"오리너구리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"기러기","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"거위","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"칠면조","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"올빼미","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"장어","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"바다표범","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"치타","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"해달","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"랍스터","aliases":["바닷가재"],"category":"animal","difficulty":"normal"},
{"word":"도마뱀","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"소라게","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"개코원숭이","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"알파카","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"오소리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"청개구리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"올챙이","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"가오리","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"성게","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"산호","aliases":[],"category":"animal","difficulty":"normal"},
{"word":"김밥","aliases":[],"category":"food","difficulty":"easy"},
{"word":"떡볶이","aliases":[],"category":"food","difficulty":"easy"},
{"word":"피자","aliases":[],"category":"food","difficulty":"easy"},
{"word":"라면","aliases":[],"category":"food","difficulty":"easy"},
{"word":"수박","aliases":[],"category":"food","difficulty":"easy"},
{"word":"아이스크림","aliases":[],"category":"food","difficulty":"easy"},
{"word":"바나나","aliases":[],"category":"food","difficulty":"easy"},
{"word":"케이크","aliases":[],"category":"food","difficulty":"easy"},
{"word":"만두","aliases":[],"category":"food","difficulty":"easy"},
{"word":"붕어빵","aliases":[],"category":"food","difficulty":"easy"},
{"word":"사과","aliases":[],"category":"food","difficulty":"easy"},
{"word":"포도","aliases":[],"category":"food","difficulty":"easy"},
{"word":"딸기","aliases":[],"category":"food","difficulty":"easy"},
{"word":"참외","aliases":[],"category":"food","difficulty":"easy"},
{"word":"복숭아","aliases":[],"category":"food","difficulty":"easy"},
{"word":"파인애플","aliases":[],"category":"food","difficulty":"easy"},
{"word":"체리","aliases":[],"category":"food","difficulty":"easy"},
{"word":"레몬","aliases":[],"category":"food","difficulty":"easy"},
{"word":"키위","aliases":[],"category":"food","difficulty":"easy"},
{"word":"당근","aliases":[],"category":"food","difficulty":"easy"},
{"word":"감자","aliases":[],"category":"food","difficulty":"easy"},
{"word":"고구마","aliases":[],"category":"food","difficulty":"easy"},
{"word":"옥수수","aliases":[],"category":"food","difficulty":"easy"},
{"word":"오이","aliases":[],"category":"food","difficulty":"easy"},
{"word":"양파","aliases":[],"category":"food","difficulty":"easy"},
{"word":"버섯","aliases":[],"category":"food","difficulty":"easy"},
{"word":"토마토","aliases":[],"category":"food","difficulty":"easy"},
{"word":"고추","aliases":[],"category":"food","difficulty":"easy"},
{"word":"호박","aliases":[],"category":"food","difficulty":"easy"},
{"word":"브로콜리","aliases":[],"category":"food","difficulty":"easy"},
{"word":"달걀","aliases":["계란"],"category":"food","difficulty":"easy"},
{"word":"우유","aliases":[],"category":"food","difficulty":"easy"},
{"word":"식빵","aliases":[],"category":"food","difficulty":"easy"},
{"word":"햄버거","aliases":[],"category":"food","difficulty":"easy"},
{"word":"핫도그","aliases":[],"category":"food","difficulty":"easy"},
{"word":"샌드위치","aliases":[],"category":"food","difficulty":"easy"},
{"word":"도넛","aliases":[],"category":"food","difficulty":"easy"},
{"word":"쿠키","aliases":[],"category":"food","difficulty":"easy"},
{"word":"사탕","aliases":[],"category":"food","difficulty":"easy"},
{"word":"초콜릿","aliases":["초콜렛","초코"],"category":"food","difficulty":"easy"},
{"word":"젤리","aliases":[],"category":"food","difficulty":"easy"},
{"word":"팝콘","aliases":[],"category":"food","difficulty":"easy"},
{"word":"솜사탕","aliases":[],"category":"food","difficulty":"easy"},
{"word":"김치","aliases":[],"category":"food","difficulty":"easy"},
{"word":"비빔밥","aliases":[],"category":"food","difficulty":"easy"},
{"word":"짜장면","aliases":["자장면"],"category":"food","difficulty":"easy"},
{"word":"국수","aliases":[],"category":"food","difficulty":"easy"},
{"word":"볶음밥","aliases":[],"category":"food","difficulty":"easy"},
{"word":"치킨","aliases":["통닭"],"category":"food","difficulty":"easy"},
{"word":"돈가스","aliases":["돈까스"],"category":"food","difficulty":"easy"},
{"word":"스파게티","aliases":[],"category":"food","difficulty":"easy"},
{"word":"카레","aliases":[],"category":"food","difficulty":"easy"},
{"word":"주먹밥","aliases":[],"category":"food","difficulty":"easy"},
{"word":"호떡","aliases":[],"category":"food","difficulty":"easy"},
{"word":"어묵","aliases":["오뎅"],"category":"food","difficulty":"easy"},
{"word":"튀김","aliases":[],"category":"food","difficulty":"easy"},
{"word":"감자튀김","aliases":[],"category":"food","difficulty":"easy"},
{"word":"소시지","aliases":[],"category":"food","difficulty":"easy"},
{"word":"치즈","aliases":[],"category":"food","difficulty":"easy"},
{"word":"요구르트","aliases":["요거트"],"category":"food","difficulty":"easy"},
{"word":"주스","aliases":["쥬스"],"category":"food","difficulty":"easy"},
{"word":"땅콩","aliases":[],"category":"food","difficulty":"easy"},
{"word":"팥빙수","aliases":["빙수"],"category":"food","difficulty":"easy"},
{"word":"컵라면","aliases":[],"category":"food","difficulty":"easy"},
{"word":"막대사탕","aliases":[],"category":"food","difficulty":"easy"},
{"word":"롤케이크","aliases":[],"category":"food","difficulty":"easy"},
{"word":"곶감","aliases":[],"category":"food","difficulty":"normal"},
{"word":"망고","aliases":[],"category":"food","difficulty":"normal"},
{"word":"배추","aliases":[],"category":"food","difficulty":"normal"},
{"word":"마늘","aliases":[],"category":"food","difficulty":"normal"},
{"word":"가지","aliases":[],"category":"food","difficulty":"normal"},
{"word":"콩나물","aliases":[],"category":"food","difficulty":"normal"},
{"word":"와플","aliases":[],"category":"food","difficulty":"normal"},
{"word":"짬뽕","aliases":[],"category":"food","difficulty":"normal"},
{"word":"냉면","aliases":[],"category":"food","difficulty":"normal"},
{"word":"삼각김밥","aliases":[],"category":"food","difficulty":"normal"},
{"word":"꿀떡","aliases":[],"category":"food","difficulty":"normal"},
{"word":"순대","aliases":[],"category":"food","difficulty":"normal"},
{"word":"계란말이","aliases":[],"category":"food","difficulty":"normal"},
{"word":"아보카도","aliases":[],"category":"food","difficulty":"normal"},
{"word":"호두","aliases":[],"category":"food","difficulty":"normal"},
{"word":"찐빵","aliases":[],"category":"food","difficulty":"normal"},
{"word":"꽈배기","aliases":[],"category":"food","difficulty":"normal"},
{"word":"떡꼬치","aliases":[],"category":"food","difficulty":"normal"},
{"word":"마카롱","aliases":[],"category":"food","difficulty":"normal"},
{"word":"푸딩","aliases":[],"category":"food","difficulty":"normal"},
{"word":"프레첼","aliases":[],"category":"food","difficulty":"normal"},
{"word":"생선구이","aliases":[],"category":"food","difficulty":"normal"},
{"word":"새우튀김","aliases":[],"category":"food","difficulty":"normal"},
{"word":"김치찌개","aliases":[],"category":"food","difficulty":"normal"},
{"word":"된장찌개","aliases":[],"category":"food","difficulty":"normal"},
{"word":"미역국","aliases":[],"category":"food","difficulty":"normal"},
{"word":"삼겹살","aliases":[],"category":"food","difficulty":"normal"},
{"word":"꼬치구이","aliases":[],"category":"food","difficulty":"normal"},
{"word":"오므라이스","aliases":[],"category":"food","difficulty":"normal"},
{"word":"유부초밥","aliases":[],"category":"food","difficulty":"normal"},
{"word":"잡채","aliases":[],"category":"food","difficulty":"normal"},
{"word":"군만두","aliases":[],"category":"food","difficulty":"normal"},
{"word":"탕후루","aliases":[],"category":"food","difficulty":"normal"},
{"word":"석류","aliases":[],"category":"food","difficulty":"normal"},
{"word":"자두","aliases":[],"category":"food","difficulty":"normal"},
{"word":"블루베리","aliases":[],"category":"food","difficulty":"normal"},
{"word":"코코넛","aliases":[],"category":"food","difficulty":"normal"},
{"word":"무화과","aliases":[],"category":"food","difficulty":"normal"},
{"word":"연근","aliases":[],"category":"food","difficulty":"normal"},
{"word":"피망","aliases":[],"category":"food","difficulty":"normal"},
{"word":"시금치","aliases":[],"category":"food","difficulty":"normal"},
{"word":"양배추","aliases":[],"category":"food","difficulty":"normal"},
{"word":"우산","aliases":[],"category":"object","difficulty":"easy"},
{"word":"안경","aliases":[],"category":"object","difficulty":"easy"},
{"word":"가위","aliases":[],"category":"object","difficulty":"easy"},
{"word":"시계","aliases":[],"category":"object","difficulty":"easy"},
{"word":"열쇠","aliases":[],"category":"object","difficulty":"easy"},
{"word":"연필","aliases":[],"category":"object","difficulty":"easy"},
{"word":"의자","aliases":[],"category":"object","difficulty":"easy"},
{"word":"냉장고","aliases":[],"category":"object","difficulty":"easy"},
{"word":"칫솔","aliases":[],"category":"object","difficulty":"easy"},
{"word":"선풍기","aliases":[],"category":"object","difficulty":"easy"},
{"word":"책상","aliases":[],"category":"object","difficulty":"easy"},
{"word":"침대","aliases":[],"category":"object","difficulty":"easy"},
{"word":"거울","aliases":[],"category":"object","difficulty":"easy"},
{"word":"숟가락","aliases":[],"category":"object","difficulty":"easy"},
{"word":"젓가락","aliases":[],"category":"object","difficulty":"easy"},
{"word":"포크","aliases":[],"category":"object","difficulty":"easy"},
{"word":"접시","aliases":[],"category":"object","difficulty":"easy"},
{"word":"냄비","aliases":[],"category":"object","difficulty":"easy"},
{"word":"프라이팬","aliases":[],"category":"object","difficulty":"easy"},
{"word":"텔레비전","aliases":["티비","텔레비젼","티브이"],"category":"object","difficulty":"easy"},
{"word":"컴퓨터","aliases":[],"category":"object","difficulty":"easy"},
{"word":"휴대폰","aliases":["핸드폰","휴대전화","스마트폰"],"category":"object","difficulty":"easy"},
{"word":"전화기","aliases":[],"category":"object","difficulty":"easy"},
{"word":"카메라","aliases":[],"category":"object","difficulty":"easy"},
{"word":"모자","aliases":[],"category":"object","difficulty":"easy"},
{"word":"장갑","aliases":[],"category":"object","difficulty":"easy"},
{"word":"양말","aliases":[],"category":"object","difficulty":"easy"},
{"word":"신발","aliases":[],"category":"object","difficulty":"easy"},
{"word":"운동화","aliases":[],"category":"object","difficulty":"easy"},
{"word":"바지","aliases":[],"category":"object","difficulty":"easy"},
{"word":"치마","aliases":[],"category":"object","difficulty":"easy"},
{"word":"티셔츠","aliases":[],"category":"object","difficulty":"easy"},
{"word":"목도리","aliases":[],"category":"object","difficulty":"easy"},
{"word":"가방","aliases":[],"category":"object","difficulty":"easy"},
{"word":"풍선","aliases":[],"category":"object","difficulty":"easy"},
{"word":"인형","aliases":[],"category":"object","difficulty":"easy"},
{"word":"로봇","aliases":[],"category":"object","difficulty":"easy"},
{"word":"크레파스","aliases":[],"category":"object","difficulty":"easy"},
{"word":"지우개","aliases":[],"category":"object","difficulty":"easy"},
{"word":"공책","aliases":["노트"],"category":"object","difficulty":"easy"},
{"word":"필통","aliases":[],"category":"object","difficulty":"easy"},
{"word":"색연필","aliases":[],"category":"object","difficulty":"easy"},
{"word":"달력","aliases":[],"category":"object","difficulty":"easy"},
{"word":"양초","aliases":[],"category":"object","difficulty":"easy"},
{"word":"전구","aliases":[],"category":"object","difficulty":"easy"},
{"word":"손전등","aliases":[],"category":"object","difficulty":"easy"},
{"word":"사다리","aliases":[],"category":"object","difficulty":"easy"},
{"word":"망치","aliases":[],"category":"object","difficulty":"easy"},
{"word":"빗자루","aliases":[],"category":"object","difficulty":"easy"},
{"word":"쓰레기통","aliases":[],"category":"object","difficulty":"easy"},
{"word":"휴지","aliases":[],"category":"object","difficulty":"easy"},
{"word":"수건","aliases":[],"category":"object","difficulty":"easy"},
{"word":"비누","aliases":[],"category":"object","difficulty":"easy"},
{"word":"치약","aliases":[],"category":"object","difficulty":"easy"},
{"word":"옷걸이","aliases":[],"category":"object","difficulty":"easy"},
{"word":"리모컨","aliases":["리모콘"],"category":"object","difficulty":"easy"},
{"word":"피아노","aliases":[],"category":"object","difficulty":"easy"},
{"word":"기타","aliases":[],"category":"object","difficulty":"easy"},
{"word":"실로폰","aliases":[],"category":"object","difficulty":"easy"},
{"word":"마이크","aliases":[],"category":"object","difficulty":"easy"},
{"word":"청소기","aliases":[],"category":"object","difficulty":"easy"},
{"word":"세탁기","aliases":[],"category":"object","difficulty":"easy"},
{"word":"소파","aliases":[],"category":"object","difficulty":"easy"},
{"word":"이불","aliases":[],"category":"object","difficulty":"easy"},
{"word":"베개","aliases":[],"category":"object","difficulty":"easy"},
{"word":"창문","aliases":[],"category":"object","difficulty":"easy"},
{"word":"저금통","aliases":[],"category":"object","difficulty":"easy"},
{"word":"주사위","aliases":[],"category":"object","difficulty":"easy"},
{"word":"팽이","aliases":[],"category":"object","difficulty":"easy"},
{"word":"왕관","aliases":[],"category":"object","difficulty":"easy"},
{"word":"반지","aliases":[],"category":"object","difficulty":"easy"},
{"word":"목걸이","aliases":[],"category":"object","difficulty":"easy"},
{"word":"리본","aliases":[],"category":"object","difficulty":"easy"},
{"word":"선물","aliases":[],"category":"object","difficulty":"easy"},
{"word":"편지","aliases":[],"category":"object","difficulty":"easy"},
{"word":"지도","aliases":[],"category":"object","difficulty":"easy"},
{"word":"깃발","aliases":[],"category":"object","difficulty":"easy"},
{"word":"텐트","aliases":[],"category":"object","difficulty":"easy"},
{"word":"망원경","aliases":[],"category":"object","difficulty":"easy"},
{"word":"신호등","aliases":[],"category":"object","difficulty":"easy"},
{"word":"자동문","aliases":[],"category":"object","difficulty":"easy"},
{"word":"칠판","aliases":[],"category":"object","difficulty":"easy"},
{"word":"책가방","aliases":[],"category":"object","difficulty":"easy"},
{"word":"물병","aliases":[],"category":"object","difficulty":"easy"},
{"word":"컵받침","aliases":[],"category":"object","difficulty":"easy"},
{"word":"주전자","aliases":[],"category":"object","difficulty":"normal"},
{"word":"라디오","aliases":[],"category":"object","difficulty":"normal"},
{"word":"넥타이","aliases":[],"category":"object","difficulty":"normal"},
{"word":"지갑","aliases":[],"category":"object","difficulty":"normal"},
{"word":"물감","aliases":[],"category":"object","difficulty":"normal"},
{"word":"액자","aliases":[],"category":"object","difficulty":"normal"},
{"word":"드라이버","aliases":[],"category":"object","difficulty":"normal"},
{"word":"샴푸","aliases":[],"category":"object","difficulty":"normal"},
{"word":"빨래집게","aliases":[],"category":"object","difficulty":"normal"},
{"word":"단추","aliases":[],"category":"object","difficulty":"normal"},
{"word":"자물쇠","aliases":[],"category":"object","difficulty":"normal"},
{"word":"돋보기","aliases":[],"category":"object","difficulty":"normal"},
{"word":"나침반","aliases":[],"category":"object","difficulty":"normal"},
{"word":"지구본","aliases":[],"category":"object","difficulty":"normal"},
{"word":"드럼","aliases":[],"category":"object","difficulty":"normal"},
{"word":"바이올린","aliases":[],"category":"object","difficulty":"normal"},
{"word":"트라이앵글","aliases":[],"category":"object","difficulty":"normal"},
{"word":"탬버린","aliases":[],"category":"object","difficulty":"normal"},
{"word":"나팔","aliases":[],"category":"object","difficulty":"normal"},
{"word":"헤드폰","aliases":[],"category":"object","difficulty":"normal"},
{"word":"전자레인지","aliases":[],"category":"object","difficulty":"normal"},
{"word":"다리미","aliases":[],"category":"object","difficulty":"normal"},
{"word":"우체통","aliases":[],"category":"object","difficulty":"normal"},
{"word":"퍼즐","aliases":[],"category":"object","difficulty":"normal"},
{"word":"귀걸이","aliases":[],"category":"object","difficulty":"normal"},
{"word":"머리띠","aliases":[],"category":"object","difficulty":"normal"},
{"word":"봉투","aliases":[],"category":"object","difficulty":"normal"},
{"word":"신문","aliases":[],"category":"object","difficulty":"normal"},
{"word":"현미경","aliases":[],"category":"object","difficulty":"normal"},
{"word":"모래시계","aliases":[],"category":"object","difficulty":"normal"},
{"word":"오르골","aliases":[],"category":"object","difficulty":"normal"},
{"word":"체스판","aliases":[],"category":"object","difficulty":"normal"},
{"word":"국자","aliases":[],"category":"object","difficulty":"normal"},
{"word":"도마","aliases":[],"category":"object","difficulty":"normal"},
{"word":"밥솥","aliases":[],"category":"object","difficulty":"normal"},
{"word":"믹서기","aliases":[],"category":"object","difficulty":"normal"},
{"word":"토스터","aliases":[],"category":"object","difficulty":"normal"},
{"word":"손거울","aliases":[],"category":"object","difficulty":"normal"},
{"word":"머리빗","aliases":[],"category":"object","difficulty":"normal"},
{"word":"면봉","aliases":[],"category":"object","difficulty":"normal"},
{"word":"반창고","aliases":[],"category":"object","difficulty":"normal"},
{"word":"체온계","aliases":[],"category":"object","difficulty":"normal"},
{"word":"줄자","aliases":[],"category":"object","difficulty":"normal"},
{"word":"스테이플러","aliases":[],"category":"object","difficulty":"normal"},
{"word":"계산기","aliases":[],"category":"object","difficulty":"normal"},
{"word":"키보드","aliases":[],"category":"object","difficulty":"normal"},
{"word":"마우스","aliases":[],"category":"object","difficulty":"normal"},
{"word":"노트북","aliases":[],"category":"object","difficulty":"normal"},
{"word":"이어폰","aliases":[],"category":"object","difficulty":"normal"},
{"word":"충전기","aliases":[],"category":"object","difficulty":"normal"},
{"word":"손수건","aliases":[],"category":"object","difficulty":"normal"},
{"word":"효자손","aliases":[],"category":"object","difficulty":"normal"},
{"word":"부메랑","aliases":[],"category":"object","difficulty":"normal"},
{"word":"확성기","aliases":[],"category":"object","difficulty":"normal"},
{"word":"빨대","aliases":[],"category":"object","difficulty":"normal"},
{"word":"병따개","aliases":[],"category":"object","difficulty":"normal"},
{"word":"자전거","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"비행기","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"기차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"버스","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"헬리콥터","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"로켓","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"소방차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"구급차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"경찰차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"택시","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"트럭","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"지하철","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"오토바이","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"자동차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"돛단배","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"잠수함","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"열기구","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"우주선","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"킥보드","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"유모차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"썰매","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"낙하산","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"쓰레기차","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"세발자전거","aliases":[],"category":"vehicle","difficulty":"easy"},
{"word":"요트","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"유람선","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"스쿠터","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"휠체어","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"트랙터","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"굴착기","aliases":["포클레인","포크레인"],"category":"vehicle","difficulty":"normal"},
{"word":"불도저","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"기중기","aliases":["크레인"],"category":"vehicle","difficulty":"normal"},
{"word":"케이블카","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"마차","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"뗏목","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"견인차","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"비행선","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"행글라이더","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"캠핑카","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"경운기","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"기관차","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"모노레일","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"고무보트","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"카누","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"레미콘","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"증기기관차","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"수상스키","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"롤러코스터","aliases":[],"category":"vehicle","difficulty":"normal"},
{"word":"무지개","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"화산","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"눈사람","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"해바라기","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"구름","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"번개","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"바다","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"태양","aliases":["해님"],"category":"nature","difficulty":"easy"},
{"word":"보름달","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"초승달","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"별똥별","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"나무","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"장미","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"튤립","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"민들레","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"나뭇잎","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"선인장","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"폭포","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"사막","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"동굴","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"바위","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"파도","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"눈송이","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"고드름","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"지구","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"연못","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"도토리","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"솔방울","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"나팔꽃","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"빗방울","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"물방울","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"얼음","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"모닥불","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"무인도","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"소나무","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"야자수","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"꽃다발","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"모래성","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"거미줄","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"개나리","aliases":[],"category":"nature","difficulty":"easy"},
{"word":"호수","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"오아시스","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"빙산","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"소나기","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"우박","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"회오리바람","aliases":["회오리","토네이도"],"category":"nature","difficulty":"normal"},
{"word":"행성","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"토성","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"해돋이","aliases":["일출"],"category":"nature","difficulty":"normal"},
{"word":"노을","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"개울","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"클로버","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"연꽃","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"코스모스","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"잔디","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"그림자","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"오로라","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"불꽃","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"은하수","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"달무리","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"안개","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"산호초","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"징검다리","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"갯벌","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"계곡","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"절벽","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"화석","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"이슬","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"단풍잎","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"강아지풀","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"수선화","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"무궁화","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"진달래","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"백합","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"갈대","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"버드나무","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"대나무","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"밀림","aliases":[],"category":"nature","difficulty":"normal"},
{"word":"학교","aliases":[],"category":"place","difficulty":"easy"},
{"word":"병원","aliases":[],"category":"place","difficulty":"easy"},
{"word":"놀이터","aliases":[],"category":"place","difficulty":"easy"},
{"word":"수영장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"도서관","aliases":[],"category":"place","difficulty":"easy"},
{"word":"동물원","aliases":[],"category":"place","difficulty":"easy"},
{"word":"공원","aliases":[],"category":"place","difficulty":"easy"},
{"word":"시장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"마트","aliases":["슈퍼마켓","슈퍼"],"category":"place","difficulty":"easy"},
{"word":"편의점","aliases":[],"category":"place","difficulty":"easy"},
{"word":"빵집","aliases":[],"category":"place","difficulty":"easy"},
{"word":"꽃집","aliases":[],"category":"place","difficulty":"easy"},
{"word":"우체국","aliases":[],"category":"place","difficulty":"easy"},
{"word":"소방서","aliases":[],"category":"place","difficulty":"easy"},
{"word":"경찰서","aliases":[],"category":"place","difficulty":"easy"},
{"word":"식당","aliases":[],"category":"place","difficulty":"easy"},
{"word":"영화관","aliases":["극장"],"category":"place","difficulty":"easy"},
{"word":"미용실","aliases":[],"category":"place","difficulty":"easy"},
{"word":"공항","aliases":[],"category":"place","difficulty":"easy"},
{"word":"기차역","aliases":[],"category":"place","difficulty":"easy"},
{"word":"정류장","aliases":["버스정류장"],"category":"place","difficulty":"easy"},
{"word":"등대","aliases":[],"category":"place","difficulty":"easy"},
{"word":"터널","aliases":[],"category":"place","difficulty":"easy"},
{"word":"놀이공원","aliases":[],"category":"place","difficulty":"easy"},
{"word":"수족관","aliases":[],"category":"place","difficulty":"easy"},
{"word":"운동장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"교실","aliases":[],"category":"place","difficulty":"easy"},
{"word":"부엌","aliases":[],"category":"place","difficulty":"easy"},
{"word":"화장실","aliases":[],"category":"place","difficulty":"easy"},
{"word":"주차장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"해수욕장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"농장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"아파트","aliases":[],"category":"place","difficulty":"easy"},
{"word":"엘리베이터","aliases":[],"category":"place","difficulty":"easy"},
{"word":"계단","aliases":[],"category":"place","difficulty":"easy"},
{"word":"스키장","aliases":[],"category":"place","difficulty":"easy"},
{"word":"유치원","aliases":[],"category":"place","difficulty":"easy"},
{"word":"횡단보도","aliases":[],"category":"place","difficulty":"easy"},
{"word":"문방구","aliases":[],"category":"place","difficulty":"easy"},
{"word":"약국","aliases":[],"category":"place","difficulty":"easy"},
{"word":"치과","aliases":[],"category":"place","difficulty":"easy"},
{"word":"놀이방","aliases":[],"category":"place","difficulty":"easy"},
{"word":"항구","aliases":[],"category":"place","difficulty":"normal"},
{"word":"궁궐","aliases":[],"category":"place","difficulty":"normal"},
{"word":"박물관","aliases":[],"category":"place","difficulty":"normal"},
{"word":"미술관","aliases":[],"category":"place","difficulty":"normal"},
{"word":"체육관","aliases":[],"category":"place","difficulty":"normal"},
{"word":"욕실","aliases":[],"category":"place","difficulty":"normal"},
{"word":"거실","aliases":[],"category":"place","difficulty":"normal"},
{"word":"주유소","aliases":[],"category":"place","difficulty":"normal"},
{"word":"캠핑장","aliases":[],"category":"place","difficulty":"normal"},
{"word":"목장","aliases":[],"category":"place","difficulty":"normal"},
{"word":"과수원","aliases":[],"category":"place","difficulty":"normal"},
{"word":"공장","aliases":[],"category":"place","difficulty":"normal"},
{"word":"분수대","aliases":[],"category":"place","difficulty":"normal"},
{"word":"무대","aliases":[],"category":"place","difficulty":"normal"},
{"word":"시계탑","aliases":[],"category":"place","difficulty":"normal"},
{"word":"피라미드","aliases":[],"category":"place","difficulty":"normal"},
{"word":"이글루","aliases":[],"category":"place","difficulty":"normal"},
{"word":"오두막","aliases":[],"category":"place","difficulty":"normal"},
{"word":"우물","aliases":[],"category":"place","difficulty":"normal"},
{"word":"세탁소","aliases":[],"category":"place","difficulty":"normal"},
{"word":"사진관","aliases":[],"category":"place","difficulty":"normal"},
{"word":"서점","aliases":[],"category":"place","difficulty":"normal"},
{"word":"경기장","aliases":[],"category":"place","difficulty":"normal"},
{"word":"전망대","aliases":[],"category":"place","difficulty":"normal"},
{"word":"등산로","aliases":[],"category":"place","difficulty":"normal"},
{"word":"성벽","aliases":[],"category":"place","difficulty":"normal"},
{"word":"지하실","aliases":[],"category":"place","difficulty":"normal"},
{"word":"다락방","aliases":[],"category":"place","difficulty":"normal"},
{"word":"온실","aliases":[],"category":"place","difficulty":"normal"},
{"word":"비닐하우스","aliases":[],"category":"place","difficulty":"normal"},
{"word":"매표소","aliases":[],"category":"place","difficulty":"normal"},
{"word":"승강장","aliases":[],"category":"place","difficulty":"normal"},
{"word":"휴게소","aliases":[],"category":"place","difficulty":"normal"},
{"word":"소방관","aliases":["소방대원"],"category":"job","difficulty":"easy"},
{"word":"요리사","aliases":["셰프"],"category":"job","difficulty":"easy"},
{"word":"의사","aliases":[],"category":"job","difficulty":"easy"},
{"word":"경찰관","aliases":["경찰"],"category":"job","difficulty":"easy"},
{"word":"우체부","aliases":["집배원"],"category":"job","difficulty":"easy"},
{"word":"화가","aliases":[],"category":"job","difficulty":"easy"},
{"word":"간호사","aliases":[],"category":"job","difficulty":"easy"},
{"word":"선생님","aliases":["교사","선생"],"category":"job","difficulty":"easy"},
{"word":"농부","aliases":[],"category":"job","difficulty":"easy"},
{"word":"가수","aliases":[],"category":"job","difficulty":"easy"},
{"word":"우주비행사","aliases":[],"category":"job","difficulty":"easy"},
{"word":"과학자","aliases":[],"category":"job","difficulty":"easy"},
{"word":"축구선수","aliases":[],"category":"job","difficulty":"easy"},
{"word":"마술사","aliases":[],"category":"job","difficulty":"easy"},
{"word":"왕자","aliases":[],"category":"job","difficulty":"easy"},
{"word":"공주","aliases":[],"category":"job","difficulty":"easy"},
{"word":"마법사","aliases":[],"category":"job","difficulty":"easy"},
{"word":"산타클로스","aliases":["산타","산타할아버지"],"category":"job","difficulty":"easy"},
{"word":"해적","aliases":[],"category":"job","difficulty":"easy"},
{"word":"요정","aliases":[],"category":"job","difficulty":"easy"},
{"word":"임금님","aliases":[],"category":"job","difficulty":"easy"},
{"word":"어부","aliases":[],"category":"job","difficulty":"normal"},
{"word":"미용사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"조종사","aliases":["파일럿"],"category":"job","difficulty":"normal"},
{"word":"발레리나","aliases":[],"category":"job","difficulty":"normal"},
{"word":"광대","aliases":[],"category":"job","difficulty":"normal"},
{"word":"제빵사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"사진작가","aliases":[],"category":"job","difficulty":"normal"},
{"word":"택배기사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"운전기사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"목수","aliases":[],"category":"job","difficulty":"normal"},
{"word":"경비원","aliases":[],"category":"job","difficulty":"normal"},
{"word":"판사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"수의사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"치과의사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"환경미화원","aliases":["청소부"],"category":"job","difficulty":"normal"},
{"word":"탐정","aliases":[],"category":"job","difficulty":"normal"},
{"word":"기자","aliases":[],"category":"job","difficulty":"normal"},
{"word":"아나운서","aliases":[],"category":"job","difficulty":"normal"},
{"word":"지휘자","aliases":[],"category":"job","difficulty":"normal"},
{"word":"피아니스트","aliases":[],"category":"job","difficulty":"normal"},
{"word":"바리스타","aliases":[],"category":"job","difficulty":"normal"},
{"word":"잠수부","aliases":[],"category":"job","difficulty":"normal"},
{"word":"야구선수","aliases":[],"category":"job","difficulty":"normal"},
{"word":"정원사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"등대지기","aliases":[],"category":"job","difficulty":"normal"},
{"word":"양치기","aliases":[],"category":"job","difficulty":"normal"},
{"word":"마부","aliases":[],"category":"job","difficulty":"normal"},
{"word":"대장장이","aliases":[],"category":"job","difficulty":"normal"},
{"word":"사육사","aliases":[],"category":"job","difficulty":"normal"},
{"word":"축구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"줄넘기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"수영","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"연날리기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"그네","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"미끄럼틀","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"야구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"농구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"배구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"탁구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"테니스","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"배드민턴","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"스키","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"스케이트","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"태권도","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"달리기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"줄다리기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"피구","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"시소","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"훌라후프","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"팽이치기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"제기차기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"윷놀이","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"비눗방울","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"등산","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"낚시","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"축구공","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"모래놀이","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"자전거타기","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"공놀이","aliases":[],"category":"sport","difficulty":"easy"},
{"word":"골프","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"볼링","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"씨름","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"정글짐","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"숨바꼭질","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"술래잡기","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"공기놀이","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"딱지치기","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"캠핑","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"서핑","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"요가","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"체조","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"역도","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"양궁","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"스노보드","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"야구공","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"야구방망이","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"트램펄린","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"마라톤","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"철봉","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"다이빙","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"사방치기","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"고무줄놀이","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"줄타기","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"컬링","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"하키","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"럭비","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"승마","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"카약","aliases":[],"category":"sport","difficulty":"normal"},
{"word":"머리카락","aliases":[],"category":"body","difficulty":"easy"},
{"word":"이빨","aliases":[],"category":"body","difficulty":"easy"},
{"word":"손가락","aliases":[],"category":"body","difficulty":"easy"},
{"word":"발가락","aliases":[],"category":"body","difficulty":"easy"},
{"word":"무릎","aliases":[],"category":"body","difficulty":"easy"},
{"word":"눈썹","aliases":[],"category":"body","difficulty":"easy"},
{"word":"입술","aliases":[],"category":"body","difficulty":"easy"},
{"word":"어깨","aliases":[],"category":"body","difficulty":"easy"},
{"word":"손톱","aliases":[],"category":"body","difficulty":"easy"},
{"word":"주먹","aliases":[],"category":"body","difficulty":"easy"},
{"word":"손바닥","aliases":[],"category":"body","difficulty":"easy"},
{"word":"발바닥","aliases":[],"category":"body","difficulty":"easy"},
{"word":"얼굴","aliases":[],"category":"body","difficulty":"easy"},
{"word":"머리","aliases":[],"category":"body","difficulty":"easy"},
{"word":"다리","aliases":[],"category":"body","difficulty":"easy"},
{"word":"수염","aliases":[],"category":"body","difficulty":"easy"},
{"word":"팔꿈치","aliases":[],"category":"body","difficulty":"normal"},
{"word":"발톱","aliases":[],"category":"body","difficulty":"normal"},
{"word":"눈동자","aliases":[],"category":"body","difficulty":"normal"},
{"word":"콧수염","aliases":[],"category":"body","difficulty":"normal"},
{"word":"이마","aliases":[],"category":"body","difficulty":"normal"},
{"word":"엄지손가락","aliases":["엄지"],"category":"body","difficulty":"normal"},
{"word":"콧구멍","aliases":[],"category":"body","difficulty":"normal"},
{"word":"보조개","aliases":[],"category":"body","difficulty":"normal"},
{"word":"주근깨","aliases":[],"category":"body","difficulty":"normal"},
{"word":"속눈썹","aliases":[],"category":"body","difficulty":"normal"},
{"word":"손목","aliases":[],"category":"body","difficulty":"normal"},
{"word":"발목","aliases":[],"category":"body","difficulty":"normal"},
{"word":"허리","aliases":[],"category":"body","difficulty":"normal"},
{"word":"근육","aliases":[],"category":"body","difficulty":"normal"},
{"word":"뒤통수","aliases":[],"category":"body","difficulty":"normal"},
{"word":"송편","aliases":[],"category":"season","difficulty":"easy"},
{"word":"떡국","aliases":[],"category":"season","difficulty":"easy"},
{"word":"크리스마스트리","aliases":["트리"],"category":"season","difficulty":"easy"},
{"word":"눈싸움","aliases":[],"category":"season","difficulty":"easy"},
{"word":"부채","aliases":[],"category":"season","difficulty":"easy"},
{"word":"단풍","aliases":[],"category":"season","difficulty":"easy"},
{"word":"세배","aliases":[],"category":"season","difficulty":"easy"},
{"word":"복주머니","aliases":[],"category":"season","difficulty":"easy"},
{"word":"한복","aliases":[],"category":"season","difficulty":"easy"},
{"word":"장화","aliases":[],"category":"season","difficulty":"easy"},
{"word":"우비","aliases":["비옷"],"category":"season","difficulty":"easy"},
{"word":"밀짚모자","aliases":[],"category":"season","difficulty":"easy"},
{"word":"선글라스","aliases":[],"category":"season","difficulty":"easy"},
{"word":"허수아비","aliases":[],"category":"season","difficulty":"easy"},
{"word":"낙엽","aliases":[],"category":"season","difficulty":"easy"},
{"word":"군고구마","aliases":[],"category":"season","difficulty":"easy"},
{"word":"털모자","aliases":[],"category":"season","difficulty":"easy"},
{"word":"털장갑","aliases":[],"category":"season","difficulty":"easy"},
{"word":"벚꽃","aliases":[],"category":"season","difficulty":"easy"},
{"word":"도시락","aliases":[],"category":"season","difficulty":"easy"},
{"word":"눈썰매","aliases":[],"category":"season","difficulty":"easy"},
{"word":"크리스마스","aliases":["성탄절"],"category":"season","difficulty":"easy"},
{"word":"생일","aliases":[],"category":"season","difficulty":"easy"},
{"word":"설날","aliases":[],"category":"season","difficulty":"normal"},
{"word":"추석","aliases":[],"category":"season","difficulty":"normal"},
{"word":"장마","aliases":[],"category":"season","difficulty":"normal"},
{"word":"밤송이","aliases":[],"category":"season","difficulty":"normal"},
{"word":"호빵","aliases":[],"category":"season","difficulty":"normal"},
{"word":"카네이션","aliases":[],"category":"season","difficulty":"normal"},
{"word":"김장","aliases":[],"category":"season","difficulty":"normal"},
{"word":"강강술래","aliases":[],"category":"season","difficulty":"normal"},
{"word":"달맞이","aliases":[],"category":"season","difficulty":"normal"},
{"word":"삼계탕","aliases":[],"category":"season","difficulty":"normal"},
{"word":"수박화채","aliases":[],"category":"season","difficulty":"normal"},
{"word":"소풍","aliases":[],"category":"season","difficulty":"normal"},
{"word":"모내기","aliases":[],"category":"season","difficulty":"normal"},
{"word":"그네뛰기","aliases":[],"category":"season","difficulty":"normal"},
{"word":"동지팥죽","aliases":[],"category":"season","difficulty":"normal"},
{"word":"부럼","aliases":[],"category":"season","difficulty":"normal"},
{"word":"화로","aliases":[],"category":"season","difficulty":"normal"},
{"word":"고무신","aliases":[],"category":"season","difficulty":"normal"}
]

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

@@ -0,0 +1,63 @@
/** Projective-plane deck and card layouts (docs/games/dobble.md §3). */
import type { Rng } from '@bg/engine';
import type { CardLayout } from './types';
export const ORDER = 7;
export const SYMBOLS = ORDER * ORDER + ORDER + 1; // 57
export const FULL_DECK = SYMBOLS; // 57 cards
export const PER_CARD = ORDER + 1; // 8
/** §3.1: the order-n projective plane (n prime). Card i → its n+1 symbol numbers. */
export function buildDeck(n = ORDER): number[][] {
const cards: number[][] = [];
cards.push(Array.from({ length: n + 1 }, (_, i) => i));
for (let j = 0; j < n; j++) {
const c = [0];
for (let k = 0; k < n; k++) c.push(n + 1 + n * j + k);
cards.push(c);
}
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const c = [i + 1];
for (let k = 0; k < n; k++) c.push(n + 1 + n * k + ((i * k + j) % n));
cards.push(c);
}
}
return cards;
}
/** The single symbol two different cards share (projective plane: exactly one). */
export function commonSymbol(a: readonly number[], b: readonly number[]): number {
for (const x of a) if (b.includes(x)) return x;
throw new Error('dobble: cards share no symbol');
}
/**
* §3.3: six circle-packing templates, slot = [x, y, r] in the unit circle. Every slot is at least 0.48 of
* the radius wide (≥ 72px on a 300px card, well over the 48px touch target) and no two slots overlap.
*/
export const TEMPLATES: readonly (readonly [number, number, number])[][] = [
[[-0.448, 0.516, 0.287], [-0.683, -0.028, 0.287], [0.011, -0.014, 0.287], [0.18, -0.659, 0.287], [0.628, -0.271, 0.287], [0.603, 0.322, 0.287], [-0.404, -0.552, 0.287], [0.124, 0.672, 0.287]],
[[0, 0.002, 0.399], [-0.695, 0.005, 0.275], [0.568, -0.4, 0.275], [-0.465, -0.516, 0.275], [0.078, -0.691, 0.275], [0.677, 0.159, 0.275], [-0.457, 0.523, 0.275], [0.33, 0.612, 0.275]],
[[-0.134, 0.603, 0.353], [0.365, -0.498, 0.353], [-0.654, -0.246, 0.271], [-0.261, -0.648, 0.271], [-0.129, -0.044, 0.271], [0.5, 0.488, 0.271], [0.726, 0.003, 0.244], [-0.666, 0.289, 0.244]],
[[-0.033, 0.019, 0.343], [0.051, -0.653, 0.315], [0.595, 0.274, 0.315], [0.59, -0.346, 0.286], [-0.414, 0.545, 0.286], [0.147, 0.683, 0.272], [-0.526, -0.48, 0.257], [-0.662, 0.037, 0.257]],
[[0.008, -0.646, 0.324], [0.059, 0.021, 0.324], [-0.568, -0.307, 0.324], [0.679, 0.108, 0.282], [-0.61, 0.318, 0.282], [-0.146, 0.672, 0.282], [0.568, -0.436, 0.254], [0.404, 0.591, 0.254]],
[[0.363, 0.461, 0.383], [-0.043, -0.128, 0.312], [0.393, -0.562, 0.284], [-0.307, 0.614, 0.284], [-0.591, -0.376, 0.27], [-0.678, 0.177, 0.27], [0.708, -0.1, 0.255], [-0.158, -0.695, 0.241]],
];
/** Fixes one card's look: template, which symbol sits in which slot, and (classic) size and rotation. */
export function makeLayout(symbols: readonly number[], style: 'easy' | 'classic', rng: Rng): CardLayout {
const template = rng.int(TEMPLATES.length);
const slots = TEMPLATES[template]!;
const order = rng.shuffle(symbols);
return {
template,
items: order.map((symbol, i) => {
const [x, y, r] = slots[i]!;
if (style === 'easy') return { symbol, x, y, r, scale: 1, rot: 0 };
const scale = Math.round((0.6 + rng.next() * 0.6) * 100) / 100;
const rot = rng.int(360);
return { symbol, x, y, r, scale, rot };
}),
};
}

View File

@@ -0,0 +1,849 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_STEP_MS,
COUNTDOWN_MS,
FAST_MS,
HINT_MS,
LATE_TAKEN,
ROUND_BREAK_MS,
STALL_MS,
SYMBOLS,
SYMBOL_NAMES,
TEMPLATES,
WINDOW_MS,
allCardIds,
answerArt,
buildDeck,
cardKey,
commonSymbol,
dobble,
dueAt,
pairsFor,
type ClaimAction,
type DobbleAction,
type DobbleOptions,
type DobbleState,
} from './index';
// ---------------------------------------------------------------- helpers
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
const T0 = 1_000_000;
const START = T0 + COUNTDOWN_MS;
function make(opts: Partial<DobbleOptions> = {}, players = ['A', 'B', 'C'], seed = 1): DobbleState {
const options = dobble.optionsSchema.parse(opts);
return dobble.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
}
function applyE(s: DobbleState, p: string, a: DobbleAction, now: number): { state: DobbleState; events: GameEvent[] } {
const v = dobble.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return dobble.apply(s, p, a, { rng: rng(), now });
}
const act = (s: DobbleState, p: string, a: DobbleAction, now: number) => applyE(s, p, a, now).state;
/** Countdown over → playing. */
const started = (s: DobbleState, now = START) => act(s, s.order[0]!, { type: 'tick' }, now);
/** The correct claim of `p` (on `target` for gift / hot potato). */
function right(s: DobbleState, p: string, target?: string, extra: Partial<ClaimAction> = {}): ClaimAction {
const pair = pairsFor(s, p).find((x) => (x.target ?? undefined) === target);
if (!pair) throw new Error(`${p} has no pair on ${target}`);
return {
type: 'claim',
base: pair.base,
symbol: answerArt(s, p, pair)!,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...extra,
};
}
/** A symbol that is on one compared card but is not the shared one. */
function wrong(s: DobbleState, p: string, target?: string): ClaimAction {
const r = right(s, p, target);
const card = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[p]!.at(-1)!;
const sym = s.cards[card]!.find((x) => s.symbolArt[x] !== r.symbol)!;
return { ...r, symbol: s.symbolArt[sym]! };
}
const top = (s: DobbleState, p: string) => s.piles[p]!.at(-1);
/** Ranking groups with ids sorted inside each group (seats are shuffled, so tie order follows the seat order). */
const groups = (r: { ranking: PlayerId[][] }) => r.ranking.map((g) => [...g].sort());
function conserved(s: DobbleState): void {
const all = allCardIds(s);
if (all.length !== 57) throw new Error(`card count ${all.length}`);
if (new Set(all).size !== 57) throw new Error('duplicate card');
}
/** Puts the given cards (bottom → top) on the given piles, everything else into the draw pile (tests only). */
function rig(s: DobbleState, r: { piles?: Record<string, number[]>; center?: number[]; draw?: number[] }): DobbleState {
const used = new Set([...Object.values(r.piles ?? {}).flat(), ...(r.center ?? []), ...(r.draw ?? [])]);
for (const p of s.order) {
s.piles[p] = r.piles?.[p] ?? [];
s.taken[p] = [];
}
s.center = r.center ?? [];
s.drawPile = r.draw ?? [];
s.unused = Array.from({ length: 57 }, (_, i) => i).filter((i) => !used.has(i));
return s;
}
// ---------------------------------------------------------------- §3
describe('덱과 카드 모양 (§3)', () => {
test('57장·57종, 카드당 서로 다른 8개, 모든 쌍(1596쌍) 공유 1개, 그림마다 8번', () => {
const deck = buildDeck();
expect(deck).toHaveLength(57);
expect(SYMBOLS).toBe(57);
expect(SYMBOL_NAMES).toHaveLength(57);
expect(new Set(SYMBOL_NAMES).size).toBe(57);
const seen = new Map<number, number>();
for (const c of deck) {
expect(new Set(c).size).toBe(8);
for (const x of c) seen.set(x, (seen.get(x) ?? 0) + 1);
}
expect(seen.size).toBe(57);
expect([...seen.keys()].every((x) => x >= 0 && x < 57)).toBe(true);
expect([...seen.values()].every((n) => n === 8)).toBe(true);
let pairs = 0;
for (let i = 0; i < 57; i++) {
for (let j = i + 1; j < 57; j++) {
expect(deck[i]!.filter((x) => deck[j]!.includes(x))).toHaveLength(1);
pairs++;
}
}
expect(pairs).toBe(1596);
});
test('55장 모드: RNG로 2장을 빼고 남은 55장도 쌍마다 공유 1개', () => {
for (const seed of [1, 2, 3]) {
const s = make({ deckSize: 55, miniGame: 'tower' }, ['A', 'B'], seed);
conserved(s);
expect(s.unused).toHaveLength(2);
const inPlay = allCardIds(s).filter((id) => !s.unused.includes(id));
expect(inPlay).toHaveLength(55);
for (const a of inPlay) for (const b of inPlay) if (a < b) expect(s.cards[a]!.filter((x) => s.cards[b]!.includes(x))).toHaveLength(1);
}
// Different seeds remove different cards.
expect(make({ deckSize: 55 }, ['A', 'B'], 1).unused).not.toEqual(make({ deckSize: 55 }, ['A', 'B'], 2).unused);
});
test('템플릿 6종: 원 안, 겹침 없음, 지름 300px 카드에서 칸이 48px 이상', () => {
expect(TEMPLATES).toHaveLength(6);
for (const t of TEMPLATES) {
expect(t).toHaveLength(8);
for (const [x, y, r] of t) {
expect(Math.hypot(x, y) + r).toBeLessThanOrEqual(1);
expect(2 * r * 150).toBeGreaterThanOrEqual(48);
}
for (let i = 0; i < 8; i++) for (let j = i + 1; j < 8; j++) expect(Math.hypot(t[i]![0] - t[j]![0], t[i]![1] - t[j]![1])).toBeGreaterThanOrEqual(t[i]![2] + t[j]![2]);
}
});
test('레이아웃은 셋업 때 카드마다 확정: classic은 크기 0.6~1.2·회전 0~359, easy는 1·0', () => {
const c = make({ symbolStyle: 'classic' });
const e = make({ symbolStyle: 'easy' });
for (const [s, easy] of [
[c, false],
[e, true],
] as const) {
expect(s.layouts).toHaveLength(57);
s.layouts.forEach((l, id) => {
expect(l.items.map((i) => i.symbol).sort((a, b) => a - b)).toEqual([...s.cards[id]!].sort((a, b) => a - b));
for (const i of l.items) {
if (easy) expect([i.scale, i.rot]).toEqual([1, 0]);
else {
expect(i.scale).toBeGreaterThanOrEqual(0.6);
expect(i.scale).toBeLessThanOrEqual(1.2);
expect(i.rot).toBeGreaterThanOrEqual(0);
expect(i.rot).toBeLessThan(360);
}
}
});
}
expect(new Set(c.layouts.map((l) => l.template)).size).toBeGreaterThan(3);
expect(new Set(c.layouts.flatMap((l) => l.items.map((i) => i.scale))).size).toBeGreaterThan(20);
});
test('그림 번호 → 아이콘 배정·좌석·덱 순서는 시드로 정해지고 판마다 달라요', () => {
const a = make({}, ['A', 'B', 'C'], 1);
const b = make({}, ['A', 'B', 'C'], 1);
const c = make({}, ['A', 'B', 'C'], 2);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
expect(a.symbolArt).not.toEqual(c.symbolArt);
expect([...a.symbolArt].sort((x, y) => x - y)).toEqual(Array.from({ length: 57 }, (_, i) => i));
expect(a.host).toBe('A');
});
});
// ---------------------------------------------------------------- §4
describe('준비 (§4)', () => {
test('탑 쌓기·독이 든 선물: 각자 1장 공개, 가운데 1장 공개, 나머지는 뽑기 더미. 3초 카운트다운 동안 그림은 안 보여요', () => {
for (const miniGame of ['tower', 'poisonedGift'] as const) {
const s = make({ miniGame }, ['A', 'B', 'C', 'D']);
conserved(s);
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
expect(s.center).toHaveLength(1);
expect(s.drawPile).toHaveLength(57 - 5);
expect(s.phase).toBe('countdown');
const v = dobble.view(s, 'A');
expect(v.center).toBeNull();
expect(v.seats.every((x) => x.top === null)).toBe(true);
expect(v.pairs).toEqual([]);
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center[0]!, symbol: 0 })).toEqual({ ok: false, reason: '곧 시작해요. 잠시만 기다려 주세요.' });
expect(dueAt(s)).toBe(START);
const p = started(s);
expect(p.phase).toBe('playing');
const v2 = dobble.view(p, 'A');
expect(v2.center?.key).toBe(cardKey(p.center[0]!));
expect(v2.seats.every((x) => x.top !== null)).toBe(true);
}
});
test.each([2, 3, 4, 5, 6, 7, 8])('우물 %i명: 가운데 1장, 1인당 floor((덱-1)/N)장, 나머지는 사용 안 함', (n) => {
const players = Array.from({ length: n }, (_, i) => `P${i}`);
for (const deckSize of [57, 55] as const) {
const s = make({ miniGame: 'well', deckSize }, players);
conserved(s);
const per = Math.floor((deckSize - 1) / n);
for (const p of players) expect(s.piles[p]).toHaveLength(per);
expect(s.center).toHaveLength(1);
expect(s.unused).toHaveLength(57 - 1 - per * n);
// Only the top of each pile is face up (after the countdown).
const v = dobble.view(started(s), players[0]!);
for (const seat of v.seats) expect(seat.top?.key).toBe(cardKey(s.piles[seat.id]!.at(-1)!));
}
});
test('뜨거운 감자: 라운드마다 각자 1장, 8명 × 7라운드도 57장이면 충분, 55장이면 시작 전에 막아요', () => {
const s = make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']);
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
expect(s.drawPile).toHaveLength(54);
const eight = Array.from({ length: 8 }, (_, i) => `P${i}`);
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7 }), 8)).toBeNull();
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 8)).toContain('카드가 모자라요');
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 7)).toBeNull();
expect(make({ miniGame: 'hotPotato', hotPotatoRounds: 7 }, eight).drawPile).toHaveLength(57 - 8);
});
});
// ---------------------------------------------------------------- §5
describe('찾기 판정 (§5.1, §5.3)', () => {
test('탑 쌓기: 같은 가운데 카드에 두 사람이 정답 → 먼저 도착한 사람만 가져가고, 나중 사람은 "늦었어요"(잠금 없음)', () => {
let s = started(make({}, ['A', 'B', 'C']));
const center = s.center[0]!;
const a = right(s, 'A');
const b = right(s, 'B');
expect(a.base).toBe(center);
expect(b.base).toBe(center);
const { state, events } = applyE(s, 'A', a, START + 500);
s = state;
expect(s.piles.A).toEqual([expect.any(Number), center]);
expect(top(s, 'A')).toBe(center);
expect(s.center).toHaveLength(1);
expect(s.center[0]).not.toBe(center);
expect(events.map((e) => e.type)).toEqual(['hit', 'reveal']);
expect(s.feedback.A).toMatchObject({ kind: 'ok', art: a.symbol });
// B's claim for the same centre card arrives 50ms later.
expect(dobble.validate(s, 'B', b)).toEqual({ ok: false, reason: LATE_TAKEN });
expect(s.lockedUntil.B).toBe(0);
expect(s.stats.B).toMatchObject({ correct: 0, wrong: 0 });
});
test('틀린 그림 → wrongLockoutMs 동안 정답도 무시, 잠금이 풀리면 허용 (serverTs 기준)', () => {
let s = started(make({ wrongLockoutMs: 2000 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 100);
expect(s.lockedUntil.A).toBe(START + 2100);
expect(s.stats.A!.wrong).toBe(1);
expect(s.feedback.A?.kind).toBe('wrong');
const before = s.piles.A!.length;
s = act(s, 'A', right(s, 'A'), START + 2099);
expect(s.feedback.A?.kind).toBe('locked');
expect(s.piles.A).toHaveLength(before);
s = act(s, 'A', right(s, 'A'), START + 2100);
expect(s.feedback.A?.kind).toBe('ok');
expect(s.piles.A).toHaveLength(before + 1);
});
test('wrongLockoutMs 0: 틀려도 바로 다시 누를 수 있어요', () => {
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 100);
s = act(s, 'A', right(s, 'A'), START + 100);
expect(s.feedback.A?.kind).toBe('ok');
});
test('house rule wrongGivesCard: 탑 쌓기에서 틀리면 내 탑 맨 위가 뽑기 더미 맨 아래로(마지막 1장은 지켜요)', () => {
let s = started(make({ wrongGivesCard: true, wrongLockoutMs: 0 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 10);
expect(s.piles.A).toHaveLength(1);
s = act(s, 'A', right(s, 'A'), START + 20);
const myTop = top(s, 'A')!;
const draw = s.drawPile.length;
s = act(s, 'A', wrong(s, 'A'), START + 30);
expect(s.piles.A).toHaveLength(1);
expect(s.drawPile).toHaveLength(draw + 1);
expect(s.drawPile[0]).toBe(myTop);
conserved(s);
});
test('잘못된 요청: 탑·우물은 대상 없음, 선물·감자는 대상 필수, 이미 바뀐 내 카드는 늦음', () => {
const s = started(make({}, ['A', 'B']));
expect(dobble.validate(s, 'A', { ...right(s, 'A'), target: 'B' }).ok).toBe(false);
const g = started(make({ miniGame: 'poisonedGift' }, ['A', 'B']));
expect(dobble.validate(g, 'A', { type: 'claim', base: g.center[0]!, symbol: 1 })).toEqual({ ok: false, reason: '누구 카드에서 찾을지 먼저 골라 주세요.' });
expect(dobble.validate(s, 'Z', right(s, 'A')).ok).toBe(false);
const h = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
const c = right(h, 'A', 'B');
expect(dobble.validate(h, 'A', { ...c, mine: (c.mine! + 1) % 57 }).ok).toBe(false);
});
});
describe('미니게임 (§5.2, §6)', () => {
test('탑 쌓기: 뽑기 더미가 끝나면 카드가 가장 많은 사람이 1등, 동점은 공동 순위, 점수 3·2·1', () => {
let s = started(make({}, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5, 6] });
let now = START;
for (const p of ['A', 'A', 'B', 'C']) s = act(s, p, right(s, p), (now += 300));
expect(s.phase).toBe('ended');
expect(dobble.view(s, 'A').center).toBeNull();
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
expect(r.scores).toEqual({ A: 3, B: 2, C: 2 });
expect(r.summary).toBe('탑 쌓기 끝! 카드 3장을 모은 사람이 1등이에요.');
expect(s.final!.ranking[0]).toMatchObject({ id: 'A', rank: 1, cards: 3, points: 3 });
});
test('우물: 내 카드를 가운데로, 다 비운 순서가 순위, 마지막 1명은 꼴찌로 바로 종료', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1, 2], C: [3, 4] }, center: [5] });
let now = START;
const aTop = top(s, 'A')!;
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.center.at(-1)).toBe(aTop);
expect(s.status.A).toBe('finished');
expect(s.finishedOrder).toEqual(['A']);
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center.at(-1)!, symbol: 0 })).toEqual({ ok: false, reason: '이미 카드를 다 비웠어요.' });
s = act(s, 'C', right(s, 'C'), (now += 200));
s = act(s, 'C', right(s, 'C'), (now += 200));
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
expect(r.scores).toEqual({ A: 3, C: 2, B: 1 });
expect(dobble.view(s, null).seats.find((x) => x.id === 'C')!.place).toBe(2);
});
test('우물 wellEndAtFirst: 처음 다 비운 사람이 나오면 바로 끝, 나머지는 남은 장수 순', () => {
let s = started(make({ miniGame: 'well', wellEndAtFirst: true }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0, 1], B: [2, 3, 4], C: [5, 6] }, center: [7] });
let now = START;
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.phase).toBe('playing');
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.phase).toBe('ended');
expect(groups(dobble.result(s)!)).toEqual([['A'], ['C'], ['B']]);
});
test('뜨거운 감자: 묶음 전체를 상대 위로, 탈출자는 대상 불가, 한 명이 다 가지면 라운드 끝·벌점 누적', () => {
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, draw: Array.from({ length: 10 }, (_, i) => 10 + i) });
let now = START;
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
expect(s.piles.A).toEqual([]);
expect(s.piles.B).toEqual([1, 0]);
expect(s.status.A).toBe('escaped');
expect(s.outThisRound).toEqual(['A']);
// Nobody may target the escaped A, and A cannot claim this round.
expect(dobble.validate(s, 'C', { type: 'claim', base: 0, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '이미 이번 라운드를 탈출한 사람이에요.' });
expect(dobble.validate(s, 'A', { type: 'claim', base: 0, target: 'C', symbol: 1 }).ok).toBe(false);
expect(pairsFor(s, 'C').map((x) => x.target).sort()).toEqual(['B']);
// C's claim was made against B's old top → late.
expect(dobble.validate(s, 'C', { type: 'claim', base: 1, target: 'B', symbol: 1 }).ok).toBe(false);
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
expect(s.phase).toBe('roundEnd');
expect(s.penalty).toEqual({ A: 0, B: 3, C: 0 });
expect(s.taken.B).toEqual([1, 0, 2]);
expect(s.lastRound).toEqual({ round: 1, loser: 'B', cards: 3 });
conserved(s);
// Round 2 after the break + countdown.
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
expect(s.phase).toBe('countdown');
expect(s.round).toBe(2);
expect(s.order.every((p) => s.piles[p]!.length === 1 && s.status[p] === 'playing')).toBe(true);
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
expect(s.phase).toBe('playing');
s = act(s, 'B', right(s, 'B', 'A'), (now += 300));
s = act(s, 'C', right(s, 'C', 'A'), (now += 300));
expect(s.penalty).toEqual({ A: 3, B: 3, C: 0 });
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
s = act(s, 'A', right(s, 'A', 'C'), (now += 300));
expect(s.phase).toBe('ended');
expect(s.penalty).toEqual({ A: 3, B: 3, C: 3 });
expect(groups(dobble.result(s)!)).toEqual([['A', 'B', 'C']]);
expect(dobble.result(s)!.summary).toContain('공동 1등 3명');
conserved(s);
});
test('뜨거운 감자: 설정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등', () => {
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B']));
let now = START;
for (const loser of ['A', 'A', 'B']) {
const winner = loser === 'A' ? 'B' : 'A';
s = act(s, winner, right(s, winner, loser), (now += 400));
if (s.phase === 'ended') break;
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
}
expect(s.phase).toBe('ended');
expect(s.penalty).toEqual({ A: 4, B: 2 });
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['B'], ['A']]);
expect(s.final!.ranking.find((x) => x.id === 'B')).toMatchObject({ penalty: 2, points: 3 });
});
test('독이 든 선물: 내 카드에는 못 올리고, 더미가 끝나면 카드가 가장 적은 사람이 1등(동점 공동)', () => {
let s = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5] });
expect(dobble.validate(s, 'A', { type: 'claim', base: 3, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '내 카드에는 선물을 줄 수 없어요.' });
expect(pairsFor(s, 'A').map((x) => x.target).sort()).toEqual(['B', 'C']);
let now = START;
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
expect(s.piles.B).toEqual([1, 3]);
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
expect(r.summary).toBe('독이 든 선물 끝! 카드를 1장만 받은 사람이 1등이에요.');
// Tie: two players with the fewest cards share 1st.
let t = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
rig(t, { piles: { A: [0], B: [1], C: [2] }, center: [3] });
t = act(t, 'A', right(t, 'A', 'C'), START + 100);
expect(groups(dobble.result(t)!)).toEqual([['A', 'B'], ['C']]);
expect(dobble.result(t)!.scores).toEqual({ A: 3, B: 3, C: 1 });
});
});
describe('지연 보정 light (§5.3)', () => {
function duel(delayMs: number) {
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
s.rtt = { A: 200, B: 20 };
const t = START + 1000;
const b = right(s, 'B', undefined, { rtt: 20 });
const a = right(s, 'A', undefined, { rtt: 200 });
s = act(s, 'B', b, t);
expect(s.pendingWindow?.claims).toHaveLength(1);
expect(s.center).toHaveLength(1);
const v = dobble.validate(s, 'A', a);
if (v.ok) s = act(s, 'A', a, t + delayMs);
// The server closes the window when its deadline comes.
if (s.pendingWindow) {
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'resolve' });
expect(dueAt(s)).toBe(t + WINDOW_MS);
s = act(s, 'A', { type: 'resolve' }, t + WINDOW_MS);
}
return s;
}
test('상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms): 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리', () => {
const s = duel(40);
expect(s.stats.A!.correct).toBe(1);
expect(s.stats.B!.correct).toBe(0);
expect(s.feedback.B?.kind).toBe('late');
expect(s.lastHit?.player).toBe('A');
});
test('100ms 늦게 도착하면 60ms 창 밖이라 패배("늦었어요")', () => {
const s = duel(100);
expect(s.stats.B!.correct).toBe(1);
expect(s.stats.A!.correct).toBe(0);
expect(s.feedback.A?.kind).toBe('late');
});
test('RTT는 찾기에 실린 값의 지수이동평균(α=0.2), 보정 상한 80ms', () => {
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 10);
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 100 }, START + 20);
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 200 }, START + 3000);
expect(s.rtt.B).toBe(120);
expect(s.rtt.A).toBeNull();
s = act(s, 'B', { ...right(s, 'B'), rtt: 5000 }, START + 6000);
expect(s.rtt.B).toBe(496);
expect(s.pendingWindow!.claims[0]!.adjTs).toBe(START + 6000 - 80);
});
});
describe('시간 초과·자리 비움·나가기 (§8.5)', () => {
test('onTimeout은 tick(창이 열려 있으면 resolve), 45초 동안 아무도 못 맞히면 공통 그림을 2초 보여 주고 계속', () => {
let s = started(make({}, ['A', 'B']));
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'tick' });
expect(dueAt(s)).toBe(START + STALL_MS);
s = act(s, 'A', { type: 'tick' }, START + STALL_MS);
expect(s.hintUntil).toBe(START + STALL_MS + HINT_MS);
const v = dobble.view(s, 'A');
expect(v.hint).toEqual([right(s, 'A').symbol]);
expect(dobble.view(s, 'B').hint).toEqual([right(s, 'B').symbol]);
expect(dobble.view(s, null).hint).toEqual([]);
s = act(s, 'B', { type: 'tick' }, START + STALL_MS + HINT_MS);
expect(s.hintUntil).toBe(0);
expect(dobble.view(s, 'A').hint).toEqual([]);
expect(s.stats.A!.correct + s.stats.B!.correct).toBe(0);
expect(dueAt(s)).toBe(START + STALL_MS + HINT_MS + STALL_MS);
});
test('우물: 30초 넘게 끊긴 사람은 기권(카드는 상자로) → 꼴찌 그룹, 남은 사람끼리 계속', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
// The room times out a disconnected player before the game deadline only after its grace period:
// such early ticks come at most every AWAY_STEP_MS (timeoutNotBefore) and are strikes.
let now = START + 30_000;
expect(dobble.timeoutNotBefore!(s)).toBeLessThanOrEqual(now);
for (let i = 0; i < 3; i++) {
expect(s.status.C).toBe('playing');
s = act(s, 'C', { type: 'tick' }, now);
expect(dobble.timeoutNotBefore!(s)).toBe(now + AWAY_STEP_MS);
// Someone keeps playing meanwhile (the stall timer must not swallow the strikes).
s = act(s, 'A', right(s, 'A'), now + 5000);
now += AWAY_STEP_MS;
}
expect(s.status.C).toBe('forfeit');
expect(s.piles.C).toEqual([]);
expect(s.phase).toBe('playing');
conserved(s);
expect(dobble.activePlayers(s)).toEqual(s.order.filter((p) => p !== 'C'));
expect(dobble.validate(s, 'C', { type: 'claim', base: s.center.at(-1)!, symbol: 0 }).ok).toBe(false);
// A and B play on; the forfeited C ends in the bottom group.
while (s.phase === 'playing') s = act(s, 'B', right(s, 'B'), (now += 300));
const r = dobble.result(s)!;
expect(r.ranking.at(-1)).toEqual(['C']);
expect(s.final!.ranking.find((x) => x.id === 'C')).toMatchObject({ status: 'forfeit', points: 0 });
});
test('짧게 끊겼다 돌아오면(찾기를 하면) 기권 카운트가 초기화돼요', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
let now = START + 60_000;
s = act(s, 'C', { type: 'tick' }, now);
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
s = act(s, 'C', wrong(s, 'C'), (now += 1000));
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
expect(s.status.C).toBe('playing');
expect(s.away.C!.strikes).toBe(1);
});
test('탑 쌓기: 끊긴 사람이 있어도 그대로 진행, 모두 자리를 비우면 끝내요', () => {
let s = started(make({}, ['A', 'B']));
let now = START + 10_000;
for (let i = 0; i < 3; i++) {
s = act(s, 'B', { type: 'tick' }, now);
s = act(s, 'A', right(s, 'A'), now + 1000);
now += AWAY_STEP_MS;
}
expect(s.away.B!.strikes).toBe(3);
expect(s.status.B).toBe('playing');
expect(s.phase).toBe('playing');
// Now both are away: the room ticks whoever is first in timeoutPlayers once timeoutNotBefore passes.
for (let i = 0; i < 20 && s.phase === 'playing'; i++) {
now = Math.max(now, dobble.timeoutNotBefore!(s)!);
const who = dobble.timeoutPlayers!(s)[0]!;
s = act(s, who, { type: 'tick' }, now);
}
expect(s.phase).toBe('ended');
expect(dobble.result(s)!.summary).toBe('모두 자리를 비워서 게임을 끝냈어요.');
});
test('나가기: 탑 쌓기는 탑이 남고 꼴찌, 1명만 남으면 끝', () => {
let s = started(make({}, ['A', 'B', 'C']));
expect(dobble.onLeave!(s, 'C')).toEqual({ type: 'leave' });
s = act(s, 'C', { type: 'leave' }, START + 100);
expect(s.status.C).toBe('left');
expect(s.piles.C).toHaveLength(1);
expect(dobble.onLeave!(s, 'C')).toBeNull();
s = act(s, 'B', { type: 'leave' }, START + 200);
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
expect(r.summary).toContain('다른 사람이 모두 나가서');
});
test('나가기: 뜨거운 감자는 대상에서 빠지고 카드는 상자로, 겨룰 사람이 없어지면 그 라운드는 무효', () => {
let s = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
s = act(s, 'A', right(s, 'A', 'B'), START + 300);
s = act(s, 'C', { type: 'leave' }, START + 400);
expect(s.phase).toBe('roundEnd');
expect(s.lastRound).toEqual({ round: 1, loser: null, cards: 0 });
expect(s.penalty).toEqual({ A: 0, B: 0, C: 0 });
conserved(s);
s = act(s, 'A', { type: 'tick' }, START + 400 + ROUND_BREAK_MS);
expect(s.piles.C).toEqual([]);
expect(s.piles.A).toHaveLength(1);
});
test('onTimeout은 모든 상태에서 합법', () => {
let s = make({ miniGame: 'hotPotato', latencyComp: 'light' }, ['A', 'B', 'C']);
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
s = started(s);
s = act(s, 'A', right(s, 'A', 'B'), START + 10);
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
});
});
describe('비공개 정보·봇 의심·결정성 (§8.3, §10)', () => {
test('view에는 뽑기 더미·우물 뒷면·미사용 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => {
for (const miniGame of ['tower', 'well', 'hotPotato', 'poisonedGift'] as const) {
const s = started(make({ miniGame, deckSize: 55 }, ['A', 'B', 'C']));
const t = structuredClone(s);
// Swap hidden cards around (draw pile ↔ box ↔ well face-down cards).
const hidden = [...t.drawPile, ...t.unused, ...(miniGame === 'well' ? t.order.flatMap((p) => t.piles[p]!.slice(0, -1)) : [])];
const shuffled = SeededRng.fromSeed([1, 2, 3, 4]).shuffle(hidden);
let k = 0;
t.drawPile = t.drawPile.map(() => shuffled[k++]!);
t.unused = t.unused.map(() => shuffled[k++]!);
if (miniGame === 'well') for (const p of t.order) t.piles[p] = [...t.piles[p]!.slice(0, -1).map(() => shuffled[k++]!), t.piles[p]!.at(-1)!];
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(dobble.view(t, viewer))).toBe(JSON.stringify(dobble.view(s, viewer)));
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
// Layouts of hidden cards are never sent.
const v = JSON.stringify(dobble.view(s, 'A'));
for (const id of s.drawPile) expect(v).not.toContain(`"key":"${cardKey(id)}"`);
expect(v).not.toContain('"symbol"');
expect(v).not.toContain('rng');
}
});
test('카운트다운 중에는 모든 카드가 비밀(덱이 미리 보이지 않음)', () => {
const s = make({ miniGame: 'hotPotato' }, ['A', 'B']);
expect(dobble.secretsOf!(s, 'A')).toContain(cardKey(s.piles.A![0]!));
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
});
test('봇 의심: 카드가 바뀐 뒤 200ms 안에 맞히기를 5번 연속 → 방장 view에만 표시', () => {
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B', 'C']));
let now = START;
for (let i = 0; i < 5; i++) {
expect(s.suspect).toEqual([]);
s = act(s, 'B', right(s, 'B'), (now += FAST_MS - 50));
}
expect(s.suspect).toEqual(['B']);
expect(dobble.view(s, 'A').suspects).toEqual(['B']);
expect(dobble.view(s, 'A').isHost).toBe(true);
expect(dobble.view(s, 'B').suspects).toEqual([]);
expect(dobble.view(s, 'C').suspects).toEqual([]);
expect(dobble.view(s, null).suspects).toEqual([]);
// A slow answer resets the streak.
let t = started(make({}, ['A', 'B']));
let n = START;
for (let i = 0; i < 4; i++) t = act(t, 'B', right(t, 'B'), (n += 100));
t = act(t, 'B', right(t, 'B'), (n += 900));
expect(t.stats.B!.fastHits).toBe(0);
});
test('이벤트에는 공개된 카드만 담긴다', () => {
let s = started(make({}, ['A', 'B']));
const { state, events } = applyE(s, 'A', right(s, 'A'), START + 300);
s = state;
const secrets = dobble.secretsOf!(s, 'A');
for (const e of events) for (const k of secrets) expect(JSON.stringify(e)).not.toContain(`"${k}"`);
expect(events.find((e) => e.type === 'reveal')).toEqual({ type: 'reveal', key: cardKey(s.center.at(-1)!) });
});
test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => {
for (const opts of [{}, { miniGame: 'hotPotato', latencyComp: 'light' }, { miniGame: 'well' }] as Partial<DobbleOptions>[]) {
const options = dobble.optionsSchema.parse(opts);
const a = fastPlayout(['A', 'B', 'C', 'D'], options, seedN(42), 20_000);
expect(a.finished).toBe(true);
const replay = (log: FastReport['log']) => {
let s = dobble.setup({ players: ['A', 'B', 'C', 'D'], options, rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 });
for (const x of log) s = dobble.apply(s, x.p, x.a, { rng: rng(), now: x.now }).state;
return s;
};
expect(JSON.stringify(replay(a.log))).toBe(JSON.stringify(a.final));
}
});
});
// ---------------------------------------------------------------- random playouts
interface FastReport {
steps: number;
finished: boolean;
log: { p: PlayerId; a: DobbleAction; now: number }[];
final: DobbleState;
}
function invariants(s: DobbleState): void {
conserved(s);
const o = s.options;
for (const p of s.order) {
const st = s.status[p]!;
if (o.miniGame === 'well' && st === 'finished' && s.piles[p]!.length) throw new Error('finished with cards');
if ((o.miniGame === 'tower' || o.miniGame === 'poisonedGift') && s.piles[p]!.length < 1) throw new Error(`${p} lost the starting card`);
if (o.miniGame === 'hotPotato' && s.penalty[p] !== s.taken[p]!.length) throw new Error('penalty != taken');
if (st === 'escaped' && (o.miniGame !== 'hotPotato' || s.piles[p]!.length)) throw new Error('bad escaped');
}
if (s.phase === 'playing') {
if (s.center.length > 0 && o.miniGame !== 'well' && s.center.length !== 1) throw new Error('centre holds more than one card');
const live = s.order.filter((p) => s.status[p] !== 'forfeit' && s.status[p] !== 'left');
if (live.length < 2) throw new Error('game should have ended');
if (o.miniGame === 'hotPotato' && s.order.filter((p) => s.status[p] === 'playing' && s.piles[p]!.length).length < 2) throw new Error('round should have ended');
if (o.miniGame !== 'hotPotato' && s.center.length === 0) throw new Error('no centre card');
}
if (s.phase === 'ended' && !s.final) throw new Error('ended without result');
}
/**
* Time moves 20–1500ms per step. Mostly correct claims (some made against an older table → late),
* some wrong ones, server ticks when due, and now and then a disconnected player's early tick or a leave.
*/
function fastPlayout(players: PlayerId[], options: DobbleOptions, seed: RngState, maxSteps: number, away: PlayerId | null = null): 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 = dobble.setup({ players, options, rng: r, now });
const log: FastReport['log'] = [];
let stale: { p: PlayerId; a: ClaimAction } | null = null;
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 20 === 0) checkNoLeak(dobble, s, players[step % players.length]!, players);
for (const p of dobble.timeoutPlayers!(s)) {
const v = dobble.validate(s, p, dobble.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (dobble.result(s)) return { steps: step, finished: true, log, final: s };
now += 20 + chooser.int(1480);
const due = dueAt(s)!;
let actor: PlayerId;
let action: DobbleAction;
const roll = chooser.int(100);
const claimers = s.order.filter((p) => p !== away && pairsFor(s, p).length > 0);
const notBefore = dobble.timeoutNotBefore!(s);
if (away && notBefore !== null && now >= notBefore && now < due && dobble.timeoutPlayers!(s).includes(away)) {
// The disconnected player is timed out as soon as timeoutNotBefore allows (the room does the same after grace).
actor = away;
action = dobble.onTimeout(s, away);
} else if (now >= due || claimers.length === 0) {
if (now < due) now = due;
const tp = dobble.timeoutPlayers!(s);
actor = tp[chooser.int(tp.length)]!;
action = dobble.onTimeout(s, actor);
} else if (roll < 2 && dobble.timeoutPlayers!(s).length > 0) {
// A disconnected player's early timeout (only fires once per away step, as the room would).
const nb = dobble.timeoutNotBefore!(s)!;
if (now < nb) now = nb;
const tp = dobble.timeoutPlayers!(s);
actor = tp[0]!;
action = dobble.onTimeout(s, actor);
} else if (roll < 3) {
const live = s.order.filter((p) => dobble.onLeave!(s, p));
actor = live[chooser.int(live.length)]!;
action = { type: 'leave' };
} else if (roll < 10 && stale) {
actor = stale.p;
action = stale.a;
} else {
actor = claimers[chooser.int(claimers.length)]!;
const pairs = pairsFor(s, actor);
const pair = pairs[chooser.int(pairs.length)]!;
const ans = answerArt(s, actor, pair)!;
const wrongOne = roll < 25;
const cc = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[actor]!.at(-1)!;
const sym = wrongOne ? s.symbolArt[s.cards[cc]![chooser.int(8)]!]! : ans;
action = {
type: 'claim',
base: pair.base,
symbol: sym,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
rtt: chooser.int(300),
};
stale = { p: claimers[chooser.int(claimers.length)]!, a: action as ClaimAction };
}
const v = dobble.validate(s, actor, action);
if (!v.ok) {
if (action.type !== 'claim') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${v.reason}`);
continue;
}
const out = dobble.apply(s, actor, action, { rng: r, now });
const secrets = dobble.secretsOf!(out.state, actor);
for (const e of out.events) {
const j = JSON.stringify(e);
for (const k of secrets) if (j.includes(`"${k}"`)) throw new Error(`event leaks ${k}`);
}
s = out.state;
log.push({ p: actor, a: action, now });
}
return { steps: maxSteps, finished: false, log, final: s };
}
const PRESETS: Partial<DobbleOptions>[] = [
{},
{ miniGame: 'well' },
{ miniGame: 'hotPotato' },
{ miniGame: 'poisonedGift' },
{ miniGame: 'tower', latencyComp: 'light', wrongGivesCard: true, deckSize: 55 },
{ miniGame: 'well', wellEndAtFirst: true, wrongLockoutMs: 0, symbolStyle: 'easy' },
{ miniGame: 'hotPotato', hotPotatoRounds: 7, latencyComp: 'light', wrongLockoutMs: 2000 },
{ miniGame: 'poisonedGift', deckSize: 55, latencyComp: 'light' },
{ miniGame: 'hotPotato', hotPotatoRounds: 3, deckSize: 55 },
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const tally: Record<string, number> = { correct: 0, wrong: 0, forfeit: 0, left: 0 };
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % 7);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
// Every 5th game one player is disconnected for the whole game (never claims).
const away = i % 5 === 0 ? players[1]! : null;
const a = fastPlayout(players, options, seedN(i), 20_000, away);
if (!a.finished) throw new Error(`seed ${i} (${n} players, ${JSON.stringify(PRESETS[i % PRESETS.length])}) did not finish`);
const res = dobble.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
for (const p of players) {
tally.correct! += a.final.stats[p]!.correct;
tally.wrong! += a.final.stats[p]!.wrong;
if (a.final.status[p] === 'forfeit') tally.forfeit!++;
if (a.final.status[p] === 'left') tally.left!++;
}
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000, away);
expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log));
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(tally.correct).toBeGreaterThan(10_000);
expect(tally.wrong).toBeGreaterThan(1000);
expect(tally.forfeit).toBeGreaterThan(0);
expect(tally.left).toBeGreaterThan(0);
}, 60_000);
test.each([
[2, {}],
[3, { miniGame: 'well' }],
[4, { miniGame: 'hotPotato', hotPotatoRounds: 3 }],
[5, { miniGame: 'poisonedGift', latencyComp: 'light' }],
[8, { miniGame: 'tower', wrongGivesCard: true }],
] as const)('engine randomPlayout (per-step leak / round-trip checks): %i players %o', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(opts);
const inv = (st: unknown) => invariants(st as DobbleState);
for (let i = 1; i <= 4; i++) {
const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, maxApplyMs: 20 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
});
});
test('공통 그림 헬퍼', () => {
const d = buildDeck();
expect(d[0]!).toContain(commonSymbol(d[0]!, d[1]!));
expect(d[1]!).toContain(commonSymbol(d[0]!, d[1]!));
});

View File

@@ -0,0 +1,856 @@
/**
* 도블 — 같은 그림 찾기 (docs/games/dobble.md). Pure and deterministic; state is JSON; time only via ctx.now.
* Real time: everyone may claim at any moment, the room's single-threaded queue (arrival order) decides.
*/
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import { FULL_DECK, buildDeck, makeLayout } from './deck';
import {
MINI_GAME_KO,
dobbleActionSchema,
dobbleOptionsSchema,
type CardView,
type ClaimAction,
type ClaimPair,
type DobbleAction,
type DobbleFinal,
type DobbleOptions,
type DobbleState,
type DobbleView,
type Feedback,
type LogEntry,
type PendingClaim,
type RankRow,
type SeatStatus,
} from './types';
export * from './types';
export { FULL_DECK, PER_CARD, SYMBOLS, TEMPLATES, buildDeck, commonSymbol, makeLayout } from './deck';
type S = DobbleState;
type A = DobbleAction;
export const COUNTDOWN_MS = 3000;
/** Hot potato: pause showing the round's loser before the next countdown. */
export const ROUND_BREAK_MS = 2500;
/** §5.3 `latencyComp: 'light'` window. */
export const WINDOW_MS = 60;
export const MAX_LATENCY_CREDIT_MS = 80;
/** §8.5 stall: nobody found anything for this long → show the common symbol for HINT_MS. */
export const STALL_MS = 45_000;
export const HINT_MS = 2000;
/**
* Away detection: the room only times a player out before the game's own deadline when they have been
* disconnected past the grace period. Such early ticks are spaced AWAY_STEP_MS apart; AWAY_STRIKES of them
* (≥ grace + 20s, so always ≥ 30s) forfeit the player in well / hot potato (§8.5).
*/
export const AWAY_STEP_MS = 10_000;
export const AWAY_STRIKES = 3;
/** §5.3 봇 의심: FAST_STREAK correct claims in a row, each < FAST_MS after the cards changed. */
export const FAST_MS = 200;
export const FAST_STREAK = 5;
const RTT_ALPHA = 0.2;
const LOG_KEEP = 30;
const POINTS = [3, 2, 1];
/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */
export const cardKey = (id: number): string => `dc${id}`;
export const LATE_TAKEN = '늦었어요! 다른 사람이 먼저 찾았어요.';
export const LATE_CHANGED = '늦었어요! 그 사이에 카드가 바뀌었어요.';
export const lockedText = (ms: number): string => `잠깐! ${Math.max(1, Math.ceil(ms / 1000))}초 뒤에 다시 눌러요`;
// ---------------------------------------------------------------- queries
const topOf = (s: S, p: PlayerId): number | undefined => s.piles[p]?.[s.piles[p]!.length - 1];
const centerTop = (s: S): number | undefined => s.center[s.center.length - 1];
/** Still in the game (not forfeited / left). */
const inGame = (s: S, p: PlayerId): boolean => s.status[p] !== 'forfeit' && s.status[p] !== 'left';
const usesTarget = (o: DobbleOptions): boolean => o.miniGame === 'hotPotato' || o.miniGame === 'poisonedGift';
/** Every card id, wherever it is (test invariant: each id exactly once). */
export function allCardIds(s: S): number[] {
return [...s.order.flatMap((p) => [...s.piles[p]!, ...s.taken[p]!]), ...s.center, ...s.drawPile, ...s.unused];
}
export function canClaim(s: S, p: PlayerId): boolean {
if (s.phase !== 'playing' || s.status[p] !== 'playing') return false;
return s.options.miniGame === 'poisonedGift' || s.piles[p]!.length > 0;
}
/** Whether `q` may be the target of `p`'s claim (gift / hot potato). */
function validTarget(s: S, p: PlayerId, q: PlayerId): boolean {
return q !== p && s.order.includes(q) && s.status[q] === 'playing' && s.piles[q]!.length > 0;
}
/** Pairs of cards `p` may claim on right now (§5.2). */
export function pairsFor(s: S, p: PlayerId): ClaimPair[] {
if (!canClaim(s, p)) return [];
const c = centerTop(s);
const mine = topOf(s, p);
switch (s.options.miniGame) {
case 'tower':
case 'well':
return c === undefined || mine === undefined ? [] : [{ target: null, base: c, mine }];
case 'poisonedGift':
return c === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: c, mine: null }));
case 'hotPotato':
return mine === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: topOf(s, q)!, mine }));
}
}
/** The two card ids compared by a claim of `p` on `target` (null = the claim cannot be judged). */
function comparedCards(s: S, p: PlayerId, target: PlayerId | undefined): [number, number] | null {
const c = centerTop(s);
const mine = topOf(s, p);
switch (s.options.miniGame) {
case 'tower':
case 'well':
return c === undefined || mine === undefined ? null : [mine, c];
case 'poisonedGift': {
const t = target === undefined ? undefined : topOf(s, target);
return c === undefined || t === undefined ? null : [t, c];
}
case 'hotPotato': {
const t = target === undefined ? undefined : topOf(s, target);
return mine === undefined || t === undefined ? null : [mine, t];
}
}
}
/** The single symbol number shared by two cards. */
function common(s: S, a: number, b: number): number {
const x = s.cards[a]!;
const y = s.cards[b]!;
for (const v of x) if (y.includes(v)) return v;
throw new Error(`dobble: cards ${a} and ${b} share nothing`);
}
/** The correct icon id for a pair (tests / legalActions / stall hint). */
export function answerArt(s: S, p: PlayerId, pair: ClaimPair): number | null {
const cc = comparedCards(s, p, pair.target ?? undefined);
return cc ? s.symbolArt[common(s, cc[0], cc[1])]! : null;
}
/** Reference card id a claim on `target` must carry. */
function currentBase(s: S, target: PlayerId | undefined): number | undefined {
return s.options.miniGame === 'hotPotato' ? (target === undefined ? undefined : topOf(s, target)) : centerTop(s);
}
/** Earliest time the server timer has something to do (null when ended). */
export function dueAt(s: S): number | null {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown' || s.phase === 'roundEnd') return s.phaseEndsAt;
if (s.pendingWindow) return s.pendingWindow.openedAt + WINDOW_MS;
return s.hintUntil > 0 ? s.hintUntil : s.changedAt + STALL_MS;
}
// ---------------------------------------------------------------- helpers
function clone(s: S): S {
const rec = <T>(r: Record<PlayerId, T>, f: (v: T) => T) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, f(v)])) as Record<PlayerId, T>;
return {
...s, // cards, layouts, symbolArt and options are never mutated
order: [...s.order],
status: { ...s.status },
piles: rec(s.piles, (v) => [...v]),
center: [...s.center],
drawPile: [...s.drawPile],
unused: [...s.unused],
taken: rec(s.taken, (v) => [...v]),
lockedUntil: { ...s.lockedUntil },
pendingWindow: s.pendingWindow ? { ...s.pendingWindow, claims: s.pendingWindow.claims.map((c) => ({ ...c })) } : null,
penalty: { ...s.penalty },
outThisRound: [...s.outThisRound],
finishedOrder: [...s.finishedOrder],
away: rec(s.away, (v) => ({ ...v })),
rtt: { ...s.rtt },
stats: rec(s.stats, (v) => ({ ...v })),
suspect: [...s.suspect],
feedback: { ...s.feedback },
log: [...s.log],
};
}
interface Ctx {
now: number;
events: GameEvent[];
}
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'n'> : never) : never;
function log(s: S, e: LogBody): void {
s.logNo += 1;
s.log.push({ n: s.logNo, ...e } as LogEntry);
if (s.log.length > LOG_KEEP) s.log.splice(0, s.log.length - LOG_KEEP);
}
function judge(s: S, p: PlayerId, kind: Feedback['kind'], art: number | null, ctx: Ctx): void {
s.seq += 1;
s.feedback[p] = { seq: s.seq, kind, art, at: ctx.now };
}
/** Public cards changed: restart the stall timer and cancel a showing hint. */
function cardsChanged(s: S, now: number): void {
s.changedAt = now;
s.hintUntil = 0;
}
/** Turns the next hidden card face up in the centre (tower / gift); false when the draw pile is empty. */
function revealNext(s: S, ctx: Ctx): boolean {
const id = s.drawPile.pop();
if (id === undefined) return false;
s.center.push(id);
ctx.events.push({ type: 'reveal', key: cardKey(id) });
return true;
}
function dealRound(s: S, ctx: Ctx): boolean {
const players = s.order.filter((p) => inGame(s, p));
if (s.drawPile.length < players.length) return false;
for (const p of players) {
s.status[p] = 'playing';
s.piles[p] = [s.drawPile.pop()!];
}
s.outThisRound = [];
s.phase = 'countdown';
s.phaseEndsAt = ctx.now + COUNTDOWN_MS;
s.changedAt = s.phaseEndsAt;
s.hintUntil = 0;
ctx.events.push({ type: 'roundStart', round: s.round });
return true;
}
// ---------------------------------------------------------------- claims
function succeed(s: S, c: PendingClaim, ctx: Ctx): void {
const p = c.player;
const st = s.stats[p]!;
st.correct += 1;
if (ctx.now - s.changedAt < FAST_MS) {
st.fastHits += 1;
if (st.fastHits >= FAST_STREAK && !s.suspect.includes(p)) s.suspect.push(p);
} else st.fastHits = 0;
s.away[p] = { strikes: 0, last: 0 };
const art = s.symbolArt[c.symbol]!;
judge(s, p, 'ok', art, ctx);
s.lastHit = { seq: s.seq, player: p, target: c.target, art, at: ctx.now };
log(s, { t: 'hit', p, target: c.target, art });
ctx.events.push({ type: 'hit', player: p, target: c.target, art });
cardsChanged(s, ctx.now);
switch (s.options.miniGame) {
case 'tower': {
s.piles[p]!.push(s.center.pop()!);
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
return;
}
case 'poisonedGift': {
s.piles[c.target as PlayerId]!.push(s.center.pop()!);
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
return;
}
case 'well': {
s.center.push(s.piles[p]!.pop()!);
if (s.piles[p]!.length === 0) {
s.status[p] = 'finished';
s.finishedOrder.push(p);
log(s, { t: 'done', p, place: s.finishedOrder.length });
ctx.events.push({ type: 'done', player: p, place: s.finishedOrder.length });
}
checkWellEnd(s, ctx);
return;
}
case 'hotPotato': {
const t = c.target as PlayerId;
s.piles[t]!.push(...s.piles[p]!);
s.piles[p] = [];
s.status[p] = 'escaped';
s.outThisRound.push(p);
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
if (holders.length <= 1) endRound(s, holders[0] ?? null, ctx);
return;
}
}
}
function checkWellEnd(s: S, ctx: Ctx): void {
if (s.phase === 'ended') return;
const left = s.order.filter((q) => s.status[q] === 'playing');
if ((s.options.wellEndAtFirst && s.finishedOrder.length > 0) || left.length <= 1) finish(s, ctx);
}
/** Hot potato: the last holder keeps the bundle as penalty (null = round voided because someone left). */
function endRound(s: S, loser: PlayerId | null, ctx: Ctx): void {
let cards = 0;
if (loser) {
cards = s.piles[loser]!.length;
s.penalty[loser] = (s.penalty[loser] ?? 0) + cards;
s.taken[loser]!.push(...s.piles[loser]!);
s.piles[loser] = [];
} else {
for (const p of s.order) {
s.unused.push(...s.piles[p]!);
s.piles[p] = [];
}
}
s.pendingWindow = null;
s.lastRound = { round: s.round, loser, cards };
log(s, { t: 'round', round: s.round, loser, cards });
ctx.events.push({ type: 'roundEnd', round: s.round, loser, cards });
for (const p of s.order) if (s.status[p] === 'escaped') s.status[p] = 'playing';
s.outThisRound = [];
const players = s.order.filter((p) => inGame(s, p));
if (s.round >= s.options.hotPotatoRounds || players.length < 2) {
finish(s, ctx);
return;
}
s.phase = 'roundEnd';
s.phaseEndsAt = ctx.now + ROUND_BREAK_MS;
s.hintUntil = 0;
}
/** Re-checks a claim against the current table (it may have changed since it arrived). */
function stillValid(s: S, c: PendingClaim): boolean {
if (!canClaim(s, c.player)) return false;
const target = c.target === 'center' ? undefined : c.target;
if (target !== undefined && !validTarget(s, c.player, target)) return false;
if (currentBase(s, target) !== c.base) return false;
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, c.player) !== c.mine) return false;
const cc = comparedCards(s, c.player, target);
return !!cc && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
}
/** Closes the latency window: earliest adjusted time first (ties: arrival), stale claims become "late". */
function resolveWindow(s: S, ctx: Ctx): void {
const w = s.pendingWindow;
if (!w) return;
s.pendingWindow = null;
const sorted = [...w.claims].sort((a, b) => a.adjTs - b.adjTs || a.arrived - b.arrived);
for (const c of sorted) {
if (s.phase === 'playing' && stillValid(s, c)) succeed(s, c, ctx);
else if (s.order.includes(c.player)) {
judge(s, c.player, 'late', null, ctx);
ctx.events.push({ type: 'late', player: c.player });
}
}
}
function claim(s: S, p: PlayerId, a: ClaimAction, ctx: Ctx): void {
if (s.pendingWindow && ctx.now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
if (a.rtt !== undefined) {
const sample = Math.min(2000, Math.max(0, Math.round(a.rtt)));
const old = s.rtt[p];
s.rtt[p] = old === null || old === undefined ? sample : Math.round(old * (1 - RTT_ALPHA) + sample * RTT_ALPHA);
}
const target = a.target;
const c: PendingClaim = {
player: p,
target: target ?? 'center',
base: a.base,
mine: a.mine ?? null,
symbol: s.symbolArt.indexOf(a.symbol),
adjTs: ctx.now,
arrived: ctx.now,
};
// The table moved while the window was being resolved → this claim is late (no penalty).
if (s.phase !== 'playing' || !canClaim(s, p) || (target !== undefined && !validTarget(s, p, target)) || currentBase(s, target) !== a.base) {
judge(s, p, 'late', null, ctx);
ctx.events.push({ type: 'late', player: p });
return;
}
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, p) !== c.mine) {
judge(s, p, 'late', null, ctx);
ctx.events.push({ type: 'late', player: p });
return;
}
s.away[p] = { strikes: 0, last: 0 };
// §5.3 잠금: judged on the server clock.
if (ctx.now < (s.lockedUntil[p] ?? 0)) {
judge(s, p, 'locked', null, ctx);
ctx.events.push({ type: 'locked', player: p });
return;
}
const cc = comparedCards(s, p, target)!;
const right = c.symbol >= 0 && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
if (!right) {
s.stats[p]!.wrong += 1;
s.lockedUntil[p] = ctx.now + s.options.wrongLockoutMs;
judge(s, p, 'wrong', a.symbol, ctx);
ctx.events.push({ type: 'wrong', player: p });
// §7 wrongGivesCard (tower): my top card goes under the draw pile; my last card always stays.
if (s.options.miniGame === 'tower' && s.options.wrongGivesCard && s.piles[p]!.length >= 2) {
s.drawPile.unshift(s.piles[p]!.pop()!);
ctx.events.push({ type: 'gaveCard', player: p });
}
return;
}
if (s.options.latencyComp === 'light') {
const rtt = s.rtt[p] ?? 0;
c.adjTs = ctx.now - Math.min(rtt / 2, MAX_LATENCY_CREDIT_MS);
if (!s.pendingWindow) s.pendingWindow = { openedAt: ctx.now, claims: [] };
s.pendingWindow.claims.push(c);
ctx.events.push({ type: 'windowed', player: p });
return;
}
succeed(s, c, ctx);
}
// ---------------------------------------------------------------- timer, away, leaving
function processDue(s: S, ctx: Ctx): void {
if (s.phase === 'countdown') {
s.phase = 'playing';
cardsChanged(s, ctx.now);
ctx.events.push({ type: 'start', round: s.round });
return;
}
if (s.phase === 'roundEnd') {
s.round += 1;
if (!dealRound(s, ctx)) finish(s, ctx);
return;
}
if (s.pendingWindow) {
resolveWindow(s, ctx);
return;
}
if (s.hintUntil > 0) {
// Hint over: keep playing the same cards (no points for anyone), stall timer restarts.
cardsChanged(s, ctx.now);
return;
}
s.hintUntil = ctx.now + HINT_MS;
log(s, { t: 'hint' });
ctx.events.push({ type: 'hint' });
}
/** Removes `p` (left the room, or forfeited after being away in well / hot potato; §8.5). */
function drop(s: S, p: PlayerId, why: 'forfeit' | 'left', ctx: Ctx): void {
const was = s.status[p];
s.status[p] = why;
if (s.pendingWindow) s.pendingWindow.claims = s.pendingWindow.claims.filter((c) => c.player !== p);
const mode = s.options.miniGame;
const holding = s.piles[p]!.length > 0;
if (mode === 'well' || mode === 'hotPotato') {
// Cards go back to the box ("가운데로 반환"); tower / gift piles stay as they are.
s.unused.push(...s.piles[p]!);
s.piles[p] = [];
}
s.outThisRound = s.outThisRound.filter((q) => q !== p);
log(s, { t: 'out', p, why });
ctx.events.push({ type: 'out', player: p, why });
if (s.order.filter((q) => inGame(s, q)).length < 2) {
finish(s, ctx);
return;
}
if (mode === 'well') checkWellEnd(s, ctx);
if (mode === 'hotPotato' && s.phase === 'playing' && was === 'playing' && holding) {
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
// Nobody left to race against: the round is void (no penalty).
if (holders.length <= 1) endRound(s, null, ctx);
}
}
/** An early tick: the room timed `p` out while disconnected past the grace period. */
function awayStrike(s: S, p: PlayerId, ctx: Ctx): void {
const st = s.status[p];
if (st !== 'playing' && st !== 'escaped') return;
const prev = s.away[p] ?? { strikes: 0, last: 0 };
// Strikes rotate over every away player, so the gap between one player's strikes is ≤ players × step.
const fresh = prev.last > 0 && ctx.now - prev.last <= AWAY_STEP_MS * (s.order.length + 1);
const strikes = fresh ? prev.strikes + 1 : 1;
s.away[p] = { strikes, last: ctx.now };
const mode = s.options.miniGame;
if (strikes >= AWAY_STRIKES && (mode === 'well' || mode === 'hotPotato')) {
drop(s, p, 'forfeit', ctx);
return;
}
// Tower / gift keep going without the away player — unless everybody is away.
if (mode === 'tower' || mode === 'poisonedGift') {
const live = s.order.filter((q) => s.status[q] === 'playing');
const allAway = live.every((q) => {
const a = s.away[q];
return !!a && a.strikes >= AWAY_STRIKES && ctx.now - a.last <= AWAY_STEP_MS * (s.order.length + 1);
});
if (allAway) finish(s, ctx, '모두 자리를 비워서 게임을 끝냈어요.');
}
}
function tick(s: S, actor: PlayerId, ctx: Ctx): void {
const due = dueAt(s);
if (due !== null && ctx.now >= due) processDue(s, ctx);
else if (ctx.now >= s.awayCheckAt) {
awayStrike(s, actor, ctx);
s.awayRot += 1;
}
s.awayCheckAt = Math.max(s.awayCheckAt, ctx.now + AWAY_STEP_MS);
}
// ---------------------------------------------------------------- ending
function rankBy(ids: PlayerId[], key: (p: PlayerId) => number, start: number): { id: PlayerId; rank: number }[] {
const sorted = [...ids].sort((a, b) => key(a) - key(b));
return sorted.map((id, i) => {
let j = i;
while (j > 0 && key(sorted[j - 1]!) === key(id)) j--;
return { id, rank: start + j };
});
}
function finish(s: S, ctx: Ctx, override?: string): void {
if (s.phase === 'ended') return;
s.phase = 'ended';
s.pendingWindow = null;
s.hintUntil = 0;
const mode = s.options.miniGame;
const live = s.order.filter((p) => inGame(s, p));
const gone = s.order.filter((p) => !inGame(s, p));
const cards = (p: PlayerId) => s.piles[p]!.length;
let ranks: { id: PlayerId; rank: number }[];
switch (mode) {
case 'tower':
ranks = rankBy(live, (p) => -cards(p), 1);
break;
case 'poisonedGift':
ranks = rankBy(live, cards, 1);
break;
case 'hotPotato':
ranks = rankBy(live, (p) => s.penalty[p] ?? 0, 1);
break;
case 'well': {
const done = s.finishedOrder.filter((p) => live.includes(p)).map((id, i) => ({ id, rank: i + 1 }));
ranks = [...done, ...rankBy(live.filter((p) => !s.finishedOrder.includes(p)), cards, done.length + 1)];
break;
}
}
// Forfeited / left players share the bottom place (§8.5 "꼴찌 그룹").
for (const id of gone) ranks.push({ id, rank: live.length + 1 });
const ranking: RankRow[] = ranks.map(({ id, rank }) => ({
rank,
id,
...(mode === 'hotPotato' ? { penalty: s.penalty[id] ?? 0 } : { cards: cards(id) }),
// Forfeited / left players score nothing even when the bottom place is 3rd.
points: inGame(s, id) ? (POINTS[rank - 1] ?? 0) : 0,
status: s.status[id]!,
}));
const winners = ranking.filter((r) => r.rank === 1);
const w = winners[0]!;
let summary: string;
if (override) summary = override;
else if (live.length === 1 && gone.length > 0) summary = `다른 사람이 모두 나가서 ${MINI_GAME_KO[mode]}을(를) 끝냈어요.`;
else if (mode === 'tower') summary = `탑 쌓기 끝! 카드 ${w.cards}장을 모은 사람이 1등이에요.`;
else if (mode === 'poisonedGift') summary = `독이 든 선물 끝! 카드를 ${w.cards}장만 받은 사람이 1등이에요.`;
else if (mode === 'hotPotato') summary = `뜨거운 감자 ${s.lastRound?.round ?? s.round}라운드 끝! 벌점 ${w.penalty}장으로 가장 적은 사람이 1등이에요.`;
else summary = s.finishedOrder.length > 0 ? '우물 끝! 카드를 가장 먼저 다 비운 사람이 1등이에요.' : '우물 끝! 카드가 가장 적게 남은 사람이 1등이에요.';
if (winners.length > 1 && !override) summary += ` (공동 1등 ${winners.length}명)`;
const stats: DobbleFinal['stats'] = {};
for (const p of s.order) stats[p] = { correct: s.stats[p]!.correct, wrong: s.stats[p]!.wrong };
s.final = { miniGame: mode, ranking, stats, summary };
ctx.events.push({ type: 'gameEnd' });
}
// ---------------------------------------------------------------- view
function cardView(s: S, id: number): CardView {
return {
id,
key: cardKey(id),
items: s.layouts[id]!.items.map((i) => ({ art: s.symbolArt[i.symbol]!, x: i.x, y: i.y, r: i.r, scale: i.scale, rot: i.rot })),
};
}
function setup(players: PlayerId[], options: DobbleOptions, rng: Rng, now: number): S {
if (players.length < 2 || players.length > 8) throw new Error(`dobble: ${players.length} players not allowed`);
const err = playerCountError(options, players.length);
if (err) throw new Error(err);
// §8.4: seats, symbol → icon, card looks, deck order and the 55-card removals all come from the seeded RNG.
const order = rng.shuffle(players);
const symbolArt = rng.shuffle(Array.from({ length: FULL_DECK }, (_, i) => i));
const cards = buildDeck();
const layouts = cards.map((c) => makeLayout(c, options.symbolStyle, rng));
const deck = rng.shuffle(cards.map((_, i) => i));
const unused = options.deckSize === 55 ? deck.splice(0, FULL_DECK - 55) : [];
const per0 = <T>(v: () => T) => Object.fromEntries(order.map((p) => [p, v()])) as Record<PlayerId, T>;
const s: S = {
options,
host: players[0]!,
cards,
layouts,
symbolArt,
order,
status: per0<SeatStatus>(() => 'playing'),
piles: per0<number[]>(() => []),
center: [],
drawPile: [],
unused,
taken: per0<number[]>(() => []),
lockedUntil: per0(() => 0),
pendingWindow: null,
round: 1,
penalty: per0(() => 0),
outThisRound: [],
finishedOrder: [],
phase: 'countdown',
phaseEndsAt: now + COUNTDOWN_MS,
changedAt: now + COUNTDOWN_MS,
hintUntil: 0,
awayCheckAt: now + COUNTDOWN_MS + AWAY_STEP_MS,
awayRot: 0,
away: per0(() => ({ strikes: 0, last: 0 })),
rtt: per0<number | null>(() => null),
stats: per0(() => ({ correct: 0, wrong: 0, fastHits: 0 })),
suspect: [],
feedback: {},
lastHit: null,
lastRound: null,
seq: 0,
logNo: 0,
log: [],
final: null,
};
const ctx: Ctx = { now, events: [] };
switch (options.miniGame) {
case 'tower':
case 'poisonedGift':
for (const p of order) s.piles[p] = [deck.pop()!];
s.center = [deck.pop()!];
s.drawPile = deck;
break;
case 'well': {
s.center = [deck.pop()!];
const per = Math.floor(deck.length / order.length);
order.forEach((p, i) => (s.piles[p] = deck.slice(i * per, (i + 1) * per)));
s.unused.push(...deck.slice(order.length * per));
break;
}
case 'hotPotato':
s.drawPile = deck;
dealRound(s, ctx);
break;
}
return s;
}
function playerCountError(o: DobbleOptions, count: number): string | null {
if (o.miniGame === 'hotPotato' && count * o.hotPotatoRounds > o.deckSize) {
return `뜨거운 감자 ${o.hotPotatoRounds}라운드를 ${count}명이 하기에는 카드가 모자라요. 라운드를 줄이거나 57장 덱을 골라 주세요.`;
}
return null;
}
// ---------------------------------------------------------------- definition
type Def = GameDefinition<S, A, DobbleView, DobbleOptions>;
export const dobble: Def = {
id: 'dobble',
nameKo: '도블',
minPlayers: 2,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: dobbleOptionsSchema.parse({}),
optionsSchema: dobbleOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: dobbleActionSchema as unknown as Def['actionSchema'],
playerCountError,
setup({ players, options, rng, now }) {
return setup(players, options, rng, now);
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'tick':
return ok;
case 'resolve':
return s.pendingWindow ? ok : fail('판정할 찾기가 없어요.');
case 'leave':
return s.status[actor] === 'playing' || s.status[actor] === 'escaped' ? ok : fail('이미 게임에서 빠졌어요.');
case 'claim': {
if (s.phase === 'countdown') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (s.phase === 'roundEnd') return fail('다음 라운드를 준비하고 있어요.');
const st = s.status[actor];
if (st === 'forfeit' || st === 'left') return fail('게임에서 빠져서 찾을 수 없어요.');
if (st === 'finished') return fail('이미 카드를 다 비웠어요.');
if (st === 'escaped') return fail('이번 라운드는 탈출했어요. 다음 라운드를 기다려요.');
if (!canClaim(s, actor)) return fail('지금은 찾을 카드가 없어요.');
if (usesTarget(s.options)) {
if (a.target === undefined) return fail('누구 카드에서 찾을지 먼저 골라 주세요.');
if (a.target === actor) return fail(s.options.miniGame === 'poisonedGift' ? '내 카드에는 선물을 줄 수 없어요.' : '내 카드끼리는 비교할 수 없어요.');
if (!s.order.includes(a.target)) return fail('그런 사람은 없어요.');
const ts = s.status[a.target];
if (ts === 'escaped') return fail('이미 이번 라운드를 탈출한 사람이에요.');
if (ts !== 'playing' || !validTarget(s, actor, a.target)) return fail('게임에서 빠진 사람이에요.');
} else if (a.target !== undefined) return fail('이 미니게임은 대상을 고르지 않아요.');
// §5.1: the reference card moved on → "늦었어요" (no penalty).
if (currentBase(s, a.target) !== a.base) return fail(s.options.miniGame === 'hotPotato' ? LATE_CHANGED : LATE_TAKEN);
if (a.mine !== undefined && s.options.miniGame !== 'poisonedGift' && topOf(s, actor) !== a.mine) return fail(LATE_CHANGED);
if (s.pendingWindow?.claims.some((c) => c.player === actor)) return fail('이미 눌렀어요. 판정을 기다려 주세요.');
return ok;
}
}
},
apply(prev, actor, a, { now }) {
const s = clone(prev);
const ctx: Ctx = { now, events: [] };
switch (a.type) {
case 'tick':
tick(s, actor, ctx);
break;
case 'resolve':
if (s.pendingWindow && now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
break;
case 'claim':
claim(s, actor, a, ctx);
break;
case 'leave':
drop(s, actor, 'left', ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
const reveal = s.phase !== 'countdown';
const pairs = me && s.phase === 'playing' ? pairsFor(s, me) : [];
let hint: number[] = [];
if (s.phase === 'playing' && s.hintUntil > 0) {
const c0 = centerTop(s);
const arts = me
? pairsFor(s, me).map((pair) => answerArt(s, me, pair))
: s.options.miniGame === 'poisonedGift' && c0 !== undefined
? s.order.filter((q) => s.status[q] === 'playing' && topOf(s, q) !== undefined).map((q) => s.symbolArt[common(s, topOf(s, q)!, c0)]!)
: [];
hint = [...new Set(arts.filter((x): x is number => x !== null))];
}
const c = centerTop(s);
return {
options: s.options,
me,
isHost: viewer === s.host,
phase: s.phase,
phaseEndsAt: s.phaseEndsAt,
round: s.round,
rounds: s.options.miniGame === 'hotPotato' ? s.options.hotPotatoRounds : 1,
seats: s.order.map((p) => {
const t = topOf(s, p);
const place = s.finishedOrder.indexOf(p);
return {
id: p,
status: s.status[p]!,
cards: s.piles[p]!.length,
top: reveal && t !== undefined ? cardView(s, t) : null,
penalty: s.penalty[p] ?? 0,
lockedUntil: s.lockedUntil[p] ?? 0,
correct: s.stats[p]!.correct,
wrong: s.stats[p]!.wrong,
rtt: s.rtt[p] ?? null,
place: place >= 0 ? place + 1 : null,
};
}),
center: reveal && c !== undefined ? cardView(s, c) : null,
centerCount: s.center.length,
drawCount: s.drawPile.length,
unusedCount: s.unused.length,
pairs,
windowOpen: s.pendingWindow !== null,
hint,
hintUntil: s.hintUntil,
lastHit: s.lastHit,
feedback: me ? (s.feedback[me] ?? null) : null,
lastRound: s.lastRound,
suspects: viewer !== null && viewer === s.host ? [...s.suspect] : [],
log: s.log.slice(-12),
result: s.final,
};
},
/** No turns: everyone still in the game may claim at any time (§8.5). */
activePlayers(s) {
if (s.phase === 'ended') return [];
return s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
},
deadline(s) {
return dueAt(s);
},
/**
* Everyone still in drives the timer (countdown, latency window, stall hint), rotated after every away
* strike so that several disconnected players are all noticed in turn.
*/
timeoutPlayers(s) {
if (s.phase === 'ended') return [];
const ps = s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
if (ps.length === 0) return [];
const k = s.awayRot % ps.length;
return [...ps.slice(k), ...ps.slice(0, k)];
},
/** Disconnected players are timed out at most once per AWAY_STEP_MS (no busy loop), never before the window closes. */
timeoutNotBefore(s) {
const due = dueAt(s);
if (due === null) return null;
if (s.pendingWindow) return due;
return Math.min(due, s.awayCheckAt);
},
onTimeout(s) {
return s.pendingWindow ? { type: 'resolve' } : { type: 'tick' };
},
onLeave(s, player) {
if (s.phase === 'ended' || !s.order.includes(player)) return null;
return s.status[player] === 'playing' || s.status[player] === 'escaped' ? { type: 'leave' } : null;
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) scores[r.id] = r.points;
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores,
summary: f.summary,
reason: 'normal',
};
},
/** Test helper: the right claim on every pair, a wrong one now and then, and the timer tick. */
legalActions(s, p) {
if (s.phase === 'ended' || !s.order.includes(p)) return [];
const out: A[] = [{ type: 'tick' }];
if (s.pendingWindow) out.push({ type: 'resolve' });
const pairs = pairsFor(s, p);
for (const pair of pairs) {
const art = answerArt(s, p, pair)!;
out.push({ type: 'claim', base: pair.base, symbol: art, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) });
}
const st = s.stats[p]!;
const first = pairs[0];
if (first && (st.correct + st.wrong) % 4 === 3) {
const cc = comparedCards(s, p, first.target ?? undefined)!;
const right = common(s, cc[0], cc[1]);
const wrong = s.cards[cc[0]]!.find((x) => x !== right)!;
out.push({ type: 'claim', base: first.base, symbol: s.symbolArt[wrong]!, ...(first.target ? { target: first.target } : {}) });
}
return out.filter((x) => dobble.validate(s, p, x).ok);
},
/** Cards nobody may see: draw pile, box, well face-down piles, and everything dealt during a countdown. */
secretsOf(s) {
const hidden = [...s.drawPile, ...s.unused];
for (const p of s.order) {
const pile = s.piles[p]!;
if (s.options.miniGame === 'well') hidden.push(...pile.slice(0, -1));
if (s.phase === 'countdown' && pile.length) hidden.push(pile[pile.length - 1]!);
}
if (s.phase === 'countdown' && s.center.length) hidden.push(s.center[s.center.length - 1]!);
return hidden.map(cardKey);
},
};

View File

@@ -0,0 +1,272 @@
/** 도블(같은 그림 찾기) options, actions, state and view types (docs/games/dobble.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
/** §3.2: 57 original icons (drawn in apps/web/src/games/dobble/art.ts), index = icon id. */
export const SYMBOL_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
] as const;
export type MiniGame = 'tower' | 'well' | 'hotPotato' | 'poisonedGift';
export const MINI_GAME_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const lit = <T extends string | 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 dobbleOptionsSchema = z.object({
miniGame: z
.enum(['tower', 'well', 'hotPotato', 'poisonedGift'])
.default('tower')
.meta({ title: '미니게임', labels: { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' } }),
deckSize: lit<57 | 55>(57, 55)
.default(57)
.meta({ title: '카드 장수', labels: { 57: '57장(수학적으로 꽉 찬 덱)', 55: '55장(원작과 같은 장수)' } }),
hotPotatoRounds: lit<3 | 5 | 7>(3, 5, 7)
.default(5)
.meta({ title: '뜨거운 감자 라운드 수', labels: { 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
wrongLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000)
.default(1000)
.meta({ title: '틀린 그림을 눌렀을 때 잠금 시간', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }),
latencyComp: z
.enum(['off', 'light'])
.default('off')
.meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }),
symbolStyle: z
.enum(['easy', 'classic'])
.default('classic')
.meta({ title: '그림 크기·회전', labels: { easy: '쉽게(크기 같고 회전 없음)', classic: '원작처럼(크기·회전 무작위)' } }),
showRtt: z.boolean().default(true).meta({ title: '각자 핑(연결 지연) 표시' }),
wellEndAtFirst: z.boolean().default(false).meta({ title: '우물: 처음 다 비운 사람이 나오면 바로 끝내기' }),
wrongGivesCard: z.boolean().default(false).meta({ title: '탑 쌓기: 틀리면 내 탑 맨 위 카드를 가운데 더미 맨 아래로' }),
});
export type DobbleOptions = z.infer<typeof dobbleOptionsSchema>;
export const dobbleActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('claim'),
/** Reference card id the client was looking at: centre top (tower/well/gift) or the target's top (hot potato). */
base: z.number().int().min(0).max(56),
/** Gift / hot potato: whose card the symbol is matched on. */
target: z.string().min(1).max(64).optional(),
/** My own top card id the client saw (hot potato: someone may have just dumped their bundle on me). */
mine: z.number().int().min(0).max(56).optional(),
/** Icon id that was tapped. */
symbol: z.number().int().min(0).max(56),
/** Client-measured round trip (ms); feeds the per-player RTT average (§5.3), clamped. */
rtt: z.number().min(0).max(10_000).optional(),
}),
]);
/** Client claims plus system-only actions (server timer, latency window, leaving) that are not in the schema. */
export type DobbleAction = z.infer<typeof dobbleActionSchema> | { type: 'tick' } | { type: 'resolve' } | { type: 'leave' };
export type ClaimAction = Extract<DobbleAction, { type: 'claim' }>;
export interface LayoutItem {
/** Symbol number 0..56 (projective-plane point); the screen shows `symbolArt[symbol]`. */
symbol: number;
/** Slot centre in the unit circle (−1..1) and slot radius. */
x: number;
y: number;
r: number;
/** §3.3 classic: 0.6–1.2, easy: 1. */
scale: number;
/** Degrees, classic: 0–359, easy: 0. */
rot: number;
}
export interface CardLayout {
template: number;
items: LayoutItem[];
}
export type SeatStatus = 'playing' | 'finished' | 'escaped' | 'forfeit' | 'left';
export type Phase = 'countdown' | 'playing' | 'roundEnd' | 'ended';
export interface PendingClaim {
player: PlayerId;
target: PlayerId | 'center';
base: number;
mine: number | null;
symbol: number;
adjTs: number;
arrived: number;
}
export interface PendingWindow {
openedAt: number;
claims: PendingClaim[];
}
export interface Stats {
correct: number;
wrong: number;
/** Correct claims made < 200ms after the cards changed, in a row (§5.3 봇 의심). */
fastHits: number;
}
/** Per-player judgement of their latest claim (shown only to that player). */
export interface Feedback {
seq: number;
kind: 'ok' | 'wrong' | 'late' | 'locked';
/** Icon id. */
art: number | null;
at: number;
}
export interface LastHit {
seq: number;
player: PlayerId;
target: PlayerId | 'center';
art: number;
at: number;
}
export type LogEntry =
| { n: number; t: 'hit'; p: PlayerId; target: PlayerId | 'center'; art: number }
| { n: number; t: 'done'; p: PlayerId; place: number }
| { n: number; t: 'round'; round: number; loser: PlayerId | null; cards: number }
| { n: number; t: 'out'; p: PlayerId; why: 'forfeit' | 'left' }
| { n: number; t: 'hint' };
export interface RankRow {
rank: number;
id: PlayerId;
cards?: number;
penalty?: number;
points: number;
status: SeatStatus;
}
export interface DobbleFinal {
miniGame: MiniGame;
ranking: RankRow[];
stats: Record<PlayerId, { correct: number; wrong: number }>;
summary: string;
}
export interface DobbleState {
options: DobbleOptions;
/** Room host at game start (players[0]); only they see the bot-suspicion marks (§5.3). */
host: PlayerId;
/** Card id → symbol numbers (8 each). Immutable after setup. */
cards: number[][];
/** Card id → layout. Immutable after setup. */
layouts: CardLayout[];
/** Symbol number → icon id (shuffled per game). Immutable after setup. */
symbolArt: number[];
order: PlayerId[];
status: Record<PlayerId, SeatStatus>;
/** Player piles, top = last. Well: the whole face-down pile, only the top is face up. */
piles: Record<PlayerId, number[]>;
/** Public centre pile, top = last. */
center: number[];
/** Hidden draw pile, next card = last. */
drawPile: number[];
/** Well leftovers, 55-deck removals, forfeited / left players' returned cards. */
unused: number[];
/** Hot potato: cards each round's loser keeps as penalty. */
taken: Record<PlayerId, number[]>;
lockedUntil: Record<PlayerId, number>;
pendingWindow: PendingWindow | null;
/** Hot potato round (1-based); other modes stay 1. */
round: number;
penalty: Record<PlayerId, number>;
outThisRound: PlayerId[];
finishedOrder: PlayerId[];
phase: Phase;
phaseEndsAt: number;
/** Last time the public cards changed (stall timer, bot suspicion). */
changedAt: number;
/** §8.5 stall: the common symbol is shown until this time. */
hintUntil: number;
/** Throttles away detection (one early tick per away step). */
awayCheckAt: number;
/** Rotates `timeoutPlayers` after each away strike so several away players are all noticed. */
awayRot: number;
/** Consecutive early ticks per player (= the room timed them out while disconnected) and the last one. */
away: Record<PlayerId, { strikes: number; last: number }>;
rtt: Record<PlayerId, number | null>;
stats: Record<PlayerId, Stats>;
suspect: PlayerId[];
feedback: Record<PlayerId, Feedback>;
lastHit: LastHit | null;
/** Hot potato: the latest finished round. */
lastRound: { round: number; loser: PlayerId | null; cards: number } | null;
seq: number;
logNo: number;
log: LogEntry[];
final: DobbleFinal | null;
}
// ---------------------------------------------------------------- view
export interface ItemView {
/** Icon id. */
art: number;
x: number;
y: number;
r: number;
scale: number;
rot: number;
}
export interface CardView {
id: number;
/** Opaque public key (`secretsOf` uses the same form for hidden cards). */
key: string;
items: ItemView[];
}
export interface SeatView {
id: PlayerId;
status: SeatStatus;
/** Cards in the pile (tower: tower height, well: cards left, gift: poison, hot potato: bundle). */
cards: number;
top: CardView | null;
penalty: number;
lockedUntil: number;
correct: number;
wrong: number;
rtt: number | null;
/** Well finishing place (1-based). */
place: number | null;
}
/** One pair of cards the viewer may claim on (rules precomputed for the screen). */
export interface ClaimPair {
target: PlayerId | null;
base: number;
mine: number | null;
}
export interface DobbleView {
options: DobbleOptions;
me: PlayerId | null;
isHost: boolean;
phase: Phase;
phaseEndsAt: number;
round: number;
rounds: number;
seats: SeatView[];
center: CardView | null;
centerCount: number;
drawCount: number;
unusedCount: number;
/** Pairs the viewer can claim on right now (empty while they cannot claim). */
pairs: ClaimPair[];
windowOpen: boolean;
/** §8.5 stall hint: icon ids common to the viewer's pairs, shown until `hintUntil`. */
hint: number[];
hintUntil: number;
lastHit: LastHit | null;
feedback: Feedback | null;
lastRound: DobbleState['lastRound'];
/** Host only (§5.3). */
suspects: PlayerId[];
log: LogEntry[];
result: DobbleFinal | null;
}

View File

@@ -16,6 +16,15 @@ import { gostop } from './gostop';
import { bang } from './bang';
import { halliGalli } from './halli-galli';
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';
import { dobble } from './dobble';
import { catchmind } from './catchmind';
import { mafia } from './mafia';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -35,4 +44,13 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[bang.id]: bang,
[halliGalli.id]: halliGalli,
[marble.id]: marble,
[dalmuti.id]: dalmuti,
[splendor.id]: splendor,
[quoridor.id]: quoridor,
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine,
[dobble.id]: dobble,
[catchmind.id]: catchmind,
[mafia.id]: mafia,
};

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,784 @@
/**
* 마피아 (docs/games/mafia.md). Pure and deterministic; state is JSON.
*
* The server runs the phases: every phase has `endsAt`; the room timer applies the system-only
* `phaseTimeout` there (one "driver" player, `timeoutNotBefore` keeps away players from firing it
* early). Chat lives in the state and the view filters it by channel permission (§5.6, §8.3).
*/
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
CHANNELS,
EXTRA_ROLES,
mafiaActionSchema,
mafiaOptionsSchema,
type Channel,
type ChatView,
type DiedBy,
type ExtraRole,
type MafiaAction,
type MafiaFinal,
type MafiaOptions,
type MafiaState,
type MafiaView,
type MyView,
type NightActionType,
type NightView,
type Phase,
type PlayerState,
type PrivateResult,
type PublicEvent,
type Role,
type Team,
type WinCause,
} from './types';
export * from './types';
export const ROLE_REVEAL_MS = 8_000;
export const GREETING_MS = 60_000;
export const ADJUST_MS = 15_000;
/** `-15초` never leaves less than this (§5.2). */
export const ADJUST_FLOOR_MS = 5_000;
export const CHAT_MAX = 200;
export const CHAT_GAP_MS = 700;
/** Voting days in a row without any action → 자리 비움 탈락 (§8.5). */
export const AWAY_DAYS = 2;
const CHAT_KEEP = 400;
const CHAT_VIEW = 150;
const EVENTS_VIEW = 120;
export const ROLE_KO: Record<Role, string> = {
mafia: '마피아',
police: '경찰',
doctor: '의사',
citizen: '시민',
soldier: '군인',
politician: '정치인',
medium: '영매',
lover: '연인',
};
export const teamOf = (r: Role): Team => (r === 'mafia' ? 'mafia' : 'town');
/** §4 table: mafia count for `auto`. */
const AUTO_MAFIA: Record<number, number> = { 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 3, 10: 3, 11: 3, 12: 3 };
const EXTRA_SLOTS: Record<ExtraRole, number> = { soldier: 1, politician: 1, medium: 1, lovers: 2 };
const EXTRA_OBJ: Record<ExtraRole, string> = { soldier: '군인을', politician: '정치인을', medium: '영매를', lovers: '연인을' };
export function mafiaCountFor(o: MafiaOptions, n: number): number {
return o.mafiaCount === 'auto' ? (AUTO_MAFIA[n] ?? Math.max(1, Math.floor(n / 4))) : o.mafiaCount;
}
/** Why this composition cannot start (lobby refusal, §4.2), or null. */
export function compositionError(o: MafiaOptions, n: number): string | null {
if (n < 4 || n > 12) return '마피아는 4~12명이 할 수 있어요.';
const m = mafiaCountFor(o, n);
if (m >= n / 2) return `${n}명일 때 마피아는 ${Math.ceil(n / 2) - 1}명까지 정할 수 있어요.`;
let citizens = n - m - 2;
for (const r of EXTRA_ROLES) {
if (!o.extraRoles[r]) continue;
if (citizens < EXTRA_SLOTS[r]) return `인원이 부족해서 ${EXTRA_OBJ[r]} 넣을 수 없어요(시민 자리 ${EXTRA_SLOTS[r]}개 필요).`;
citizens -= EXTRA_SLOTS[r];
}
return null;
}
/** Roles for `n` players before shuffling (§4). */
export function rolesFor(o: MafiaOptions, n: number): Role[] {
const err = compositionError(o, n);
if (err) throw new Error(`mafia: ${err}`);
const m = mafiaCountFor(o, n);
const out: Role[] = [...Array<Role>(m).fill('mafia'), 'police', 'doctor'];
if (o.extraRoles.soldier) out.push('soldier');
if (o.extraRoles.politician) out.push('politician');
if (o.extraRoles.medium) out.push('medium');
if (o.extraRoles.lovers) out.push('lover', 'lover');
while (out.length < n) out.push('citizen');
return out;
}
// ---------------------------------------------------------------- chat filter (kidSafeChat)
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', 'fuck', 'shit', 'bitch'];
const LINK = /(https?:\/\/|www\.|\b[a-z0-9-]+\.(com|net|org|kr|io|gg|me|ly|co|xyz|app|dev|site|link)\b)/i;
const PHONE = /(01[016789]|0\d{1,2})[-\s.]?\d{3,4}[-\s.]?\d{4}/;
export function cleanChat(text: string): string {
return text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
}
/** Korean reason a message is refused, or null. */
export function chatProblem(text: string, kidSafe: boolean): string | null {
const len = [...text].length;
if (len === 0) return '메시지를 입력해 주세요.';
if (len > CHAT_MAX) return `메시지는 ${CHAT_MAX}자까지 보낼 수 있어요.`;
if (!kidSafe) return null;
const squashed = text.normalize('NFC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
if (BANNED.some((w) => squashed.includes(w))) return '고운 말을 써 주세요.';
if (LINK.test(text)) return '링크는 보낼 수 없어요.';
if (PHONE.test(text)) return '전화번호 같은 개인 정보는 보낼 수 없어요.';
return null;
}
// ---------------------------------------------------------------- helpers
const P = (s: MafiaState, id: PlayerId): PlayerState | undefined => s.players.find((p) => p.id === id);
const alive = (s: MafiaState) => s.players.filter((p) => p.alive);
const isAlive = (s: MafiaState, id: PlayerId | null) => !!id && !!P(s, id)?.alive;
type NightPhase = Extract<Phase, { kind: 'night' }>;
function discussionMs(s: MafiaState): number {
const o = s.options;
const lo = o.discussionMin;
const hi = Math.max(o.discussionMin, o.discussionMax);
return Math.min(hi, Math.max(lo, alive(s).length * o.discussionPerAlive)) * 1000;
}
function pushEvent(s: MafiaState, now: number, e: DistributiveOmit<PublicEvent, 'n' | 'at' | 'day'>, day = s.day): void {
s.events.push({ ...e, n: s.events.length + 1, at: now, day } as PublicEvent);
}
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
function startPhase(s: MafiaState, now: number, kind: 'greeting' | 'discussion' | 'vote' | 'night'): void {
switch (kind) {
case 'greeting':
s.phase = { kind, startedAt: now, endsAt: now + GREETING_MS, ready: [] };
break;
case 'discussion':
s.phase = { kind, startedAt: now, endsAt: now + discussionMs(s), adjustedBy: [], ready: [] };
break;
case 'vote':
s.phase = { kind, startedAt: now, endsAt: now + s.options.voteSeconds * 1000, votes: {} };
break;
case 'night':
s.phase = { kind, startedAt: now, endsAt: now + s.options.nightSeconds * 1000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null };
break;
}
pushEvent(s, now, { t: 'phase', kind });
}
function eliminate(s: MafiaState, now: number, p: PlayerState, by: DiedBy): void {
p.alive = false;
p.diedDay = s.day;
p.diedBy = by;
const reveal = s.options.deathReveal;
pushEvent(s, now, { t: 'death', p: p.id, by, team: reveal === 'none' ? null : teamOf(p.role), role: reveal === 'role' ? p.role : null });
}
function summaryOf(winner: Team, cause: WinCause, day: number): string {
if (winner === 'town') {
if (cause === 'vote') return `시민 팀 승리! ${day}일차 낮 투표로 마지막 마피아를 찾아냈어요.`;
if (cause === 'disconnect') return `시민 팀 승리! ${day}일차에 마지막 마피아가 자리를 비워 탈락했어요.`;
return '시민 팀 승리! 마피아가 모두 탈락했어요.';
}
if (cause === 'vote') return `마피아 팀 승리! ${day}일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.`;
if (cause === 'night') return `마피아 팀 승리! ${day}일차 아침, 마피아 수가 시민 수와 같아졌어요.`;
return `마피아 팀 승리! ${day}일차에 자리 비움 탈락으로 마피아 수가 시민 수와 같아졌어요.`;
}
/** §6: checked after an execution, after the morning news and after an away/leave elimination. */
function checkWin(s: MafiaState, now: number, cause: WinCause): boolean {
const living = alive(s);
const m = living.filter((p) => p.role === 'mafia').length;
const winner: Team | null = m === 0 ? 'town' : m >= living.length - m ? 'mafia' : null;
if (!winner) return false;
s.winner = winner;
s.phase = { kind: 'ended' };
pushEvent(s, now, { t: 'end', winner });
const players = s.players.map((p) => ({
id: p.id,
role: p.role,
team: teamOf(p.role),
alive: p.alive,
diedDay: p.diedDay,
diedBy: p.diedBy,
won: teamOf(p.role) === winner,
}));
const order = (won: boolean) => [...players.filter((p) => p.won === won && p.alive), ...players.filter((p) => p.won === won && !p.alive)].map((p) => p.id);
let mvp: PlayerId | null = null;
for (const p of s.players) if (teamOf(p.role) === 'town' && (s.mvpVotes[p.id] ?? 0) > (mvp ? s.mvpVotes[mvp]! : 0)) mvp = p.id;
s.final = { winner, days: s.day, cause, players, ranking: [...order(true), ...order(false)], mvp, summary: summaryOf(winner, cause, s.day) };
return true;
}
/** End of a voting day: away detection (§8.5), then win check. Returns true when the game ended. */
function endDay(s: MafiaState, now: number): boolean {
const away: PlayerState[] = [];
for (const p of alive(s)) {
p.idleDays = p.activeToday ? 0 : p.idleDays + 1;
p.activeToday = false;
if (p.idleDays >= AWAY_DAYS) away.push(p);
}
for (const p of away) eliminate(s, now, p, 'disconnect');
return away.length > 0 && checkWin(s, now, 'disconnect');
}
function resolveVote(s: MafiaState, now: number): PlayerId | null {
const ph = s.phase as Extract<Phase, { kind: 'vote' }>;
const tally: Record<PlayerId, number> = {};
const votes: Record<PlayerId, PlayerId | 'abstain'> = {};
let abstain = 0;
for (const p of alive(s)) {
const w = p.role === 'politician' ? 2 : 1;
const v = ph.votes[p.id];
if (!v || v === 'abstain' || !isAlive(s, v)) {
abstain += w;
votes[p.id] = 'abstain';
} else {
tally[v] = (tally[v] ?? 0) + w;
votes[p.id] = v;
}
}
const max = Math.max(0, ...Object.values(tally));
const tops = Object.keys(tally).filter((k) => tally[k] === max);
const accused = max >= 1 && tops.length === 1 ? tops[0]! : null;
s.trialVoters = accused ? Object.keys(votes).filter((k) => votes[k] === accused) : [];
pushEvent(s, now, { t: 'vote', tally, abstain, votes: s.options.openVote ? votes : null, accused, tie: tops.length > 1 });
return accused;
}
/** Returns true when the game ended. */
function resolveConfirm(s: MafiaState, now: number): boolean {
const ph = s.phase as Extract<Phase, { kind: 'confirm' }>;
let yes = 0;
let no = 0;
for (const p of alive(s)) {
const b = ph.ballots[p.id];
if (!b || p.id === ph.accused) continue;
const w = p.role === 'politician' ? 2 : 1;
if (b === 'yes') yes += w;
else no += w;
}
const acc = P(s, ph.accused)!;
if (yes <= no || !acc.alive) {
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'spared' });
return false;
}
if (acc.role === 'politician') {
acc.publicRole = true;
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'politician' });
return false;
}
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'executed' });
eliminate(s, now, acc, 'vote');
if (acc.role === 'mafia') {
for (const v of s.trialVoters) if (teamOf(P(s, v)!.role) === 'town') s.mvpVotes[v] = (s.mvpVotes[v] ?? 0) + 1;
}
return checkWin(s, now, 'vote');
}
/** Night resolution in the §5.5 order. Returns true when the game ended. */
function resolveNight(s: MafiaState, now: number, rng: Rng): boolean {
const ph = s.phase as NightPhase;
let target = isAlive(s, ph.mafiaTarget) ? ph.mafiaTarget : null;
const mafiaAlive = alive(s).some((p) => p.role === 'mafia');
if (!target && s.options.mafiaMustKill === 'random' && mafiaAlive) {
const pool = alive(s).filter((p) => p.role !== 'mafia');
if (pool.length) target = pool[rng.int(pool.length)]!.id;
}
if (!target) pushEvent(s, now, { t: 'quiet' });
else if (target === ph.heal) pushEvent(s, now, { t: 'healed' });
else {
const t = P(s, target)!;
if (t.role === 'soldier' && !t.soldierUsed) {
t.soldierUsed = true;
t.publicRole = true;
pushEvent(s, now, { t: 'soldier', p: t.id });
} else eliminate(s, now, t, 'mafia');
}
s.lastHealTarget = ph.heal;
// Private results, delivered at the end of the night (§5.5.5) even if the police/medium died tonight.
const police = s.players.find((p) => p.role === 'police');
if (police && ph.investigate) {
const r = P(s, ph.investigate)!.role;
addResult(s, police.id, 'investigate', ph.investigate, s.options.policeRevealRole ? r : r === 'mafia' ? 'mafia' : 'notMafia');
}
const medium = s.players.find((p) => p.role === 'medium');
if (medium && ph.mediumPeek) addResult(s, medium.id, 'peek', ph.mediumPeek, P(s, ph.mediumPeek)!.role);
s.day += 1;
return checkWin(s, now, 'night');
}
function addResult(s: MafiaState, to: PlayerId, kind: PrivateResult['kind'], target: PlayerId, result: PrivateResult['result']): void {
s.privateResults.push({ id: `res-${s.day}-${kind}-${s.privateResults.length + 1}`, to, day: s.day, kind, target, result });
}
/** The phase timer ran out (or everyone pressed 토론 끝내기). */
function advance(s: MafiaState, now: number, rng: Rng): void {
const ph = s.phase;
switch (ph.kind) {
case 'roleReveal':
startPhase(s, now, s.options.firstPhase === 'day' ? 'greeting' : 'night');
return;
case 'greeting':
startPhase(s, now, 'night');
return;
case 'discussion':
startPhase(s, now, 'vote');
return;
case 'vote': {
const accused = resolveVote(s, now);
if (accused) {
s.phase = { kind: 'defense', startedAt: now, endsAt: now + s.options.defenseSeconds * 1000, accused };
pushEvent(s, now, { t: 'phase', kind: 'defense' });
return;
}
if (!endDay(s, now)) startPhase(s, now, 'night');
return;
}
case 'defense':
s.phase = { kind: 'confirm', startedAt: now, endsAt: now + s.options.confirmSeconds * 1000, accused: ph.accused, ballots: {} };
pushEvent(s, now, { t: 'phase', kind: 'confirm' });
return;
case 'confirm':
if (resolveConfirm(s, now)) return;
if (!endDay(s, now)) startPhase(s, now, 'night');
return;
case 'night':
if (!resolveNight(s, now, rng)) startPhase(s, now, 'discussion');
return;
case 'ended':
throw new Error('mafia: advance after the end');
}
}
/** A player leaves mid-game: eliminated at once (like 자리 비움), then the phase is repaired. */
function leave(s: MafiaState, now: number, rng: Rng, p: PlayerState): void {
eliminate(s, now, p, 'disconnect');
if (checkWin(s, now, 'disconnect')) return;
const ph = s.phase;
switch (ph.kind) {
case 'greeting':
case 'discussion':
ph.ready = ph.ready.filter((x) => x !== p.id);
if (ph.ready.length > 0 && alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
return;
case 'vote':
delete ph.votes[p.id];
return;
case 'defense':
case 'confirm':
if (ph.kind === 'confirm') delete ph.ballots[p.id];
if (ph.accused === p.id && !endDay(s, now)) startPhase(s, now, 'night');
return;
case 'night':
if (ph.mafiaTarget === p.id) {
ph.mafiaTarget = null;
ph.mafiaPickedBy = null;
}
if (ph.investigate === p.id) ph.investigate = null;
if (ph.heal === p.id) ph.heal = null;
return;
}
}
// ---------------------------------------------------------------- permissions
function nightActionOf(r: Role): NightActionType | null {
return r === 'mafia' ? 'mafiaTarget' : r === 'police' ? 'investigate' : r === 'doctor' ? 'heal' : r === 'medium' ? 'mediumPeek' : null;
}
/** Legal targets for a night action. */
function nightTargets(s: MafiaState, me: PlayerState, action: NightActionType): PlayerId[] {
switch (action) {
case 'mafiaTarget':
return alive(s).filter((p) => p.role !== 'mafia').map((p) => p.id);
case 'investigate':
return alive(s).filter((p) => p.id !== me.id).map((p) => p.id);
case 'heal':
return alive(s)
.filter((p) => (p.id !== me.id || s.options.doctorSelfHeal) && (s.options.doctorRepeat || p.id !== s.lastHealTarget))
.map((p) => p.id);
case 'mediumPeek':
return s.players.filter((p) => !p.alive && p.id !== me.id).map((p) => p.id);
}
}
const NIGHT_PROMPT: Record<NightActionType, string> = {
mafiaTarget: '오늘 밤 할 일: 마피아가 노릴 사람을 골라 주세요',
investigate: '오늘 밤 할 일: 조사할 사람을 골라 주세요',
heal: '오늘 밤 할 일: 지켜 줄 사람을 골라 주세요',
mediumPeek: '오늘 밤 할 일: 직업을 알아볼 탈락자를 골라 주세요',
};
function canRead(s: MafiaState, viewer: PlayerState | null, ch: Channel): boolean {
if (ch === 'day') return true;
if (s.winner && s.options.revealChatsAtEnd) return true;
if (!viewer) return false;
switch (ch) {
case 'mafia':
return viewer.role === 'mafia' || (!viewer.alive && s.options.deadSeeAll);
case 'dead':
return !viewer.alive || (viewer.role === 'medium' && s.phase.kind === 'night');
case 'lovers':
return viewer.role === 'lover';
}
}
/** Why `p` cannot write in `ch` now (Korean), or null (§5.6). */
function writeBlock(s: MafiaState, p: PlayerState, ch: Channel): string | null {
const k = s.phase.kind;
if (k === 'ended') return '게임이 끝났어요.';
switch (ch) {
case 'day':
if (!p.alive) return '탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.';
if (k === 'roleReveal') return '직업을 확인하는 중이에요. 잠시 기다려 주세요.';
if (k === 'night') return '밤에는 낮 채팅을 쓸 수 없어요.';
if (k === 'defense' && s.phase.accused !== p.id) return '최후의 반론 중에는 지목된 사람만 말할 수 있어요.';
return null;
case 'mafia':
if (p.role !== 'mafia') return '마피아만 쓸 수 있어요.';
if (!p.alive) return '탈락한 마피아는 읽기만 할 수 있어요.';
return k === 'night' ? null : '마피아 채팅은 밤에만 쓸 수 있어요.';
case 'dead':
if (!p.alive) return null;
if (p.role !== 'medium') return '탈락한 사람만 쓸 수 있어요.';
return k === 'night' ? null : '영매는 밤에만 죽은 사람과 이야기할 수 있어요.';
case 'lovers':
if (p.role !== 'lover') return '연인만 쓸 수 있어요.';
if (!p.alive) return '탈락하면 연인 채팅은 읽기만 할 수 있어요.';
return k === 'night' ? null : '연인 채팅은 밤에만 쓸 수 있어요.';
}
}
// ---------------------------------------------------------------- validate
function validate(s: MafiaState, actor: PlayerId, a: MafiaAction, ctx?: { now: number }): Validation {
const p = P(s, actor);
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.winner) return fail('게임이 이미 끝났어요.');
const ph = s.phase;
if (ph.kind === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'phaseTimeout':
return ctx && ctx.now < ph.endsAt ? fail('아직 시간이 남았어요.') : ok;
case 'leave':
return p.alive ? ok : fail('이미 탈락했어요.');
case 'chat': {
if (!(CHANNELS as readonly string[]).includes(a.channel)) return fail('채팅방을 골라 주세요.');
const block = writeBlock(s, p, a.channel);
if (block) return fail(block);
const why = chatProblem(cleanChat(a.text), s.options.kidSafeChat);
if (why) return fail(why);
if (ctx && ctx.now - (s.lastChatAt[actor] ?? -Infinity) < CHAT_GAP_MS) return fail('조금 천천히 보내 주세요.');
return ok;
}
default:
break;
}
if (!p.alive) return fail('탈락한 사람은 할 수 없어요.');
switch (a.type) {
case 'adjustTime':
if (ph.kind !== 'discussion') return fail('토론 시간에만 시간을 바꿀 수 있어요.');
if (!s.options.timeAdjust) return fail('이 방은 토론 시간을 바꿀 수 없어요.');
if (ph.adjustedBy.includes(actor)) return fail('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
return a.delta === 15 || a.delta === -15 ? ok : fail('15초씩만 바꿀 수 있어요.');
case 'readyEarly':
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') return fail('지금은 토론 시간이 아니에요.');
return ph.ready.includes(actor) ? fail('이미 토론 끝내기를 눌렀어요.') : ok;
case 'vote':
if (ph.kind !== 'vote') return fail('지금은 지목 투표 시간이 아니에요.');
if (a.target === 'abstain') return ok;
if (a.target === actor) return fail('나 자신은 지목할 수 없어요.');
return isAlive(s, a.target) ? ok : fail('살아 있는 사람만 지목할 수 있어요.');
case 'confirm':
if (ph.kind !== 'confirm') return fail('지금은 찬반 투표 시간이 아니에요.');
if (ph.accused === actor) return fail('지목된 사람은 찬반 투표를 할 수 없어요.');
return a.choice === 'yes' || a.choice === 'no' ? ok : fail('찬성이나 반대를 골라 주세요.');
case 'mafiaTarget':
case 'investigate':
case 'heal':
case 'mediumPeek': {
if (ph.kind !== 'night') return fail('밤에만 할 수 있어요.');
if (nightActionOf(p.role) !== a.type) return fail('내 직업으로는 할 수 없는 행동이에요.');
if (a.type === 'mafiaTarget' && a.target === null) return ok;
const t = a.target === null ? undefined : P(s, a.target);
if (!t) return fail('이 게임의 플레이어가 아니에요.');
if (nightTargets(s, p, a.type).includes(t.id)) return ok;
if (a.type === 'mediumPeek') return fail(t.alive ? '탈락한 사람만 고를 수 있어요.' : '나 자신은 고를 수 없어요.');
if (!t.alive) return fail('살아 있는 사람만 고를 수 있어요.');
if (a.type === 'mafiaTarget') return fail('마피아끼리는 고를 수 없어요.');
if (a.type === 'heal') return t.id === actor ? fail('이 방은 의사가 자기 자신을 지킬 수 없어요.') : fail('같은 사람을 이틀 연속 지킬 수 없어요.');
return fail('나 자신은 조사할 수 없어요.');
}
}
}
// ---------------------------------------------------------------- view
function roleKnown(s: MafiaState, p: PlayerState, viewer: PlayerState | null): boolean {
if (s.winner) return true;
if (p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
if (!viewer) return false;
if (viewer.id === p.id) return true;
if (viewer.role === 'mafia' && p.role === 'mafia') return true;
if (viewer.role === 'lover' && p.role === 'lover') return true;
return !viewer.alive && s.options.deadSeeAll;
}
function nightView(s: MafiaState, me: PlayerState): NightView {
const ph = s.phase as NightPhase;
const action = nightActionOf(me.role);
if (!action) return { action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null };
const choice = action === 'mafiaTarget' ? ph.mafiaTarget : action === 'investigate' ? ph.investigate : action === 'heal' ? ph.heal : ph.mediumPeek;
const targets = nightTargets(s, me, action);
return {
action,
prompt: action === 'mediumPeek' && targets.length === 0 ? '오늘 밤 할 일: 아직 탈락한 사람이 없어요. 잠시 기다려 주세요' : NIGHT_PROMPT[action],
targets,
choice,
pickedBy: action === 'mafiaTarget' ? ph.mafiaPickedBy : null,
};
}
function myView(s: MafiaState, me: PlayerState): MyView {
const ph = s.phase;
const mates = me.role === 'mafia' || me.role === 'lover' ? s.players.filter((p) => p.role === me.role && p.id !== me.id).map((p) => p.id) : [];
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
return {
role: me.role,
team: teamOf(me.role),
alive: me.alive,
mates,
vote: ph.kind === 'vote' && me.alive ? (ph.votes[me.id] ?? null) : null,
voteTargets: ph.kind === 'vote' && me.alive ? alive(s).filter((p) => p.id !== me.id).map((p) => p.id) : [],
ballot: ph.kind === 'confirm' && me.alive ? (ph.ballots[me.id] ?? null) : null,
canConfirm: ph.kind === 'confirm' && me.alive && ph.accused !== me.id,
canAdjust: ph.kind === 'discussion' && me.alive && s.options.timeAdjust && !ph.adjustedBy.includes(me.id),
canReady: day && me.alive && !ph.ready.includes(me.id),
ready: day && ph.ready.includes(me.id),
night: ph.kind === 'night' && me.alive ? nightView(s, me) : null,
results: s.privateResults.filter((r) => r.to === me.id),
idleWarning: me.alive && !s.winner && me.idleDays >= AWAY_DAYS - 1 && !me.activeToday,
};
}
function view(s: MafiaState, viewer: PlayerId | null): MafiaView {
const me = viewer ? (P(s, viewer) ?? null) : null;
const ph = s.phase;
const readable = CHANNELS.filter((ch) => canRead(s, me, ch));
const chat: ChatView[] = s.chat
.filter((m) => readable.includes(m.channel))
.slice(-CHAT_VIEW)
.map((m) => ({ key: `${m.channel}:${m.id}`, channel: m.channel, from: m.from, text: m.text, at: m.at, day: m.day }));
const setup: Partial<Record<Role, number>> = {};
for (const p of s.players) setup[p.role] = (setup[p.role] ?? 0) + 1;
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
return {
me: me?.id ?? null,
options: s.options,
day: s.day,
phase: {
kind: ph.kind,
startedAt: ph.kind === 'ended' ? 0 : ph.startedAt,
endsAt: ph.kind === 'ended' ? null : ph.endsAt,
accused: ph.kind === 'defense' || ph.kind === 'confirm' ? ph.accused : null,
adjustedBy: ph.kind === 'discussion' ? [...ph.adjustedBy] : [],
readyCount: day ? ph.ready.length : 0,
readyNeeded: day ? alive(s).length : 0,
},
players: s.players.map((p) => {
const known = roleKnown(s, p, me);
const teamKnown = known || (!p.alive && s.options.deathReveal === 'team');
return { id: p.id, alive: p.alive, diedDay: p.diedDay, diedBy: p.diedBy, role: known ? p.role : null, team: teamKnown ? teamOf(p.role) : null };
}),
setup,
aliveCount: alive(s).length,
events: s.events.slice(-EVENTS_VIEW),
chat,
channels: me ? readable.map((ch) => ({ channel: ch, canWrite: !writeBlock(s, me, ch), why: writeBlock(s, me, ch) })) : [],
my: me ? myView(s, me) : null,
winner: s.winner,
final: s.final,
};
}
// ---------------------------------------------------------------- definition
function activePlayers(s: MafiaState): PlayerId[] {
const ph = s.phase;
if (ph.kind === 'ended') return [];
if (ph.kind === 'defense') return [ph.accused];
if (ph.kind === 'confirm') return alive(s).filter((p) => p.id !== ph.accused).map((p) => p.id);
// Night: every living player, so the list never reveals who has a night role (§8.3).
return alive(s).map((p) => p.id);
}
type Def = GameDefinition<MafiaState, MafiaAction, MafiaView, MafiaOptions>;
export const mafia: Def = {
id: 'mafia',
nameKo: '마피아',
minPlayers: 4,
maxPlayers: 12,
stateVersion: 1,
defaultOptions: mafiaOptionsSchema.parse({}),
optionsSchema: mafiaOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: mafiaActionSchema as unknown as Def['actionSchema'],
ownChat: true,
playerCountError: compositionError,
setup({ players, options, rng, now }) {
const roles = rng.shuffle(rolesFor(options, players.length));
const ps: PlayerState[] = players.map((id, i) => ({
id,
role: roles[i]!,
alive: true,
soldierUsed: false,
publicRole: false,
diedDay: null,
diedBy: null,
activeToday: false,
idleDays: 0,
}));
return {
options,
players: ps,
day: 1,
phase: { kind: 'roleReveal', startedAt: now, endsAt: now + ROLE_REVEAL_MS },
lastHealTarget: null,
privateResults: [],
chat: [],
chatCount: { day: 0, mafia: 0, dead: 0, lovers: 0 },
lastChatAt: {},
events: [],
trialVoters: [],
mvpVotes: {},
winner: null,
seq: 0,
final: null,
};
},
validate,
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const before = s.events.length;
s.seq += 1;
const p = P(s, actor)!;
const ph = s.phase;
if (a.type !== 'phaseTimeout' && a.type !== 'leave') p.activeToday = true;
switch (a.type) {
case 'phaseTimeout':
advance(s, now, rng);
break;
case 'leave':
leave(s, now, rng, p);
break;
case 'chat': {
s.chatCount[a.channel] += 1;
s.chat.push({ id: s.chatCount[a.channel], channel: a.channel, from: actor, text: cleanChat(a.text), at: now, day: s.day });
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
s.lastChatAt[actor] = now;
break;
}
case 'adjustTime':
if (ph.kind !== 'discussion') throw new Error('mafia: adjustTime outside discussion');
ph.adjustedBy.push(actor);
ph.endsAt = a.delta > 0 ? ph.endsAt + ADJUST_MS : Math.max(ph.endsAt - ADJUST_MS, Math.min(ph.endsAt, now + ADJUST_FLOOR_MS));
pushEvent(s, now, { t: 'adjust', p: actor, delta: a.delta });
break;
case 'readyEarly':
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') throw new Error('mafia: readyEarly outside discussion');
ph.ready.push(actor);
if (alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
break;
case 'vote':
if (ph.kind !== 'vote') throw new Error('mafia: vote outside the vote phase');
ph.votes[actor] = a.target;
break;
case 'confirm':
if (ph.kind !== 'confirm') throw new Error('mafia: confirm outside the confirm phase');
ph.ballots[actor] = a.choice;
break;
case 'mafiaTarget':
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
ph.mafiaTarget = a.target;
ph.mafiaPickedBy = a.target === null ? null : actor;
break;
case 'investigate':
case 'heal':
case 'mediumPeek':
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
ph[a.type] = a.target;
break;
}
// Only public announcements become events (chat and night choices stay in the filtered view).
const events: GameEvent[] = s.events.slice(before).map((e) => ({ ...e, type: e.t }));
return { state: s, events };
},
view,
activePlayers,
deadline(s) {
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
},
/** One player drives the phase clock (the deadline is shared by everybody). */
timeoutPlayers(s) {
return s.phase.kind === 'ended' ? [] : [s.players[0]!.id];
},
/** An away driver must not end a phase early. */
timeoutNotBefore(s) {
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
},
/** Phases end on the clock; a missing vote is an abstention and a missing night action is "no action" (§8.5). */
onTimeout() {
return { type: 'phaseTimeout' };
},
onLeave(s, player) {
const p = P(s, player);
if (!p || !p.alive || s.winner) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const won = (id: PlayerId) => f.players.find((p) => p.id === id)!.won;
return {
ranking: [f.ranking.filter(won), f.ranking.filter((id) => !won(id))],
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
summary: f.summary,
reason: 'normal',
};
},
/** Test helper: every legal move plus the phase clock and one short message per writable channel. */
legalActions(s, player) {
const p = P(s, player);
if (!p || s.winner || s.phase.kind === 'ended') return [];
const out: MafiaAction[] = [{ type: 'phaseTimeout' }];
for (const ch of CHANNELS) if (!writeBlock(s, p, ch)) out.push({ type: 'chat', channel: ch, text: `m${s.seq}-${player}-${ch}` });
if (!p.alive) return out;
const ph = s.phase;
const me = myView(s, p);
if (me.canReady) out.push({ type: 'readyEarly' });
if (me.canAdjust) out.push({ type: 'adjustTime', delta: 15 }, { type: 'adjustTime', delta: -15 });
if (ph.kind === 'vote') {
out.push({ type: 'vote', target: 'abstain' });
for (const t of me.voteTargets) out.push({ type: 'vote', target: t });
}
if (me.canConfirm) out.push({ type: 'confirm', choice: 'yes' }, { type: 'confirm', choice: 'no' });
if (me.night?.action) {
const type = me.night.action;
for (const t of me.night.targets) out.push({ type, target: t } as MafiaAction);
if (type === 'mafiaTarget') out.push({ type, target: null });
}
return out.filter((a) => validate(s, player, a).ok);
},
/** Private result tokens: only their owner may ever see them. */
secretsOf(s, player) {
if (s.winner) return [];
return s.privateResults.filter((r) => r.to === player).map((r) => r.id);
},
};
export const _internal = { canRead, writeBlock, roleKnown, nightTargets, discussionMs };

View File

@@ -0,0 +1,883 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_DAYS,
CHANNELS,
GREETING_MS,
ROLE_REVEAL_MS,
compositionError,
mafia,
rolesFor,
teamOf,
type Channel,
type MafiaAction,
type MafiaOptions,
type MafiaState,
type MafiaView,
type PublicEvent,
type Role,
} from './index';
// ---------------------------------------------------------------- helpers
const T0 = 1_000_000;
const opts = (o: Record<string, unknown> = {}): MafiaOptions => mafia.optionsSchema.parse(o);
const ids = (n: number) => Array.from({ length: n }, (_, i) => `P${i}`);
/** Game with fixed roles (P0, P1, … in order), started at `T0`, still in the role-reveal phase. */
function rig(roles: Role[], o: Record<string, unknown> = {}): MafiaState {
const s = mafia.setup({ players: ids(roles.length), options: opts(o), rng: SeededRng.fromSeed(seedN(1)), now: T0 });
s.players.forEach((p, i) => (p.role = roles[i]!));
return s;
}
const rng = SeededRng.fromSeed([9, 9, 9, 9]);
function act(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): MafiaState {
const at = now ?? (s.phase.kind === 'ended' ? T0 : Math.max(T0, s.phase.startedAt + 1));
const v = mafia.validate(s, actor, a, { now: at });
if (!v.ok) throw new Error(`${actor} ${JSON.stringify(a)} rejected: ${v.reason}`);
return mafia.apply(s, actor, a, { rng, now: at }).state;
}
function reject(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): string {
const v = mafia.validate(s, actor, a, now === undefined ? undefined : { now });
if (v.ok) throw new Error(`${actor} ${JSON.stringify(a)} was accepted`);
return v.reason;
}
/** The phase clock runs out. */
function tick(s: MafiaState): MafiaState {
if (s.phase.kind === 'ended') throw new Error('tick after the end');
return act(s, s.players[0]!.id, { type: 'phaseTimeout' }, s.phase.endsAt);
}
/** Ticks until `kind` (stops early if the game ends). */
function until(s: MafiaState, kind: MafiaState['phase']['kind']): MafiaState {
for (let i = 0; i < 20 && s.phase.kind !== kind && s.phase.kind !== 'ended'; i++) s = tick(s);
if (s.phase.kind !== kind) throw new Error(`never reached ${kind} (now ${s.phase.kind})`);
return s;
}
/** Marks everyone active so the away rule does not interfere. */
function busy(s: MafiaState): MafiaState {
for (const p of s.players) p.activeToday = true;
return s;
}
const pl = (s: MafiaState, id: string) => s.players.find((p) => p.id === id)!;
const evs = (s: MafiaState, t: PublicEvent['t']) => s.events.filter((e) => e.t === t);
const lastEv = <K extends PublicEvent['t']>(s: MafiaState, t: K) => evs(s, t).at(-1) as Extract<PublicEvent, { t: K }> | undefined;
const say = (s: MafiaState, p: PlayerId, channel: Channel, text: string, now?: number) => act(s, p, { type: 'chat', channel, text }, now);
/** Plays a vote day: every alive player votes per `votes` (others abstain); returns the state after the vote phase. */
function voteDay(s: MafiaState, votes: Record<string, string>): MafiaState {
s = until(busy(s), 'vote');
for (const [p, t] of Object.entries(votes)) s = act(s, p, { type: 'vote', target: t });
return tick(s);
}
// ---------------------------------------------------------------- setup (§4)
describe('직업 배분 (§4)', () => {
const TABLE: Record<number, [number, number]> = { 4: [1, 1], 5: [1, 2], 6: [2, 2], 7: [2, 3], 8: [2, 4], 9: [3, 4], 10: [3, 5], 11: [3, 6], 12: [3, 7] };
test('4~12인 자동 배분이 표와 일치', () => {
for (let n = 4; n <= 12; n++) {
const roles = rolesFor(opts(), n);
const count = (r: Role) => roles.filter((x) => x === r).length;
expect([count('mafia'), count('citizen')]).toEqual(TABLE[n]!);
expect(count('police')).toBe(1);
expect(count('doctor')).toBe(1);
const s = mafia.setup({ players: ids(n), options: opts(), rng: SeededRng.fromSeed(seedN(n)), now: T0 });
expect(s.players.map((p) => p.role).sort()).toEqual([...roles].sort());
}
});
test('추가 직업은 시민 자리만 대신하고, 모자라면 대기실에서 거부', () => {
const all = { extraRoles: { soldier: true, politician: true, medium: true, lovers: true } };
const r10 = rolesFor(opts(all), 10);
expect(r10.filter((r) => r === 'mafia')).toHaveLength(3);
expect(r10.filter((r) => r === 'citizen')).toHaveLength(0);
expect(r10.filter((r) => r === 'lover')).toHaveLength(2);
expect(compositionError(opts(all), 9)).toContain('연인을');
expect(rolesFor(opts(all), 12).filter((r) => r === 'citizen')).toHaveLength(2);
// 4명: 시민 1자리 → 연인(2자리)은 안 돼요.
expect(compositionError(opts({ extraRoles: { lovers: true } }), 4)).toBe('인원이 부족해서 연인을 넣을 수 없어요(시민 자리 2개 필요).');
expect(compositionError(opts({ extraRoles: { soldier: true } }), 4)).toBeNull();
expect(compositionError(opts({ extraRoles: { soldier: true, medium: true } }), 4)).toContain('영매를 넣을 수 없어요');
expect(mafia.playerCountError!(opts(all), 8)).toContain('연인을');
expect(() => mafia.setup({ players: ids(4), options: opts({ extraRoles: { lovers: true } }), rng: SeededRng.fromSeed(seedN(1)), now: 0 })).toThrow();
});
test('수동 마피아 수는 전체의 절반 미만', () => {
expect(compositionError(opts({ mafiaCount: 2 }), 4)).toBe('4명일 때 마피아는 1명까지 정할 수 있어요.');
expect(compositionError(opts({ mafiaCount: 2 }), 5)).toBeNull();
expect(compositionError(opts({ mafiaCount: 3 }), 6)).toContain('2명까지');
expect(compositionError(opts({ mafiaCount: 4 }), 9)).toBeNull();
expect(mafia.playerCountError!(opts(), 3)).toBeTruthy();
});
test('셔플은 시드로 결정; 마피아는 동료를, 연인은 상대를 안다; 8초 후 첫 페이즈', () => {
const o = opts({ extraRoles: { lovers: true } });
const a = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
const b = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const orders = new Set<string>();
for (let i = 1; i <= 20; i++) orders.add(mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(i)), now: T0 }).players.map((p) => p.role).join());
expect(orders.size).toBeGreaterThan(10);
const maf = a.players.filter((p) => p.role === 'mafia').map((p) => p.id);
const v = mafia.view(a, maf[0]!);
expect(v.my!.mates).toEqual([maf[1]!]);
expect(v.players.filter((p) => p.role === 'mafia').map((p) => p.id)).toEqual(maf);
const lovers = a.players.filter((p) => p.role === 'lover').map((p) => p.id);
expect(mafia.view(a, lovers[0]!).my!.mates).toEqual([lovers[1]!]);
expect(a.phase).toMatchObject({ kind: 'roleReveal', endsAt: T0 + ROLE_REVEAL_MS });
expect(a.day).toBe(1);
const g = tick(a);
expect(g.phase).toMatchObject({ kind: 'greeting', endsAt: T0 + ROLE_REVEAL_MS + GREETING_MS });
expect(tick(g).phase.kind).toBe('night');
expect(tick(rig(['mafia', 'police', 'doctor', 'citizen'], { firstPhase: 'night' })).phase.kind).toBe('night');
});
test('토론 시간 = clamp(생존자 × 값, 최소, 최대)', () => {
const s = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
expect(s.phase.kind === 'discussion' && s.phase.endsAt - s.phase.startedAt).toBe(75_000); // 5 × 15
let t = until(rig(['mafia', 'police', 'doctor', 'citizen'], { discussionPerAlive: 10, discussionMin: 60 }), 'night');
t = tick(t);
expect(t.phase.kind === 'discussion' && t.phase.endsAt - t.phase.startedAt).toBe(60_000);
let u = until(rig(['mafia', 'mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen'], { discussionPerAlive: 20, discussionMax: 120 }), 'night');
u = tick(u);
expect(u.phase.kind === 'discussion' && u.phase.endsAt - u.phase.startedAt).toBe(120_000);
});
});
// ---------------------------------------------------------------- votes (§5.3, §5.4)
describe('지목 투표와 찬반 투표 (§5.3, §5.4)', () => {
// P0 마피아, P1 경찰, P2 의사, P3~P5 시민 (2마피아 없이 6명: 마피아 1명이라 오래 버팀)
const six = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
const day2 = (roles = six(), o: Record<string, unknown> = {}) => until(busy(until(rig(roles, o), 'night')), 'discussion');
test('동률이면 처형 없이 밤으로, 전원 기권도 처형 없음', () => {
let s = voteDay(day2(), { P0: 'P1', P1: 'P0', P2: 'P3', P3: 'P2' });
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: true });
expect(s.phase.kind).toBe('night');
s = voteDay(day2(), { P0: 'abstain', P1: 'abstain' });
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: false, abstain: 6 });
expect(s.phase.kind).toBe('night');
expect(s.players.every((p) => p.alive)).toBe(true);
});
test('정치인 2표로 동률이 깨짐; 투표 변경은 마지막 선택이 유효', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
let s = until(day2(roles), 'vote');
s = act(s, 'P1', { type: 'vote', target: 'P4' });
s = act(s, 'P1', { type: 'vote', target: 'P0' });
s = act(s, 'P3', { type: 'vote', target: 'P0' });
s = act(s, 'P0', { type: 'vote', target: 'P5' });
s = act(s, 'P2', { type: 'vote', target: 'P5' });
s = tick(s);
const v = lastEv(s, 'vote')!;
expect(v.tally).toEqual({ P0: 3, P5: 2 });
expect(v.accused).toBe('P0');
expect(v.votes).toMatchObject({ P1: 'P0', P3: 'P0', P4: 'abstain' });
expect(s.phase).toMatchObject({ kind: 'defense', accused: 'P0' });
});
test('openVote 끄면 득표수만 공개; 자기 자신·죽은 사람 지목 불가', () => {
let s = until(day2(six(), { openVote: false }), 'vote');
expect(reject(s, 'P1', { type: 'vote', target: 'P1' })).toBe('나 자신은 지목할 수 없어요.');
s = act(s, 'P1', { type: 'vote', target: 'P0' });
s = tick(s);
expect(lastEv(s, 'vote')).toMatchObject({ votes: null, tally: { P0: 1 }, accused: 'P0' });
});
test('찬반 동률이면 생존; 찬성이 많으면 처형(공개는 deathReveal)', () => {
let s = voteDay(day2(), { P1: 'P3' });
s = tick(s); // defense → confirm
expect(s.phase).toMatchObject({ kind: 'confirm', accused: 'P3' });
expect(reject(s, 'P3', { type: 'confirm', choice: 'no' })).toBe('지목된 사람은 찬반 투표를 할 수 없어요.');
s = act(s, 'P0', { type: 'confirm', choice: 'yes' });
s = act(s, 'P1', { type: 'confirm', choice: 'no' });
s = tick(s);
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', yes: 1, no: 1, outcome: 'spared' });
expect(pl(s, 'P3').alive).toBe(true);
expect(s.phase.kind).toBe('night');
let t = voteDay(day2(), { P1: 'P3' });
t = tick(t);
t = act(t, 'P0', { type: 'confirm', choice: 'yes' });
t = act(t, 'P1', { type: 'confirm', choice: 'yes' });
t = act(t, 'P2', { type: 'confirm', choice: 'no' });
t = tick(t);
expect(lastEv(t, 'confirm')).toMatchObject({ yes: 2, no: 1, outcome: 'executed' });
expect(lastEv(t, 'death')).toEqual(expect.objectContaining({ p: 'P3', by: 'vote', team: 'town', role: null }));
expect(pl(t, 'P3')).toMatchObject({ alive: false, diedBy: 'vote', diedDay: 2 });
});
test('정치인 피고가 찬반을 통과하면 정치인임이 공개되고 생존', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
let s = voteDay(day2(roles), { P1: 'P3', P0: 'P3' });
s = tick(s);
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
s = tick(s);
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', outcome: 'politician' });
expect(pl(s, 'P3').alive).toBe(true);
for (const viewer of ['P4', null]) expect(mafia.view(s, viewer).players.find((p) => p.id === 'P3')!.role).toBe('politician');
});
test('최후의 반론 중에는 피고만 낮 채팅', () => {
let s = voteDay(day2(), { P1: 'P3' });
expect(s.phase.kind).toBe('defense');
expect(reject(s, 'P1', { type: 'chat', channel: 'day', text: '안녕' })).toBe('최후의 반론 중에는 지목된 사람만 말할 수 있어요.');
s = say(s, 'P3', 'day', '저는 시민이에요!');
expect(s.chat.at(-1)).toMatchObject({ from: 'P3', channel: 'day', text: '저는 시민이에요!' });
});
});
// ---------------------------------------------------------------- night (§5.5)
describe('밤 (§5.5)', () => {
const night1 = (roles: Role[], o: Record<string, unknown> = {}) => until(rig(roles, o), 'night');
const five = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen'];
test('의사가 마피아 대상을 치료하면 사망 없음, 발표에 이름 없음', () => {
let s = night1(five());
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
s = tick(s);
expect(s.players.every((p) => p.alive)).toBe(true);
const healed = lastEv(s, 'healed')!;
expect(healed).toBeDefined();
expect(JSON.stringify(healed)).not.toContain('P3');
expect(s.phase.kind).toBe('discussion');
expect(s.day).toBe(2);
expect(s.lastHealTarget).toBe('P3');
});
test('doctorSelfHeal / doctorRepeat 끄면 검증 거부', () => {
let s = night1(five(), { doctorSelfHeal: false, doctorRepeat: false });
expect(reject(s, 'P2', { type: 'heal', target: 'P2' })).toBe('이 방은 의사가 자기 자신을 지킬 수 없어요.');
s = act(s, 'P2', { type: 'heal', target: 'P4' });
s = until(busy(tick(s)), 'night');
expect(reject(s, 'P2', { type: 'heal', target: 'P4' })).toBe('같은 사람을 이틀 연속 지킬 수 없어요.');
expect(mafia.view(s, 'P2').my!.night!.targets).not.toContain('P4');
s = act(s, 'P2', { type: 'heal', target: 'P3' });
// 기본값(켜짐)이면 자기 자신·연속 모두 허용
let t = night1(five());
t = act(t, 'P2', { type: 'heal', target: 'P2' });
t = until(busy(tick(t)), 'night');
t = act(t, 'P2', { type: 'heal', target: 'P2' });
expect(t.phase.kind === 'night' && t.phase.heal).toBe('P2');
});
test('군인: 첫 공격은 버티고 공개, 두 번째 공격에 사망; 의사 치료가 겹치면 의사 우선', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'soldier', 'citizen', 'citizen'];
let s = night1(roles);
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: false, publicRole: false });
expect(lastEv(s, 'healed')).toBeDefined();
s = until(busy(s), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: true, publicRole: true });
expect(lastEv(s, 'soldier')).toMatchObject({ p: 'P3' });
expect(mafia.view(s, null).players.find((p) => p.id === 'P3')!.role).toBe('soldier');
s = until(busy(s), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: false, diedBy: 'mafia', diedDay: 3 });
});
test('마피아 2명이 다른 대상을 고르면 마지막 선택만 유효; 미선택 + noKill이면 사망 없음', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
let s = night1(roles);
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
expect(mafia.view(s, 'P0').my!.night).toMatchObject({ choice: 'P5', pickedBy: 'P1' });
expect(reject(s, 'P0', { type: 'mafiaTarget', target: 'P1' })).toBe('마피아끼리는 고를 수 없어요.');
s = tick(s);
expect(pl(s, 'P4').alive).toBe(true);
expect(pl(s, 'P5')).toMatchObject({ alive: false, diedBy: 'mafia' });
let t = night1(roles);
t = act(t, 'P0', { type: 'mafiaTarget', target: 'P4' });
t = act(t, 'P1', { type: 'mafiaTarget', target: null });
t = tick(t);
expect(t.players.every((p) => p.alive)).toBe(true);
expect(lastEv(t, 'quiet')).toBeDefined();
});
test('mafiaMustKill=random이면 대상 미선택 시 RNG로 비마피아 1명', () => {
const s = tick(night1(['mafia', 'police', 'doctor', 'citizen', 'citizen'], { mafiaMustKill: 'random' }));
const dead = s.players.filter((p) => !p.alive);
expect(dead).toHaveLength(1);
expect(dead[0]!.role).not.toBe('mafia');
});
test('경찰이 대상을 3번 바꾸면 마감 때 마지막 1명 결과만 받는다', () => {
let s = night1(five());
for (const t of ['P3', 'P0', 'P4']) s = act(s, 'P1', { type: 'investigate', target: t });
expect(s.privateResults).toHaveLength(0);
expect(mafia.view(s, 'P1').my!.results).toEqual([]);
s = tick(s);
expect(s.privateResults).toHaveLength(1);
expect(s.privateResults[0]).toMatchObject({ to: 'P1', target: 'P4', result: 'notMafia', kind: 'investigate', day: 1 });
s = until(busy(s), 'night');
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = tick(s);
expect(mafia.view(s, 'P1').my!.results.map((r) => r.result)).toEqual(['notMafia', 'mafia']);
expect(reject(until(busy(s), 'night'), 'P1', { type: 'investigate', target: 'P1' })).toBe('나 자신은 조사할 수 없어요.');
// policeRevealRole
let r = night1(five(), { policeRevealRole: true });
r = act(r, 'P1', { type: 'investigate', target: 'P2' });
expect(tick(r).privateResults[0]!.result).toBe('doctor');
});
test('경찰이 그 밤에 죽어도 결과는 전달; 영매는 탈락자 직업 확인', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'medium', 'citizen', 'citizen'];
let s = night1(roles);
expect(mafia.view(s, 'P3').my!.night).toMatchObject({ action: 'mediumPeek', targets: [] });
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P1' });
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = tick(s);
expect(pl(s, 'P1').alive).toBe(false);
expect(mafia.view(s, 'P1').my!.results[0]).toMatchObject({ target: 'P0', result: 'mafia' });
s = until(busy(s), 'night');
expect(reject(s, 'P3', { type: 'mediumPeek', target: 'P4' })).toBe('탈락한 사람만 고를 수 있어요.');
s = act(s, 'P3', { type: 'mediumPeek', target: 'P1' });
s = tick(s);
expect(mafia.view(s, 'P3').my!.results[0]).toMatchObject({ kind: 'peek', target: 'P1', result: 'police' });
});
test('밤 행동은 자기 직업만, 밤에만', () => {
let s = night1(five());
expect(reject(s, 'P3', { type: 'investigate', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
expect(reject(s, 'P1', { type: 'heal', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
s = until(busy(s), 'discussion');
expect(reject(s, 'P1', { type: 'investigate', target: 'P0' })).toBe('밤에만 할 수 있어요.');
});
});
// ---------------------------------------------------------------- win (§6)
describe('승리 판정 (§6)', () => {
test('처형 직후 마피아 = 비마피아면 즉시 마피아 승리(밤으로 넘어가지 않음)', () => {
// 마피아 2, 시민 4 → 밤에 1명 → 2 대 3 → 시민 1명 처형 → 2 대 2
let s = rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']);
s = until(s, 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P5' });
s = tick(s);
expect(pl(s, 'P5').alive).toBe(false);
s = voteDay(s, { P0: 'P4', P1: 'P4', P2: 'P4' });
s = tick(s);
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
s = tick(s);
expect(s.winner).toBe('mafia');
expect(s.phase.kind).toBe('ended');
expect(lastEv(s, 'phase')!.kind).not.toBe('night');
const res = mafia.result(s)!;
expect(res.ranking).toEqual([['P0', 'P1'], ['P2', 'P3', 'P4', 'P5']]);
expect(res.scores).toMatchObject({ P0: 1, P1: 1, P2: 0 });
expect(res.summary).toBe('마피아 팀 승리! 2일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.');
expect(mafia.activePlayers(s)).toEqual([]);
expect(mafia.deadline!(s)).toBeNull();
});
test('4인: 첫 밤 시민 사망 → 계속, 다음 낮 오처형 → 마피아 승리; 마피아 처형 → 시민 승리', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen']), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(s.winner).toBeNull();
expect(lastEv(s, 'death')).toMatchObject({ p: 'P3', by: 'mafia', team: 'town' });
let a = voteDay(s, { P0: 'P1', P2: 'P1' });
a = tick(a);
a = act(a, 'P0', { type: 'confirm', choice: 'yes' });
a = tick(a);
expect(a.winner).toBe('mafia');
let b = voteDay(s, { P1: 'P0', P2: 'P0' });
b = tick(b);
b = act(b, 'P1', { type: 'confirm', choice: 'yes' });
b = act(b, 'P2', { type: 'confirm', choice: 'yes' });
b = tick(b);
expect(b.winner).toBe('town');
expect(b.final!.summary).toBe('시민 팀 승리! 2일차 낮 투표로 마지막 마피아를 찾아냈어요.');
expect(b.final!.mvp).toBe('P1');
expect(mafia.result(b)!.ranking).toEqual([['P1', 'P2', 'P3'], ['P0']]);
});
test('아침 발표 직후 판정', () => {
let s = until(rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = tick(s); // 2 vs 3
s = voteDay(s, {});
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
s = tick(s); // 2 vs 2
expect(s.winner).toBe('mafia');
expect(s.final!.summary).toBe('마피아 팀 승리! 3일차 아침, 마피아 수가 시민 수와 같아졌어요.');
});
});
// ---------------------------------------------------------------- chat (§5.6)
describe('채팅 권한 (§5.6)', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
const o = { extraRoles: { medium: true, lovers: true } };
test('밤에 시민 낮 채팅 거부, 죽은 사람 낮 채팅 거부, 레이트 리밋 초과 거부', () => {
let s = until(rig(roles, o), 'greeting');
const g0 = (s.phase as Extract<MafiaState['phase'], { kind: 'greeting' }>).startedAt;
s = say(s, 'P7', 'day', '안녕하세요');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, g0 + 200)).toBe('조금 천천히 보내 주세요.');
expect(mafia.validate(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, { now: g0 + 1 + 700 }).ok).toBe(true);
s = tick(s);
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '밤인데' })).toBe('밤에는 낮 채팅을 쓸 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'mafia', text: '...' })).toBe('마피아만 쓸 수 있어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'dead', text: '...' })).toBe('탈락한 사람만 쓸 수 있어요.');
s = say(s, 'P0', 'mafia', '누구 노릴까');
s = say(s, 'P5', 'lovers', '자기야');
s = say(s, 'P4', 'dead', '영매예요');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
s = tick(s);
expect(pl(s, 'P7').alive).toBe(false);
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '억울해' })).toBe('탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.');
s = say(s, 'P7', 'dead', '억울해요');
expect(reject(s, 'P0', { type: 'chat', channel: 'mafia', text: '낮' })).toBe('마피아 채팅은 밤에만 쓸 수 있어요.');
expect(reject(s, 'P4', { type: 'chat', channel: 'dead', text: '낮' })).toBe('영매는 밤에만 죽은 사람과 이야기할 수 있어요.');
expect(reject(s, 'P5', { type: 'chat', channel: 'lovers', text: '낮' })).toBe('연인 채팅은 밤에만 쓸 수 있어요.');
});
test('고운 말 필터·링크·전화번호·길이', () => {
const s = until(rig(roles, o), 'greeting');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '시 발' })).toBe('고운 말을 써 주세요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '여기 봐 https://a.b' })).toBe('링크는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: 'example.com 들어와' })).toBe('링크는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '010-1234-5678로 연락' })).toBe('전화번호 같은 개인 정보는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '가'.repeat(201) })).toBe('메시지는 200자까지 보낼 수 있어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: ' ' })).toBe('메시지를 입력해 주세요.');
const off = until(rig(roles, { ...o, kidSafeChat: false }), 'greeting');
expect(mafia.validate(off, 'P7', { type: 'chat', channel: 'day', text: 'example.com' }).ok).toBe(true);
expect(say(s, 'P7', 'day', '가'.repeat(200)).chat.at(-1)!.text).toHaveLength(200);
});
});
// ---------------------------------------------------------------- views (§8.3)
/** Independent re-statement of who may know whose role (§8.3). */
function mayKnowRole(s: MafiaState, owner: PlayerId, viewer: PlayerId | null): boolean {
const p = pl(s, owner);
if (s.winner || p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
if (!viewer) return false;
const v = pl(s, viewer);
if (viewer === owner) return true;
if (v.role === 'mafia' && p.role === 'mafia') return true;
if (v.role === 'lover' && p.role === 'lover') return true;
return !v.alive && s.options.deadSeeAll;
}
function mayRead(s: MafiaState, ch: Channel, viewer: PlayerId | null): boolean {
if (ch === 'day' || (s.winner && s.options.revealChatsAtEnd)) return true;
if (!viewer) return false;
const v = pl(s, viewer);
if (ch === 'mafia') return v.role === 'mafia' || (!v.alive && s.options.deadSeeAll);
if (ch === 'lovers') return v.role === 'lover';
return !v.alive || (v.role === 'medium' && s.phase.kind === 'night');
}
/** Structural + string leak check of one viewer's serialized view. */
function checkView(s: MafiaState, viewer: PlayerId | null): MafiaView {
const v = mafia.view(s, viewer);
const json = JSON.stringify(v);
for (const p of v.players) {
if (p.role !== null && !mayKnowRole(s, p.id, viewer)) throw new Error(`role of ${p.id} leaked to ${viewer}`);
if (p.role !== null && p.role !== pl(s, p.id).role) throw new Error('wrong role shown');
if (p.team !== null && p.role === null && !(!pl(s, p.id).alive && s.options.deathReveal === 'team')) throw new Error(`team of ${p.id} leaked to ${viewer}`);
}
for (const m of s.chat) {
const visible = v.chat.some((c) => c.key === `${m.channel}:${m.id}`);
if (!mayRead(s, m.channel, viewer)) {
if (visible || json.includes(JSON.stringify(m.text))) throw new Error(`${m.channel} message "${m.text}" leaked to ${viewer}`);
}
}
for (const r of s.privateResults) if (r.to !== viewer && json.includes(`"${r.id}"`)) throw new Error(`private result ${r.id} leaked to ${viewer}`);
if (v.my && viewer) {
const me = pl(s, viewer);
const n = v.my.night;
if (n && s.phase.kind === 'night') {
const ph = s.phase;
const own = me.role === 'mafia' ? ph.mafiaTarget : me.role === 'police' ? ph.investigate : me.role === 'doctor' ? ph.heal : me.role === 'medium' ? ph.mediumPeek : null;
if (n.choice !== own) throw new Error(`night choice of someone else shown to ${viewer}`);
if (me.role !== 'mafia' && n.pickedBy !== null) throw new Error('mafia picker leaked');
}
if (v.my.mates.some((m) => pl(s, m).role !== me.role)) throw new Error('wrong mates');
}
if (viewer === null && v.my !== null) throw new Error('spectator has my');
return v;
}
function keyShape(x: unknown): unknown {
if (Array.isArray(x)) return 'array';
if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, keyShape((x as Record<string, unknown>)[k])]));
return x === null ? 'null|value' : 'null|value';
}
describe('화면 정보와 누출 (§8.3)', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
const o = { extraRoles: { medium: true, lovers: true } };
function busyGame(extra: Record<string, unknown> = {}): MafiaState {
let s = until(rig(roles, { ...o, ...extra }), 'night');
s = say(s, 'P0', 'mafia', 'SECRET-MAFIA-1');
s = say(s, 'P5', 'lovers', 'SECRET-LOVE-1');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
s = act(s, 'P2', { type: 'investigate', target: 'P0' });
s = act(s, 'P3', { type: 'heal', target: 'P8' });
s = tick(s);
s = say(s, 'P7', 'dead', 'SECRET-DEAD-1');
return s;
}
test('생존 시민·관전자 view에 남의 직업, 비공개 채널, 밤 대상, 결과가 없다', () => {
const s = busyGame();
for (const viewer of [...s.players.map((p) => p.id), null]) checkView(s, viewer);
const citizen = JSON.stringify(mafia.view(s, 'P8'));
for (const secret of ['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1', s.privateResults[0]!.id]) expect(citizen).not.toContain(secret);
const spec = mafia.view(s, null);
expect(spec.players.every((p) => p.role === null)).toBe(true);
expect(spec.channels).toEqual([]);
expect(spec.chat.every((c) => c.channel === 'day')).toBe(true);
// 마피아는 마피아 채팅(언제든), 연인은 연인 채팅, 죽은 사람은 죽은 사람 채팅
expect(mafia.view(s, 'P1').chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
expect(mafia.view(s, 'P6').chat.map((c) => c.text)).toContain('SECRET-LOVE-1');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
expect(mafia.view(s, 'P2').my!.results).toHaveLength(1);
});
test('영매는 낮에는 dead 메시지를 받지 않고 밤에만 본다', () => {
let s = busyGame();
expect(s.phase.kind).toBe('discussion');
expect(JSON.stringify(mafia.view(s, 'P4'))).not.toContain('SECRET-DEAD-1');
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day']);
s = until(busy(s), 'night');
const v = mafia.view(s, 'P4');
expect(v.chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
expect(v.channels.find((c) => c.channel === 'dead')).toMatchObject({ canWrite: true });
s = say(s, 'P4', 'dead', '영매가 왔어요');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('영매가 왔어요');
expect(JSON.stringify(mafia.view(s, 'P8'))).not.toContain('영매가 왔어요');
});
test('deadSeeAll: 죽은 사람은 모든 직업과 마피아 채팅; 게임 끝나면 모두 공개', () => {
const s = busyGame({ deadSeeAll: true });
const v = checkView(s, 'P7');
expect(v.players.every((p) => p.role !== null)).toBe(true);
expect(v.chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
const end = structuredClone(s);
end.winner = 'town';
end.phase = { kind: 'ended' };
const sv = mafia.view(end, null);
expect(sv.players.every((p) => p.role !== null)).toBe(true);
expect(sv.chat.map((c) => c.text)).toEqual(expect.arrayContaining(['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1']));
end.options = { ...end.options, revealChatsAtEnd: false };
expect(mafia.view(end, null).chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
});
test('밤 화면 view 형태는 시민·경찰·의사가 같다(행동 정보는 my.night 값으로만)', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
const shape = (p: string) => JSON.stringify(keyShape(mafia.view(s, p)));
expect(shape('P1')).toBe(shape('P3'));
expect(shape('P2')).toBe(shape('P3'));
expect(mafia.view(s, 'P3').my!.night).toEqual({ action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null });
expect(mafia.view(s, 'P1').my!.night!.prompt).toBe('오늘 밤 할 일: 조사할 사람을 골라 주세요');
// 밤에 기다리는 사람 목록도 직업을 드러내지 않는다
expect(mafia.activePlayers(s)).toEqual(['P0', 'P1', 'P2', 'P3', 'P4']);
// 밤 행동은 이벤트로 나가지 않는다
const out = mafia.apply(s, 'P1', { type: 'investigate', target: 'P4' }, { rng, now: s.phase.kind === 'night' ? s.phase.startedAt + 5 : 0 });
expect(out.events).toEqual([]);
});
test('채널 탭: 읽을 수 있는 탭만, 쓸 수 없으면 이유', () => {
const s = until(rig(roles, o), 'night');
expect(mafia.view(s, 'P0').channels.map((c) => c.channel)).toEqual(['day', 'mafia']);
expect(mafia.view(s, 'P5').channels.map((c) => c.channel)).toEqual(['day', 'lovers']);
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day', 'dead']);
expect(mafia.view(s, 'P8').channels).toEqual([{ channel: 'day', canWrite: false, why: '밤에는 낮 채팅을 쓸 수 없어요.' }]);
});
});
// ---------------------------------------------------------------- time, away, leave (§5.2, §8.5)
describe('시간 조절·자리 비움·나가기 (§5.2, §8.5)', () => {
const day2 = () => until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
test('timeAdjust: 1인 1일 1회, -15초로 5초 미만 내려가지 않음', () => {
let s = day2();
const ph0 = s.phase as Extract<MafiaState['phase'], { kind: 'discussion' }>;
const end0 = ph0.endsAt;
s = act(s, 'P1', { type: 'adjustTime', delta: 15 });
expect((s.phase as typeof ph0).endsAt).toBe(end0 + 15_000);
expect(reject(s, 'P1', { type: 'adjustTime', delta: -15 })).toBe('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
expect(lastEv(s, 'adjust')).toMatchObject({ p: 'P1', delta: 15 });
// 끝나기 8초 전에 -15 → 5초 남김
s = act(s, 'P2', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 8_000);
const now = end0 + 15_000 - 8_000;
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
// 이미 5초 미만이면 그대로
s = act(s, 'P3', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 3_000);
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
expect(mafia.view(s, null).phase.adjustedBy).toEqual(['P1', 'P2', 'P3']);
// 다음 날에는 다시 가능
s = until(until(busy(s), 'night'), 'discussion');
expect(mafia.validate(s, 'P1', { type: 'adjustTime', delta: 15 }).ok).toBe(true);
const off = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen'], { timeAdjust: false }), 'night')), 'discussion');
expect(reject(off, 'P1', { type: 'adjustTime', delta: 15 })).toBe('이 방은 토론 시간을 바꿀 수 없어요.');
});
test('토론 끝내기: 생존자 전원이 누르면 즉시 지목 투표', () => {
let s = day2();
for (const p of ['P0', 'P1', 'P2', 'P3']) s = act(s, p, { type: 'readyEarly' });
expect(s.phase.kind).toBe('discussion');
expect(mafia.view(s, 'P4').phase).toMatchObject({ readyCount: 4, readyNeeded: 5 });
expect(reject(s, 'P0', { type: 'readyEarly' })).toBe('이미 토론 끝내기를 눌렀어요.');
s = act(s, 'P4', { type: 'readyEarly' });
expect(s.phase.kind).toBe('vote');
});
test('2번의 낮 동안 아무것도 안 하면 자리 비움 탈락, 그 결과로 승리 조건이면 종료', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
const keep = (x: MafiaState) => {
for (const p of x.players) if (p.id !== 'P0') p.activeToday = true;
return x;
};
s = until(keep(tick(s)), 'night'); // 2일차: P0 idle 1
expect(pl(s, 'P0').idleDays).toBe(1);
expect(mafia.view(tick(s), 'P0').my!.idleWarning).toBe(true);
s = until(keep(tick(s)), 'ended'); // 3일차 끝: P0(마피아) 자리 비움 → 시민 승리
expect(pl(s, 'P0')).toMatchObject({ alive: false, diedBy: 'disconnect', idleDays: AWAY_DAYS });
expect(s.winner).toBe('town');
expect(s.final!.cause).toBe('disconnect');
// 한 번이라도 행동하면 다시 0
let t = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
t = until(keep(tick(t)), 'vote');
t = act(t, 'P0', { type: 'vote', target: 'abstain' });
t = until(keep(t), 'night');
expect(pl(t, 'P0').idleDays).toBe(0);
});
test('나가기: 즉시 탈락(공개 규칙 적용), 피고가 나가면 밤으로, 승리 판정', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'], { deathReveal: 'role' }), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = act(s, 'P4', { type: 'leave' });
expect(lastEv(s, 'death')).toMatchObject({ p: 'P4', by: 'disconnect', role: 'citizen' });
expect(s.phase.kind === 'night' && s.phase.mafiaTarget).toBeNull();
s = voteDay(tick(s), { P1: 'P5' });
expect(s.phase.kind).toBe('defense');
s = act(s, 'P5', { type: 'leave' });
expect(s.phase.kind).toBe('night');
expect(mafia.onLeave!(s, 'P5')).toBeNull();
expect(mafia.onLeave!(s, 'P0')).toEqual({ type: 'leave' });
s = act(s, 'P0', { type: 'leave' });
expect(s.winner).toBe('town');
expect(mafia.onLeave!(s, 'P1')).toBeNull();
});
test('phaseTimeout은 시스템 전용이고 마감 전에는 거부', () => {
const s = rig(['mafia', 'police', 'doctor', 'citizen']);
expect(mafia.actionSchema.safeParse({ type: 'phaseTimeout' }).success).toBe(false);
expect(mafia.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(reject(s, 'P0', { type: 'phaseTimeout' }, T0 + 100)).toBe('아직 시간이 남았어요.');
expect(mafia.validate(s, 'P0', { type: 'phaseTimeout' }, { now: T0 + ROLE_REVEAL_MS }).ok).toBe(true);
expect(mafia.timeoutPlayers!(s)).toEqual(['P0']);
expect(mafia.timeoutNotBefore!(s)).toBe(T0 + ROLE_REVEAL_MS);
expect(mafia.deadline!(s)).toBe(T0 + ROLE_REVEAL_MS);
expect(reject(s, 'X', { type: 'readyEarly' })).toBe('이 게임의 플레이어가 아니에요.');
});
});
describe('스키마', () => {
test('옵션에 한국어 메타, 기본값', () => {
const shape = (mafia.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
const extra = (shape.extraRoles as unknown as { unwrap(): { shape: Record<string, { meta(): { title?: string } | undefined }> } }).unwrap().shape;
for (const [k, v] of Object.entries(extra)) expect(v.meta()?.title, k).toBeTruthy();
expect(mafia.defaultOptions).toMatchObject({ mafiaCount: 'auto', firstPhase: 'day', discussionPerAlive: 15, discussionMin: 60, discussionMax: 180, voteSeconds: 20, defenseSeconds: 20, confirmSeconds: 10, nightSeconds: 30, deathReveal: 'team', openVote: true, kidSafeChat: true });
expect(mafia.defaultOptions.extraRoles).toEqual({ soldier: false, politician: false, medium: false, lovers: false });
expect(mafia.ownChat).toBe(true);
expect(mafia.actionSchema.safeParse({ type: 'chat', channel: 'system', text: 'x' }).success).toBe(false);
});
});
// ---------------------------------------------------------------- properties
const PRESETS: Record<string, unknown>[] = [
{},
{ extraRoles: { soldier: true, medium: true }, deathReveal: 'role', mafiaMustKill: 'random' },
{ extraRoles: { lovers: true, politician: true }, openVote: false, firstPhase: 'night', deadSeeAll: true },
{ deathReveal: 'none', doctorSelfHeal: false, doctorRepeat: false, policeRevealRole: true, timeAdjust: false },
{ extraRoles: { soldier: true, politician: true, medium: true, lovers: true }, mafiaMustKill: 'random', revealChatsAtEnd: false },
];
/** Fits the preset's extra roles to the player count (drops the ones that do not fit). */
function presetFor(i: number, n: number): MafiaOptions {
const o = opts(PRESETS[i % PRESETS.length]!);
for (const r of ['lovers', 'politician', 'medium', 'soldier'] as const) if (compositionError(o, n)) o.extraRoles = { ...o.extraRoles, [r]: false };
return o;
}
function invariants(s: MafiaState, initialRoles: string): void {
if (s.players.map((p) => p.role).sort().join() !== initialRoles) throw new Error('roles changed');
for (const p of s.players) {
if (p.alive !== (p.diedBy === null) || p.alive !== (p.diedDay === null)) throw new Error(`${p.id} death fields out of sync`);
if (p.soldierUsed && p.role !== 'soldier') throw new Error('soldierUsed on non-soldier');
}
const living = s.players.filter((p) => p.alive);
const m = living.filter((p) => p.role === 'mafia').length;
if (!s.winner) {
if (m === 0 || m >= living.length - m) throw new Error('win condition met but game goes on');
if (s.phase.kind === 'ended') throw new Error('ended without winner');
if (mafia.activePlayers(s).length === 0) throw new Error('nobody to act');
} else {
if (s.phase.kind !== 'ended' || !s.final) throw new Error('winner without end');
if ((s.winner === 'town') !== (m === 0)) throw new Error('wrong winner');
}
for (const ch of CHANNELS) {
const msgs = s.chat.filter((c) => c.channel === ch);
if (msgs.some((c, i) => i > 0 && c.id !== msgs[i - 1]!.id + 1)) throw new Error('chat ids not consecutive');
if (msgs.length && msgs.at(-1)!.id !== s.chatCount[ch]) throw new Error('chat count out of sync');
}
if (s.events.some((e, i) => e.n !== i + 1)) throw new Error('event numbers out of order');
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: MafiaState;
}
/** Random play with cheaper scheduled checks; dead players chat too and players sometimes leave. */
function fastPlayout(players: PlayerId[], options: MafiaOptions, 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 = mafia.setup({ players, options, rng: r, now });
const roles = s.players.map((p) => p.role).sort().join();
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s, roles);
if (step % 7 === 0) for (const v of [null, players[(step / 7) % players.length]!]) checkView(s, v);
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of mafia.timeoutPlayers!(s)) {
const v = mafia.validate(s, p, mafia.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected: ${v.reason}`);
}
if (mafia.result(s)) {
for (const v of [...players, null]) checkView(s, v);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const pick = chooser.int(10);
let actor: PlayerId;
let action: MafiaAction;
if (pick === 0 && chooser.int(30) === 0) {
const living = s.players.filter((p) => p.alive);
actor = living[chooser.int(living.length)]!.id;
action = { type: 'leave' };
} else {
const pool = pick < 2 ? players : mafia.activePlayers(s);
actor = pool[chooser.int(pool.length)]!;
const legal = mafia.legalActions!(s, actor);
action = legal.length ? legal[chooser.int(legal.length)]! : mafia.onTimeout(s, actor);
}
const v = mafia.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const out = mafia.apply(s, actor, action, { rng: r, now });
// Events are broadcast to everyone: never chat text, night choices or private results.
if (out.events.length) {
const ej = JSON.stringify(out.events);
for (const m of out.state.chat) if (m.channel !== 'day' && ej.includes(JSON.stringify(m.text))) throw new Error('event leaks chat');
for (const pr of out.state.privateResults) if (ej.includes(pr.id)) throw new Error('event leaks a private result');
}
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
test('1,000 games finish; roles conserved, no leaks, onTimeout always legal, deterministic', () => {
const wins = { town: 0, mafia: 0 };
const causes: Record<string, number> = {};
for (let i = 1; i <= 1000; i++) {
const n = 4 + (i % 9);
const players = ids(n);
const options = presetFor(i, n);
const a = fastPlayout(players, options, seedN(i), 4000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`);
wins[a.final.winner!]++;
causes[a.final.final!.cause] = (causes[a.final.final!.cause] ?? 0) + 1;
const res = mafia.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 4000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(wins.town).toBeGreaterThan(50);
expect(wins.mafia).toBeGreaterThan(50);
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
}, 60_000);
test.each([4, 6, 9, 12])('randomPlayout with the engine per-step leak / round-trip checks: %i players', (n) => {
const players = ids(n);
for (let i = 1; i <= 3; i++) {
const options = presetFor(i + n, n);
const roles = (st: MafiaState) => st.players.map((p) => p.role).sort().join();
let initial: string | null = null;
const a = randomPlayout(mafia, {
players,
options,
seed: seedN(3000 + i * n),
maxSteps: 5000,
maxApplyMs: 30,
invariant: (st) => invariants(st as MafiaState, (initial ??= roles(st as MafiaState))),
});
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(mafia, { players, options, seed: seedN(3000 + i * n), maxSteps: 5000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
test('onTimeout은 모든 상태에서 합법 행동', () => {
for (let seed = 1; seed <= 30; seed++) {
const n = 4 + (seed % 9);
let s = mafia.setup({ players: ids(n), options: presetFor(seed, n), rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
const r = SeededRng.fromSeed(seedN(seed + 500));
while (!s.winner) {
for (const p of [...s.players.map((x) => x.id)]) expect(mafia.validate(s, p, mafia.onTimeout(s, p)).ok).toBe(true);
const ph = s.phase as Exclude<MafiaState['phase'], { kind: 'ended' }>;
expect(mafia.validate(s, 'P0', mafia.onTimeout(s, 'P0'), { now: ph.endsAt }).ok).toBe(true);
s = mafia.apply(s, 'P0', mafia.onTimeout(s, 'P0'), { rng: r, now: ph.endsAt }).state;
}
// Only timeouts: nobody acts, so away eliminations end the game.
expect(s.final!.cause === 'disconnect' || s.final!.cause === 'night').toBe(true);
}
});
test('같은 시드 + 같은 행동(채팅 포함) 기록을 다시 적용하면 같은 최종 상태', () => {
const players = ids(8);
const options = presetFor(2, 8);
const rep = randomPlayout(mafia, { players, options, seed: seedN(77), maxSteps: 5000, maxApplyMs: 30 });
expect(rep.trace.some((t) => (t.action as MafiaAction).type === 'chat')).toBe(true);
const replayRng = SeededRng.fromSeed(seedN(77));
let now = 1_700_000_000_000;
let s = mafia.setup({ players, options, rng: replayRng, now });
for (const { actor, action } of rep.trace) s = mafia.apply(s, actor, action as MafiaAction, { rng: replayRng, now: (now += 1000) }).state;
expect(JSON.stringify(s)).toBe(JSON.stringify(rep.finalState));
});
test('팀 판정 도우미', () => {
expect(teamOf('mafia')).toBe('mafia');
for (const r of ['police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as Role[]) expect(teamOf(r)).toBe('town');
});
});

View File

@@ -0,0 +1,287 @@
/** 마피아 options, actions, state and view types (docs/games/mafia.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
const secLabels = (...vals: number[]) => Object.fromEntries(vals.map((v) => [v, `${v}초`]));
export const ROLES = ['mafia', 'police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as const;
export type Role = (typeof ROLES)[number];
export type Team = 'town' | 'mafia';
export const EXTRA_ROLES = ['soldier', 'politician', 'medium', 'lovers'] as const;
export type ExtraRole = (typeof EXTRA_ROLES)[number];
export const CHANNELS = ['day', 'mafia', 'dead', 'lovers'] as const;
/** Channels players write in; `system` lines are the public announcements (`events`). */
export type Channel = (typeof CHANNELS)[number];
export const mafiaOptionsSchema = z.object({
mafiaCount: z
.union([z.literal('auto'), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
.default('auto')
.meta({ title: '마피아 수', labels: { auto: '자동(인원에 맞게)', 1: '1명', 2: '2명', 3: '3명', 4: '4명' } }),
extraRoles: z
.object({
soldier: z.boolean().default(false).meta({ title: '군인(마피아 공격을 한 번 버텨요)' }),
politician: z.boolean().default(false).meta({ title: '정치인(표가 2표, 투표로 탈락하지 않아요)' }),
medium: z.boolean().default(false).meta({ title: '영매(밤에 죽은 사람과 대화해요)' }),
lovers: z.boolean().default(false).meta({ title: '연인(2명, 밤에 둘만 대화해요)' }),
})
.default({ soldier: false, politician: false, medium: false, lovers: false })
.meta({ title: '추가 직업(시민 자리를 대신해요)' }),
firstPhase: z
.enum(['day', 'night'])
.default('day')
.meta({ title: '시작 페이즈', labels: { day: '인사하는 낮부터(투표 없음)', night: '밤부터' } }),
discussionPerAlive: lit(10, 15, 20)
.default(15)
.meta({ title: '토론 시간(살아 있는 사람 1명당)', labels: secLabels(10, 15, 20) }),
discussionMin: lit(30, 45, 60, 90, 120)
.default(60)
.meta({ title: '토론 시간 최소', labels: secLabels(30, 45, 60, 90, 120) }),
discussionMax: lit(60, 120, 180, 240, 300)
.default(180)
.meta({ title: '토론 시간 최대', labels: secLabels(60, 120, 180, 240, 300) }),
timeAdjust: z.boolean().default(true).meta({ title: '하루 한 번 토론 시간 늘리기·줄이기(±15초)' }),
voteSeconds: lit(10, 15, 20, 30, 45, 60)
.default(20)
.meta({ title: '지목 투표 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
defenseSeconds: lit(10, 15, 20, 30, 45, 60)
.default(20)
.meta({ title: '최후의 반론 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
confirmSeconds: lit(5, 10, 15, 20, 30)
.default(10)
.meta({ title: '찬반 투표 시간', labels: secLabels(5, 10, 15, 20, 30) }),
nightSeconds: lit(15, 20, 30, 45, 60, 90)
.default(30)
.meta({ title: '밤 시간', labels: secLabels(15, 20, 30, 45, 60, 90) }),
deathReveal: z
.enum(['none', 'team', 'role'])
.default('team')
.meta({ title: '탈락한 사람 공개', labels: { none: '공개 안 함', team: '마피아인지만', role: '직업까지' } }),
openVote: z.boolean().default(true).meta({ title: '지목 투표에서 누가 누구를 골랐는지 공개' }),
doctorSelfHeal: z.boolean().default(true).meta({ title: '의사가 자기 자신 지키기' }),
doctorRepeat: z.boolean().default(true).meta({ title: '의사가 같은 사람을 이틀 연속 지키기' }),
mafiaMustKill: z
.enum(['noKill', 'random'])
.default('noKill')
.meta({ title: '마피아가 아무도 안 골랐을 때', labels: { noKill: '아무도 탈락하지 않음', random: '무작위로 1명 탈락' } }),
policeRevealRole: z.boolean().default(false).meta({ title: '(하우스 룰) 경찰이 마피아 여부 대신 직업을 알아요' }),
deadSeeAll: z.boolean().default(false).meta({ title: '탈락한 사람이 모든 직업과 마피아 채팅 보기' }),
kidSafeChat: z.boolean().default(true).meta({ title: '고운 말 필터(욕설·링크·전화번호 막기)' }),
revealChatsAtEnd: z.boolean().default(true).meta({ title: '게임이 끝나면 모든 채팅 공개' }),
});
export type MafiaOptions = z.infer<typeof mafiaOptionsSchema>;
const pid = z.string().min(1).max(64);
export const mafiaActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chat'), channel: z.enum(CHANNELS), text: z.string().max(400) }),
z.object({ type: z.literal('adjustTime'), delta: z.union([z.literal(15), z.literal(-15)]) }),
z.object({ type: z.literal('readyEarly') }),
z.object({ type: z.literal('vote'), target: pid }),
z.object({ type: z.literal('confirm'), choice: z.enum(['yes', 'no']) }),
z.object({ type: z.literal('mafiaTarget'), target: pid.nullable() }),
z.object({ type: z.literal('investigate'), target: pid }),
z.object({ type: z.literal('heal'), target: pid }),
z.object({ type: z.literal('mediumPeek'), target: pid }),
]);
export type MafiaClientAction = z.infer<typeof mafiaActionSchema>;
/** Client actions plus the system-only ones (phase timer, leaving) that are not in the schema. */
export type MafiaAction = MafiaClientAction | { type: 'phaseTimeout' } | { type: 'leave' };
export type NightActionType = 'mafiaTarget' | 'investigate' | 'heal' | 'mediumPeek';
// ---------------------------------------------------------------- state
export type DiedBy = 'vote' | 'mafia' | 'disconnect';
export interface PlayerState {
id: PlayerId;
role: Role;
alive: boolean;
soldierUsed: boolean;
/** Soldier who survived an attack / politician saved from a vote: role is public. */
publicRole: boolean;
diedDay: number | null;
diedBy: DiedBy | null;
/** Did anything since the last day ended (away detection, §8.5). */
activeToday: boolean;
/** Voting days in a row without any action. */
idleDays: number;
}
export type Phase =
| { kind: 'roleReveal'; startedAt: number; endsAt: number }
| { kind: 'greeting'; startedAt: number; endsAt: number; ready: PlayerId[] }
| { kind: 'discussion'; startedAt: number; endsAt: number; adjustedBy: PlayerId[]; ready: PlayerId[] }
| { kind: 'vote'; startedAt: number; endsAt: number; votes: Record<PlayerId, PlayerId | 'abstain'> }
| { kind: 'defense'; startedAt: number; endsAt: number; accused: PlayerId }
| { kind: 'confirm'; startedAt: number; endsAt: number; accused: PlayerId; ballots: Record<PlayerId, 'yes' | 'no'> }
| {
kind: 'night';
startedAt: number;
endsAt: number;
mafiaTarget: PlayerId | null;
mafiaPickedBy: PlayerId | null;
investigate: PlayerId | null;
heal: PlayerId | null;
mediumPeek: PlayerId | null;
}
| { kind: 'ended' };
export type PhaseKind = Phase['kind'];
export interface ChatMessage {
/** Running number inside its channel (a global counter would let others count hidden messages). */
id: number;
channel: Channel;
from: PlayerId;
text: string;
at: number;
day: number;
}
/** Public announcement (the `system` channel). */
export type PublicEvent = { n: number; at: number; day: number } & (
| { t: 'phase'; kind: Exclude<PhaseKind, 'ended'> }
| { t: 'adjust'; p: PlayerId; delta: 15 | -15 }
| { t: 'vote'; tally: Record<PlayerId, number>; abstain: number; votes: Record<PlayerId, PlayerId | 'abstain'> | null; accused: PlayerId | null; tie: boolean }
| { t: 'confirm'; accused: PlayerId; yes: number; no: number; outcome: 'executed' | 'spared' | 'politician' }
| { t: 'death'; p: PlayerId; by: DiedBy; team: Team | null; role: Role | null }
| { t: 'healed' }
| { t: 'soldier'; p: PlayerId }
| { t: 'quiet' }
| { t: 'end'; winner: Team }
);
export type PublicEventKind = PublicEvent['t'];
export interface PrivateResult {
/** Unique token (also the leak-test secret). */
id: string;
to: PlayerId;
day: number;
kind: 'investigate' | 'peek';
target: PlayerId;
/** Police: 'mafia' / 'notMafia' (or the role with `policeRevealRole`); medium: the role. */
result: Role | 'notMafia';
}
export type WinCause = 'vote' | 'night' | 'disconnect';
export interface MafiaFinal {
winner: Team;
days: number;
cause: WinCause;
players: { id: PlayerId; role: Role; team: Team; alive: boolean; diedDay: number | null; diedBy: DiedBy | null; won: boolean }[];
/** Winning team (alive first) → losing team (alive first). */
ranking: PlayerId[];
/** Town player whose day votes sent the most mafia out (optional MVP, §6). */
mvp: PlayerId | null;
summary: string;
}
export interface MafiaState {
options: MafiaOptions;
players: PlayerState[];
day: number;
phase: Phase;
lastHealTarget: PlayerId | null;
privateResults: PrivateResult[];
/** Every message of every channel; the view filters by channel permission. */
chat: ChatMessage[];
chatCount: Record<Channel, number>;
lastChatAt: Record<PlayerId, number>;
events: PublicEvent[];
/** Voters (vote phase) for the current accused — for the MVP count. */
trialVoters: PlayerId[];
mvpVotes: Record<PlayerId, number>;
winner: Team | null;
/** Applied actions. */
seq: number;
final: MafiaFinal | null;
}
// ---------------------------------------------------------------- view
export interface PlayerView {
id: PlayerId;
alive: boolean;
diedDay: number | null;
diedBy: DiedBy | null;
/** Only when the viewer may know it (own role, team mates, public reveals, after the game). */
role: Role | null;
team: Team | null;
}
export interface ChannelView {
channel: Channel;
/** Writable right now. */
canWrite: boolean;
/** Korean reason when not writable. */
why: string | null;
}
export interface ChatView {
key: string;
channel: Channel;
from: PlayerId;
text: string;
at: number;
day: number;
}
export interface NightView {
/** null = no night action (same fields for every role, so the screen does not give the role away). */
action: NightActionType | null;
prompt: string;
targets: PlayerId[];
choice: PlayerId | null;
/** Mafia: who picked the shared target. */
pickedBy: PlayerId | null;
}
export interface MyView {
role: Role;
team: Team;
alive: boolean;
/** Mafia: the other mafia; lovers: the partner. */
mates: PlayerId[];
vote: PlayerId | 'abstain' | null;
voteTargets: PlayerId[];
ballot: 'yes' | 'no' | null;
canConfirm: boolean;
canAdjust: boolean;
canReady: boolean;
ready: boolean;
/** Alive at night: always an object with the same keys. */
night: NightView | null;
results: PrivateResult[];
/** One more idle voting day eliminates me (§8.5). */
idleWarning: boolean;
}
export interface MafiaView {
me: PlayerId | null;
options: MafiaOptions;
day: number;
phase: {
kind: PhaseKind;
startedAt: number;
endsAt: number | null;
accused: PlayerId | null;
/** Discussion: who used ±15초 today (public). */
adjustedBy: PlayerId[];
readyCount: number;
readyNeeded: number;
};
players: PlayerView[];
/** Starting role counts (public composition). */
setup: Partial<Record<Role, number>>;
aliveCount: number;
events: PublicEvent[];
chat: ChatView[];
/** Tabs the viewer may read. */
channels: ChannelView[];
my: MyView | null;
winner: Team | null;
final: MafiaFinal | null;
}

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,15 @@ export const CATALOG: CatalogEntry[] = [
{ 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: '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 },
{ id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true },
{ id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, minutes: '10~20', level: '쉬움', blurb: '한 사람이 그림을 그리면 나머지가 채팅으로 정답을 맞혀요!', available: true },
{ id: 'mafia', nameKo: '마피아', minPlayers: 4, maxPlayers: 12, minutes: '10~25', level: '보통', blurb: '낮에는 토론하고 밤에는 몰래! 숨은 마피아를 찾아요 · 채팅 추리', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);

View File

@@ -75,6 +75,8 @@ export type ServerMessage =
deadline: number | null;
cs?: number;
}
/** Events of streaming actions (e.g. drawing), relayed without a full view (docs/games/catchmind.md §5.5). */
| { t: 'stream'; seq: number; events: unknown[]; cs?: number }
| { t: 'reject'; cs: number; reason: string }
| { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string }
| { t: 'emote'; from: string; id: string }