docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)

- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:18:29 +09:00
parent b4a3c51201
commit f4d63e1544
43 changed files with 8948 additions and 0 deletions

43
docs/00-overview.md Normal file
View File

@@ -0,0 +1,43 @@
# 00. 프로젝트 개요
## 1. 한 줄 정의
광고 없이 가볍고 빠른, 한국 사람에게 익숙한 보드게임을 친구·가족과 바로 즐길 수 있는 온라인 보드게임 사이트.
## 2. 만드는 이유
- Board Game Arena(BGA)는 게임 수가 많고 완성도가 높지만, 한국에서 많이 하는 게임(섯다, 7포커, 고스톱, 장기, 윷놀이, 원카드 등)이 없거나 찾기 어렵다.
- 광고·배너·부가 기능이 많아 페이지가 무겁고, 처음 쓰는 사람이 방을 만들고 친구를 부르기까지 단계가 많다.
- 그래서 "링크 하나 보내면 바로 같이 하는" 단순하고 가벼운 사이트를 직접 만든다.
## 3. 최우선 원칙 (충돌 시 위에 있는 것이 이긴다)
1. **안정성** — 게임 도중 끊겨도 진행 상태가 사라지지 않는다. 서버가 재시작해도 방과 게임이 복구된다. 잘못된 수, 조작된 요청, 숨겨진 정보 유출이 구조적으로 불가능하다.
2. **실시간성** — 수를 두면 같은 방의 모든 사람 화면에 체감 지연 없이(목표 p95 150ms 이내, 같은 국내 망 기준) 반영된다.
3. **쉬움** — 어린아이부터 어르신까지 설명 없이 방을 만들고 들어갈 수 있다. 첫 화면에서 게임 시작까지 클릭 3번 이내.
4. **가벼움** — 첫 화면 JS 150KB(gzip) 이하, 광고·추적 스크립트 없음. 게임 화면 코드는 게임별로 나눠서 필요할 때만 받는다.
5. **게임 수** — 위 네 가지를 지키는 범위에서 게임을 늘린다.
## 4. 범위 (Scope)
- 게임: 사용자가 지정한 포커(여러 모드), 마작, 오목, 바둑, 체스, 뱅, 섯다(여러 모드) + 한국에서 인기 있는 보드게임. 전체 목록과 순서는 `games/README.md`.
- 인원: 각 게임의 원래 최소/최대 인원을 그대로 따른다.
- 계정: 디스코드 로그인 또는 닉네임만 정하는 게스트. 디스코드 로그인 사용자도 닉네임을 바꿀 수 있다.
- 방: 6자리 숫자 코드, 초대 링크, 코드/링크 복사, QR, 모바일 공유 시트, 빠른 시작, 관전, 재접속.
- 기기: 모바일(세로) 우선, 태블릿·PC 모두 지원. 최신 Chrome/Safari/Edge/Samsung Internet.
## 5. 범위 밖 (Non-goals)
- 실제 돈, 유료 재화, 환전, 사이트 전체에서 유지되는 칩/포인트. 포커·섯다·고스톱의 칩/점수는 **그 방 안에서만** 쓰이고 방이 끝나면 사라진다(법적 위험 회피, `04-stability-security.md` 6절).
- 광고, 결제, 친구 추천 알림, 소셜 피드.
- 네이티브 앱(초기에는 웹 + 홈 화면 추가(PWA)로 대체).
- 물리 조작이 핵심인 게임(젠가, 루핑루이, 할리갈리 컵스 등)은 제외. 이유는 `games/README.md`.
## 6. 성공 기준 (측정 가능한 목표)
| 항목 | 목표 |
|---|---|
| 수 반영 지연 | 서버 처리 p95 < 20ms, 체감 왕복 p95 < 150ms(국내) |
| 재접속 복구 | 연결이 끊긴 뒤 30초 안에 돌아오면 자리와 패가 그대로 |
| 서버 재시작 | 진행 중이던 모든 방이 재시작 후 자동 복구, 클라이언트는 자동 재연결 |
| 동시 접속 | 단일 서버(4코어/8GB)에서 동시 연결 2,000, 진행 중인 방 500개를 p95 지연 목표 안에서 처리 |
| 첫 화면 | 4G 모바일에서 LCP < 2초, 초기 JS < 150KB gzip |
| 쉬움 | 처음 온 사람이 링크를 받고 게임 자리에 앉기까지: 닉네임 입력 1번 + 버튼 1번 |
| 규칙 정확성 | 게임마다 규칙 테스트 + 무작위 대국 수천 판 불변식 테스트 통과 |
## 7. 문서 구성
`README.md`의 목차를 참고. 구현은 반드시 이 문서들을 따르고, 구현 중 결정이 바뀌면 문서를 먼저 고친 뒤 코드를 고친다.

67
docs/01-research.md Normal file
View File

@@ -0,0 +1,67 @@
# 01. 조사 결과
## 1. Board Game Arena(BGA) 분석
### 1.1 가져올 점
- **테이블(방) 개념**: 게임을 고르고 "테이블"을 만들면 인원·옵션을 정하고, 친구만 초대하거나 모르는 사람에게 열어 둘 수 있다. 링크로 초대할 수 있고 접근을 친구/그룹으로 제한할 수 있다.
- **방장이 옵션을 먼저 정하고 나중에 공개**하는 흐름(수동 모드).
- **실시간 / 턴제(비동기)** 두 가지 속도. 우리는 우선 실시간만 하고, 턴제는 로드맵 후순위로 둔다.
- **규칙은 서버가 강제**: 불가능한 수는 아예 둘 수 없고 둘 수 있는 곳만 표시된다. 초보도 규칙을 몰라도 시작할 수 있다.
- **관전, 리플레이, 게임 기록**, 게임별 튜토리얼.
- **시간 초과 처리**: 시간이 다 된 플레이어는 강퇴되거나 자동 진행된다.
### 1.2 버릴 점 (사용자가 불편하다고 한 부분)
- 광고, 프리미엄 유도 배너, 뉴스 피드, 포럼, 트로피 알림 등 게임과 무관한 요소가 첫 화면과 게임 화면을 차지한다.
- 무거운 프레임워크(Dojo 기반)와 많은 외부 스크립트 → 저사양 폰에서 느림.
- 테이블 만들기 단계가 많고(수동/자동/친구 모드, 옵션 페이지, "테이블 열기" 버튼 등) 용어가 어렵다.
- 한국 게임이 거의 없고, 한국어 번역 품질이 고르지 않다.
### 1.3 우리 설계에 반영
| BGA | 우리 사이트 |
|---|---|
| 로비 → 게임 선택 → 테이블 생성 → 옵션 → 열기 → 초대 | 홈에서 게임 카드 누르기 → "방 만들기" → 바로 대기실(코드·링크 즉시 표시). 옵션은 대기실에서 바꿀 수 있고 기본값이 이미 들어 있음 |
| 계정 필수 | 게스트는 닉네임만 입력 |
| 초대: 친구 목록/링크 | 6자리 숫자 코드 + 링크 복사 + QR + 모바일 공유 시트 |
| 광고/배너 | 없음 |
## 2. 한국에서 인기 있는 보드게임
### 2.1 근거 자료
- 코리아보드게임즈 2025 상반기 판매 TOP 20: 1 할리갈리, 2 우노/원카드, 3 루미큐브, 4 커피 러시, 5 도블, 6 다빈치 코드, 7 우봉고, 8 루핑루이, 9 모두의 마블, 10 달무티, 11 스플렌더, 12 구십구, 13 햄버거 타이쿤, 14 할리갈리 컵스, 15 라쿠카라차, 16 뱅, 17 쿼리도, 18 김밥 요리사, 19 텔레스트레이션, 20 라스베가스.
- 히어로 보드게임 카페(전국 57개점) 2025 규칙 영상 재생 순위: 1 다빈치 코드, 2 루미큐브, 3 바퀴벌레 포커, 4 스플렌더, 5 갈팡질팡, 6 우노, 7 요트 다이스, 8 뱅, 9 캣츠 런, 10 5초 준다.
- 한국 전통·온라인 국민 게임: 바둑, 장기, 오목, 윷놀이, 고스톱/맞고, 섯다, 7포커, 바둑이, 원카드, 마피아, 캐치마인드(그림 맞히기).
### 2.2 선정 결과
온라인으로 옮겼을 때 재미가 유지되고, 규칙을 서버가 판정할 수 있는 게임만 고른다. 최종 목록과 단계는 `games/README.md`.
## 3. UI/UX 조사
### 3.1 어르신·어린이 친화 설계 원칙 (조사 요약)
- 기본 글자 16px 이상, 얇은 글꼴 금지, 줄 간격 넉넉하게. 사용자가 글자 크기를 키울 수 있게 한다(브라우저 확대 + 사이트 자체 "글자 크게" 설정).
- 터치 영역 최소 48×48px(애플/구글 권장 44px보다 크게), 버튼 사이 간격 8px 이상.
- 명암비: 본문 4.5:1 이상, 큰 글자·버튼 3:1 이상(WCAG 2.2).
- 쉬운 말, 한 화면에 할 일 하나, 중요한 버튼은 화면 가운데/아래 엄지 닿는 곳.
- 아이콘만 쓰지 않고 항상 글자를 같이 쓴다("복사" 아이콘 + "코드 복사" 글자).
- 실수해도 되돌릴 수 있게(나가기 전 확인, 무르기 요청), 지금 무엇을 해야 하는지 항상 화면 위에 문장으로 표시("내 차례예요. 놓을 곳을 눌러 주세요").
### 3.2 파티 게임 입장 UX 사례
- Kahoot, Jackbox, skribbl.io, Gartic Phone처럼 **숫자/짧은 코드 + 닉네임 한 칸**으로 바로 들어가는 방식이 어린이·어르신에게 가장 쉽다.
- 링크로 들어온 사람은 코드 입력 없이 닉네임만 정하면 바로 자리에 앉는다.
- 한국 사용자는 카카오톡으로 링크를 많이 공유하므로 모바일에서 `navigator.share`(공유 시트)를 쓰고, 미리보기(Open Graph) 제목에 "OO님이 오목 방에 초대했어요 · 코드 123456"을 넣는다.
## 4. 기술 조사 요약
- WebSocket은 모든 대상 브라우저에서 지원. Bun 내장 WebSocket 서버는 방 단위 pub/sub를 기본 제공하고 처리량이 높다.
- 숨겨진 정보가 있는 게임은 반드시 서버가 판정하고, 클라이언트에는 "그 사람이 볼 수 있는 화면"만 보낸다(카드 게임 치팅의 대부분은 클라이언트에 전체 상태를 보내서 생긴다).
- 실시간 게임에서 연결 끊김은 흔하다(모바일 화면 꺼짐, 지하철, 와이파이↔LTE 전환). 자리 유지 + 자동 재연결 + 전체 상태 재전송이 필수.
## 5. 출처
- https://boardgamearena.com/
- https://forum.boardgamearena.com/viewtopic.php?t=28240
- https://en.boardgamearena.com/faq
- https://www.koreaboardgames.com/magazine/menuDetail?boardCd=contents&postNo=1346
- https://www.koreaboardgames.com/magazine/menuDetail?boardCd=contents&postNo=1437
- https://boardlife.co.kr/rank
- https://www.toptal.com/designers/ui/ui-design-for-older-adults
- https://www.aufaitux.com/blog/designing-elder-friendly-ui-interfaces/
- https://arxiv.org/pdf/1703.06317 (노인 대상 터치스크린 설계 지침 리뷰)
- https://www.w3.org/TR/WCAG22/

86
docs/02-architecture.md Normal file
View File

@@ -0,0 +1,86 @@
# 02. 시스템 아키텍처
## 1. 핵심 결정
| 결정 | 선택 | 이유 |
|---|---|---|
| 언어 | TypeScript (strict) 하나로 서버·클라이언트·게임 규칙 | 규칙 코드와 타입을 서버/클라이언트가 같이 씀 |
| 서버 런타임 | Bun 1.3+ (`Bun.serve` HTTP + WebSocket) | 내장 WebSocket pub/sub, 높은 처리량, 내장 SQLite·테스트 러너 |
| HTTP 라우팅 | Hono | 작고 검증된 라우터, 쿠키·미들웨어 |
| 실시간 | WebSocket + JSON, 서버 권위(authoritative) | 모든 판정은 서버, 클라이언트는 화면만 |
| 검증 | zod (공용 스키마) | 들어오는 모든 메시지를 스키마로 검사 |
| DB | SQLite (WAL 모드, `bun:sqlite`) | 단일 서버에서 가장 단순하고 안정적, 백업 쉬움 |
| 웹 | React 19 + Vite + react-router + zustand | 익숙하고 작음, 게임 화면은 lazy import로 분리 |
| 스타일 | CSS 변수 + CSS Modules, UI 라이브러리 미사용 | 가벼움, 글자 크기·테마 전환이 쉬움 |
| 보드 그리기 | SVG(보드·말) + DOM(카드·버튼) | 선명함, 확대·접근성·클릭 판정이 쉬움. Canvas는 그림 맞히기에서만 |
| 글꼴 | Pretendard(오픈 라이선스) 자체 호스팅, 한글 서브셋 | 가독성, 외부 CDN 의존 없음 |
| 배포 | Docker Compose: app 컨테이너 + Caddy(HTTPS, 압축, 정적 파일) | 단순, 자동 인증서 |
## 2. 전체 구조
```
브라우저 (React)
│ HTTPS: 정적 파일, /api/* (로그인, 계정, 방 만들기/조회)
│ WSS: /ws (방 입장, 행동, 상태 수신, 채팅)
▼
Caddy ── 정적 파일(/assets 장기 캐시) ── gzip/zstd 압축
│ reverse proxy
▼
Bun 서버 (단일 프로세스)
├─ HTTP API (Hono) : auth, me, rooms, games 목록
├─ WS 게이트웨이 : 연결 인증, 메시지 검증, 속도 제한, 하트비트
├─ RoomManager : 방 생성/조회/정리, 6자리 코드 발급
│ └─ Room (방 하나) : 자리, 준비 상태, 채팅, 타이머, GameRunner
│ └─ GameRunner : GameDefinition 호출(validate/apply/view), 시퀀스 번호, 저장
├─ Persistence : SQLite (users, sessions, rooms, room_log, results)
└─ games/* : 게임별 순수 규칙 모듈 (I/O 없음)
```
## 3. 저장소 구조 (bun workspaces 모노레포)
```
/
├─ apps/
│ ├─ server/src/
│ │ ├─ index.ts 서버 시작, 종료 신호 처리
│ │ ├─ http/ Hono 라우트 (auth, me, rooms)
│ │ ├─ ws/ 게이트웨이, 연결 상태, 속도 제한
│ │ ├─ rooms/ RoomManager, Room, GameRunner, 타이머
│ │ ├─ auth/ 디스코드 OAuth, 게스트, 세션
│ │ └─ db/ 스키마, 마이그레이션, 쿼리
│ └─ web/src/
│ ├─ main.tsx, app/ 라우터, 레이아웃, 설정(글자 크기·테마)
│ ├─ pages/ 홈, 게임 상세, 방(대기실/게임), 계정, 참여(/j/:code)
│ ├─ net/ WS 클라이언트(재연결, 시퀀스), API 클라이언트
│ ├─ components/ 버튼, 카드, 모달, 토스트, 타이머 링, 채팅
│ └─ games/<id>/ 게임별 화면 (lazy)
├─ packages/
│ ├─ shared/ 프로토콜 타입, zod 스키마, 공용 상수(닉네임 규칙 등)
│ ├─ engine/ GameDefinition 타입, 시드 RNG, 카드/덱 유틸, 테스트 도구
│ └─ games/src/<id>/ 게임 규칙 (index.ts, rules/*.ts, *.test.ts)
├─ deploy/ Dockerfile, docker-compose.yml, Caddyfile
└─ docs/
```
규칙:
- `packages/games`는 I/O, 시간(`Date.now`), `Math.random`을 쓰지 않는다(린트로 금지). 시간과 난수는 `ctx`로만 받는다.
- 웹은 숨겨진 정보가 있는 게임의 규칙 코드를 import하지 않는다(서버가 준 view만 사용). 완전 정보 게임(오목·바둑·체스 등)은 둘 수 있는 곳 표시를 위해 규칙 코드를 import할 수 있다.
- 서버와 웹은 `packages/shared`의 타입·스키마로만 통신한다.
## 4. 방 하나의 처리 흐름
1. 클라이언트가 `{t:"act", room, clientSeq, action}` 전송.
2. 게이트웨이: 세션 확인 → 메시지 크기·속도 제한 → zod 스키마 검사.
3. Room: 보낸 사람이 그 방의 플레이어인지, 게임이 진행 중인지 확인.
4. GameRunner: `validate()` → 실패면 그 사람에게만 `{t:"reject", clientSeq, reason}`.
5. 성공이면 `apply()` → 새 상태 + 이벤트. **먼저 DB에 행동 기록을 쓰고(같은 틱 안의 동기 SQLite 쓰기)**, 그다음 방 시퀀스 번호를 올리고 브로드캐스트.
6. 각 접속자에게 `view(state, 그 사람)`을 계산해 `{t:"state", seq, view, events}` 전송. 관전자는 `view(state, null)` 하나를 계산해 공유.
7. 타이머 재설정(`deadline()`), 끝났으면 `result()` 저장.
방 하나의 처리는 자바스크립트 단일 스레드에서 순서대로 실행되므로 같은 방에서 경쟁 상태가 생기지 않는다. 한 행동 처리(검증+적용+저장+전송)는 1~5ms 안에 끝나야 하며, 무거운 계산(예: 마작 점수 계산)도 이 안에 들어오도록 테스트한다.
## 5. 왜 단일 프로세스인가
- 목표 규모(동시 2,000명, 방 500개)는 Bun 프로세스 하나로 충분하다(방당 초당 행동 수가 매우 적음).
- 프로세스가 하나면 방 상태를 메모리에 두고 잠금 없이 처리할 수 있어 버그가 줄고 빠르다.
- 확장이 필요해지면: 방 코드 기준으로 여러 프로세스에 나누고(코드 → 워커 매핑), Caddy가 `/ws?room=` 기준으로 라우팅. 이건 로드맵 M9 이후 검토(`12-roadmap.md`).
## 6. 의존성 원칙
- 런타임 의존성은 최소화. 추가 전에 "직접 50줄로 되는가?"를 먼저 따진다.
- 허용 목록(초기): hono, zod, react, react-dom, react-router, zustand, qrcode(작은 QR 생성기). 체스는 `chess.js` 사용 여부를 `games/chess.md`에서 결정.
- 버전은 lockfile로 고정하고, 업데이트는 테스트 통과 후에만.

View File

@@ -0,0 +1,100 @@
# 03. 실시간 통신 프로토콜
## 1. 원칙
- 연결은 사용자당 브라우저 탭 하나에 WebSocket 하나(`wss://<도메인>/ws`).
- **서버가 유일한 진실**. 클라이언트는 행동을 "요청"만 하고, 화면은 서버가 보낸 상태로만 그린다.
- 서버는 매번 **그 사람이 볼 수 있는 전체 화면 상태(view)** 를 보낸다(차이(diff) 전송 안 함). 보드게임 상태는 작아서(대부분 2~10KB) 전체 전송이 단순하고 안전하다. 메시지를 잃어버려도 다음 메시지 하나로 화면이 완전히 맞춰진다.
- 애니메이션용 `events`는 덤이다. 놓쳐도 상태는 맞다.
- 프로토콜 버전 `PROTOCOL = 1`. 서버와 버전이 다르면 클라이언트는 새로고침 안내를 띄운다(배포 직후 대비).
## 2. 연결과 인증
1. 클라이언트가 `/ws`로 업그레이드 요청. 브라우저가 세션 쿠키(`sid`, httpOnly)를 같이 보낸다.
2. 서버는 업그레이드 단계에서 세션을 확인하고, 없으면 401로 거절(클라이언트는 닉네임 화면으로).
3. `Origin` 헤더가 우리 도메인이 아니면 거절(CSWSH 방지).
4. 연결되면 서버가 `welcome`을 보낸다. 사용자가 이미 들어가 있는 방이 있으면 `welcome.activeRoom`에 코드를 넣어 준다(→ "진행 중인 게임으로 돌아가기").
## 3. 메시지 형식
모든 메시지는 JSON 객체이며 `t`(type) 필드를 가진다. 모든 클라이언트 메시지는 `packages/shared`의 zod 스키마로 검사한다.
### 3.1 클라이언트 → 서버
| t | 필드 | 설명 |
|---|---|---|
| `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 |
| `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) |
| `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) |
| `unseat` | — | 관전자로 일어나기(대기실에서만) |
| `ready` | `ready: boolean` | 준비 |
| `config` | `gameId?`, `options?`, `maxPlayers?`, `visibility?`, `allowSpectators?`, `chatEnabled?`, `chatFilter?` | 방장: 게임/옵션/방 설정 변경(대기실·게임 끝난 뒤에만) |
| `start` | — | 방장: 시작 |
| `kick` | `userId` | 방장: 내보내기 |
| `host` | `userId` | 방장 넘기기 |
| `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) |
| `chat` | `text`(1~200자) | 채팅 |
| `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |
| `rematch` | — | 게임 끝난 뒤 "한 판 더" 투표 |
| `sync` | — | 전체 상태 다시 요청 |
| `ping` | `ts` | 지연 측정(10초마다) |
### 3.2 서버 → 클라이언트
| t | 필드 | 설명 |
|---|---|---|
| `welcome` | `me`, `serverTime`, `protocol`, `activeRoom?` | 연결 직후 |
| `room` | `seq`, `room: RoomView` | 대기실/자리/옵션/접속 상태 전체 |
| `state` | `seq`, `view`, `events`, `active: userId[]`, `deadline: number\|null`, `cs?` | 게임 상태. `cs`는 이 상태를 만든 행동이 내 것일 때만 포함(= 성공 응답) |
| `reject` | `cs`, `reason` | 내 행동 거절(한국어 이유). 화면은 바꾸지 않고 안내만 표시 |
| `chat` | `from`, `text`, `at`, `channel?` | 채팅(마피아 등은 채널 구분) |
| `emote` | `from`, `id` | 반응 |
| `result` | `seq`, `result` | 게임 결과 |
| `notice` | `code`, `message` | "OO님이 연결이 끊겼어요" 같은 알림 |
| `error` | `code`, `message` | 잘못된 요청, 방 없음 등 |
| `pong` | `ts`, `serverTime` | 지연·시계 보정 |
| `bye` | `reason` | 서버 종료 예정(`restart`), 다른 창에서 접속(`replaced`), 강퇴(`kicked`) |
## 4. 순서 보장 (seq)
- 방마다 `seq`(정수)가 있고, 방에서 무엇이든 바뀌면 1 증가한다(자리, 옵션, 게임 행동 모두).
- 클라이언트는 받은 `seq`가 가지고 있는 값보다 작거나 같으면 버린다(늦게 도착한 옛 메시지 무시).
- `seq`가 건너뛰어도(중간 메시지 누락) 전체 상태이므로 그대로 적용한다. 단 `events` 애니메이션은 건너뛴다.
## 5. 행동 처리와 중복 방지
- 클라이언트는 `act`를 보내면 해당 버튼을 잠그고 "처리 중" 표시(150ms 이상 걸릴 때만 보이게).
- 서버는 `(userId, room, cs)`를 기억해 같은 `cs`가 다시 오면 무시한다(재연결 직후 재전송 대비). 재연결 시 클라이언트는 응답 못 받은 행동을 **다시 보내지 않고** `sync`로 상태를 받은 뒤 사용자가 다시 판단하게 한다(이미 처리됐을 수 있으므로).
- 낙관적 업데이트는 하지 않는다(숨겨진 정보 게임에서 오히려 혼란). 예외: 루미큐브 타일 정렬, 그림 맞히기 붓질처럼 "내 화면 안에서만의 조작"은 즉시 그린다.
- 2초 안에 응답이 없으면 "연결 확인 중" 표시 후 `ping`으로 연결 점검.
## 6. 연결 끊김과 재접속
### 6.1 클라이언트
- 끊기면 자동 재연결: 0.5s → 1s → 2s → 4s → 8s → 이후 10s 간격, 각 ±30% 무작위 지연(서버 재시작 시 몰림 방지).
- 화면 위에 작은 띠로 "다시 연결하는 중…" 표시. 게임 화면은 그대로 두되 조작은 잠근다.
- 탭이 다시 보이거나(`visibilitychange`) 네트워크가 돌아오면(`online`) 즉시 재시도.
- 다시 연결되면 마지막 방에 자동 `join` → 서버가 `room` + `state` 전체를 보냄.
### 6.2 서버
- 연결이 끊긴 플레이어는 **자리를 유지**하고 다른 사람에게 "연결 끊김" 표시.
- 턴 타이머는 그대로 흐른다. 끊긴 사람의 차례에 시간이 다 되면 게임의 `onTimeout` 자동 행동(예: 포커 다이/체크, 오목 무작위 착수가 아니라 **시간패** 등 게임별 문서에 정의).
- 끊긴 지 **60초**(방 옵션 30~300초)가 지나면 "자리 비움" 상태: 자기 차례가 오면 기다리지 않고 바로 자동 행동.
- 자동 행동이 연속 3번이거나 끊긴 지 5분이 지나면 방장에게 "내보내기" 버튼을 보여 준다. 2인 대전 게임은 상대에게 "승리로 끝내기" 선택권을 준다.
- 같은 사용자가 새 탭으로 같은 방에 들어오면 새 연결이 이기고, 옛 연결에는 `bye: replaced`.
## 7. 타이머와 시계
- 모든 마감 시각은 서버 시각(epoch ms)으로 보낸다.
- 클라이언트는 `pong.serverTime`과 왕복 시간으로 시계 차이를 추정(최근 5개 중간값)하고 남은 시간을 그린다.
- 시간 판정은 서버만 한다. 서버 타이머는 방마다 하나(`setTimeout`)이며, 서버 재시작 후에는 저장된 `deadline`으로 다시 건다.
## 8. 하트비트·제한
| 항목 | 값 |
|---|---|
| 서버 idle timeout | 40초 (Bun `idleTimeout`), 서버가 25초마다 ping 프레임 |
| 클라이언트 ping | 10초마다, 5초 안에 pong이 없으면 연결을 끊고 재연결 |
| 메시지 최대 크기 | 16KB (그림 맞히기 붓질 메시지만 32KB) |
| 속도 제한 | 연결당 토큰 버킷 초당 20개, 최대 40개. 채팅은 0.5초에 1개. 넘으면 버리고 계속 넘으면 연결 종료 |
| 압축 | permessage-deflate 사용(1KB 이상 메시지만 효과) |
| 서버 송신 버퍼 | 연결당 1MB 넘게 밀리면(느린 클라이언트) 연결을 끊음 → 재연결 시 전체 상태로 회복 |
## 9. 실시간 경쟁 게임(할리갈리, 도블 등)
- 먼저 누른 사람은 **서버 도착 순서**로 정한다. 같은 시점에 판정 창을 열고, 첫 도착을 처리한 뒤 30ms 동안 들어온 같은 종류 행동은 "늦음"으로 알려 준다.
- 각자의 왕복 지연(RTT)을 화면 구석에 표시하고, 방 옵션으로 "지연 보정"(클라이언트가 누른 시각 − RTT/2로 순서 결정, 단 100ms 이내 차이만 보정)을 켤 수 있다. 기본은 꺼짐(조작 가능성 때문에). 세부는 각 게임 문서.
## 10. 서버 재시작 시
1. 종료 신호(SIGTERM)를 받으면 새 연결을 받지 않고, 모든 연결에 `bye: restart`를 보낸 뒤 DB를 정리하고 종료(최대 10초).
2. 클라이언트는 `restart`를 받으면 "잠시 후 자동으로 다시 연결돼요" 표시 후 1~3초 무작위 대기 후 재연결.
3. 새 서버는 시작할 때 진행 중이던 방을 DB에서 복구(`04-stability-security.md` 3절)한 다음 연결을 받는다.

View File

@@ -0,0 +1,68 @@
# 04. 안정성과 보안
## 1. 목표
- 어떤 상황에서도 진행 중인 게임 상태를 잃지 않는다(프로세스 충돌, 배포, 서버 재부팅).
- 한 방의 버그가 다른 방이나 서버 전체를 멈추지 않는다.
- 클라이언트를 조작해도 규칙을 어기거나 남의 패를 볼 수 없다.
## 2. 상태 저장 방식
- 방 상태는 메모리에 있고, **행동이 하나 처리될 때마다** 같은 동기 트랜잭션으로 SQLite에 기록한다.
- `room_log`에 행동 한 줄 추가(누가, 무엇을, 서버 시각, seq) — 기록 보기·리플레이·디버깅용.
- `room_state`에 최신 전체 상태(게임 상태 + RNG 상태 + 타이머 마감 + 자리 정보) 덮어쓰기.
- 상태는 작으므로(대부분 수 KB) 매번 전체 저장이 가장 단순하고 확실하다. SQLite WAL + `synchronous=NORMAL`로 한 번 쓰기 1ms 미만 목표(테스트로 확인).
- 저장이 끝난 다음에만 다른 사람에게 결과를 보낸다(보낸 상태 = 저장된 상태).
- `synchronous=NORMAL`은 프로세스가 죽어도 데이터가 남는다. 정전 같은 OS 단위 장애에서는 마지막 몇 개 행동을 잃을 수 있고, 이는 허용한다.
## 3. 서버 시작 시 복구
1. `rooms`에서 `status IN ('lobby','playing')`인 방을 읽는다.
2. 각 방의 `room_state`를 불러 메모리 Room을 만든다. 모든 플레이어는 "연결 끊김" 상태로 시작.
3. 저장된 `deadline`이 이미 지났다면 복구 직후 일괄 처리하지 않고, **복구 시각 + 30초**로 마감을 미뤄 준다(재접속할 시간).
4. 복구 실패(스키마 불일치, 게임 코드 변경으로 상태가 안 맞음)한 방은 `status='broken'`으로 표시하고, 들어온 사람에게 "서버 업데이트로 이 게임을 이어갈 수 없어요. 새로 시작해 주세요"를 보여 준다.
5. 게임 규칙 코드가 바뀌어 저장 상태 형식이 달라지면, 그 게임 모듈의 `stateVersion`을 올리고 `migrate(oldState)`를 제공하거나 위 4번으로 처리한다.
## 4. 장애 격리
- 게임 모듈의 `apply/view` 호출은 try/catch로 감싼다. 예외가 나면:
- 그 행동은 적용하지 않음(상태는 직전 그대로), 행동한 사람에게 `reject`("알 수 없는 오류가 발생했어요").
- 오류 로그에 방 코드, 게임, seq, 행동, 스택을 남긴다.
- 같은 방에서 오류가 3번 연속 나면 방을 "일시 정지"하고 방장에게 "게임 끝내기(무효)"를 제공.
- `view()`에서 예외가 나면 그 사람에게만 오류 화면, 다른 사람에게는 정상 전송.
- 프로세스 수준 `uncaughtException`/`unhandledRejection`은 로그 후 종료 → Docker `restart: unless-stopped`가 재시작 → 3절로 복구.
- 무한 루프 방지: 게임 규칙 함수에 단위 테스트 + 무작위 대국 테스트로 처리 시간 상한(5ms) 확인.
## 5. 숨겨진 정보와 공정성
- 클라이언트에는 `view(state, viewer)` 결과만 보낸다. 덱 순서, 남의 패, 마피아 직업, RNG 시드는 절대 보내지 않는다.
- 게임마다 "정보 유출 테스트": 무작위 대국을 돌리며 매 순간 플레이어 A의 view(JSON 문자열)에 다른 사람만 알아야 하는 값(카드 ID 등)이 들어 있지 않은지 검사.
- 셔플: 게임 시작 시 `crypto.getRandomValues`로 128비트 시드를 만들고, 상태 안의 시드 PRNG(sfc32)로 Fisher–Yates 셔플.
- **공정성 확인(선택 표시)**: 게임 시작 때 시드의 SHA-256 해시를 모두에게 공개하고, 게임이 끝나면 시드를 공개한다. 결과 화면의 "공정성 확인"을 누르면 같은 시드로 덱을 다시 만들어 실제 배분과 같은지 브라우저가 확인한다.
- 관전자에게는 공개 정보만. 포커·섯다처럼 관전이 정보 누설 통로가 될 수 있는 게임은 기본적으로 "관전 지연 표시 없음 + 공개 정보만"이고, 같은 사람이 플레이어와 관전자를 동시에 할 수 없다.
## 6. 사행성·법적 주의
- 포커·섯다·고스톱 등은 **방 안에서만 쓰는 가상 칩/점수**를 쓴다. 방이 끝나면 사라지고, 계정에 쌓이지 않으며, 사고팔거나 바꿀 수 없다. 결제·유료 아이템·충전도 없다.
- 기록에는 "몇 판 이겼는지"만 남기고 칩 잔액은 남기지 않는다.
- 이 구조는 게임산업진흥법의 사행성 관련 위험을 피하기 위한 것이다(법률 자문 아님). 수익화를 하게 되면 그 전에 반드시 법률 검토를 받는다.
- 상표: 루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 모두의 마블 등은 상표다. 공개 서비스로 열기 전에 화면 표시 이름을 일반 명칭으로 바꿀지 사용자 결정을 받는다(`games/README.md` 4절). 카드·화투 그림은 직접 만든 SVG만 쓴다.
## 7. 웹 보안
| 위협 | 대응 |
|---|---|
| 세션 탈취 | 세션 토큰은 32바이트 무작위, DB에는 SHA-256 해시만 저장. 쿠키 `HttpOnly; Secure; SameSite=Lax; Path=/` |
| CSRF | 상태를 바꾸는 HTTP API는 POST + `Origin` 검사 + `SameSite=Lax` |
| CSWSH | WS 업그레이드 때 `Origin` 검사 |
| XSS | React 기본 이스케이프, `dangerouslySetInnerHTML` 금지(린트), 닉네임·채팅은 일반 텍스트. CSP: `default-src 'self'; img-src 'self' https://cdn.discordapp.com data:; connect-src 'self' wss://<도메인>; frame-ancestors 'none'` |
| 메시지 폭주 | 3장 8절의 속도·크기 제한, 사용자당 동시 연결 5개, IP당 분당 방 생성 10개 |
| 방 코드 무작위 대입 | 코드 입력 API는 IP당 분당 30회 제한, 6자리(100만 개) 중 사용 중인 코드는 수백 개 수준 |
| 잘못된 입력 | 모든 메시지 zod 검사, 게임 `validate()`가 한 번 더 검사 |
| 비밀값 | 디스코드 client secret, 세션 서명 키는 `.env`(저장소에 올리지 않음) |
## 8. 채팅 안전(어린이 사용자 고려)
- 방 옵션: 채팅 켜기/끄기(기본 켜짐), 욕설 필터(기본 켜짐, 단어 목록 기반 `***` 치환).
- 각 사용자는 특정 사람의 채팅을 "숨기기" 가능(내 화면에서만).
- 빠른 반응(이모트)은 고정 목록이라 항상 안전.
- 닉네임에도 같은 욕설 필터 적용.
## 9. 운영 안정성
- 로그: JSON 한 줄 로그(시간, 레벨, 방, 사용자, 이벤트). 게임 행동 내용은 `room_log`에만.
- 지표: `/internal/metrics`(외부 차단) — 연결 수, 방 수, 행동 처리 시간 히스토그램, 저장 시간, 오류 수, 메모리.
- 백업: 매일 SQLite `VACUUM INTO`로 백업 파일 생성, 14일 보관. 복구 절차는 `11-deployment-ops.md`.
- 정리: 아무도 없는 대기실 10분 후 삭제, 끝난 방 30분 후 메모리에서 내림(기록은 DB에 남음), 오래된 `room_log`는 90일 후 삭제.
- 무중단에 가까운 배포: 새 버전 시작 전 옛 버전이 `bye: restart`로 안내 → 재시작(수 초) → 자동 재연결·복구.

63
docs/05-accounts-auth.md Normal file
View File

@@ -0,0 +1,63 @@
# 05. 계정과 로그인
## 1. 두 가지 방식
| | 게스트 | 디스코드 로그인 |
|---|---|---|
| 시작 | 닉네임 한 칸 입력 → "시작하기" | "디스코드로 로그인" → 디스코드 승인 → 돌아옴 |
| 닉네임 | 입력한 값 | 처음엔 디스코드 표시 이름(global_name, 없으면 username). 계정 페이지에서 언제든 변경 |
| 프로필 사진 | 닉네임 첫 글자 + 자동 색상 동그라미 | 디스코드 아바타(끌 수 있음) |
| 유지 | 이 브라우저 쿠키(1년). 쿠키를 지우면 사라짐 | 어느 기기에서든 로그인하면 같은 계정 |
| 전적 | 이 브라우저에만 | 계정에 저장 |
| 전환 | 게스트로 쓰다가 "디스코드 연결"하면 전적·닉네임을 디스코드 계정으로 합침 | — |
어디서든 로그인이 필요해지면(예: 링크로 방에 들어옴) 같은 화면에서 두 방식을 나란히 보여 주고, 게스트 쪽을 기본으로 강조한다(가장 빠름).
## 2. 닉네임 규칙
- 2~12자, 한글/영문/숫자/공백(앞뒤 공백 제거, 연속 공백 1개로), 이모지·특수문자 불가(`_`, `-`, `.`만 허용).
- 중복 허용(사람 구분은 내부 ID로). 같은 방에 같은 닉네임이 있으면 화면에서 뒤에 `(2)`를 붙여 구분.
- 욕설 필터 통과 필수. 실패 시 "다른 닉네임을 써 주세요".
- 변경은 언제나 가능, 단 게임 진행 중에는 그 게임이 끝난 뒤 반영(진행 중 이름이 바뀌면 혼란). 하루 변경 횟수 제한 10회.
- 처음 들어오면 닉네임 칸에 무작위 추천 이름(예: "용감한 호랑이")을 미리 채워 두어 그냥 눌러도 시작되게 한다.
## 3. 디스코드 OAuth2 흐름
- 디스코드 개발자 포털에서 앱 생성 → `client_id`, `client_secret`, Redirect URI `https://<도메인>/api/auth/discord/callback` 등록(사용자 작업).
- 범위(scope): `identify`만(이메일·서버 목록 안 받음).
- 흐름(Authorization Code + state, 서버가 client secret을 보관하는 기밀 클라이언트):
1. `GET /api/auth/discord/start?next=/r/123456` → 서버가 `state`(32바이트 무작위)를 만들어 `next`와 함께 짧은 수명(10분) 서명 쿠키에 넣고 디스코드 승인 페이지로 리다이렉트.
2. 디스코드가 `/api/auth/discord/callback?code&state`로 돌려보냄 → state 비교 → 토큰 교환 → `GET /users/@me`.
3. `oauth_accounts(provider='discord', provider_user_id)`로 사용자 찾기/만들기. 현재 게스트 세션이 있으면 그 게스트를 이 계정에 **합침**(아래 4절).
4. 새 세션 발급 → `next`로 리다이렉트(`next`는 우리 사이트 내부 경로만 허용 — 오픈 리다이렉트 방지).
- 디스코드 액세스 토큰은 사용자 정보를 받은 뒤 저장하지 않는다(필요 없음).
- 아바타 URL: `https://cdn.discordapp.com/avatars/<id>/<avatar>.png?size=128` (로그인할 때마다 갱신).
## 4. 게스트 → 디스코드 합치기
- 게스트 사용자 G로 로그인된 상태에서 디스코드 계정 D로 로그인하면:
- D가 처음이면: G에 디스코드 연결을 추가하고 G를 일반 계정으로 바꾼다(닉네임 유지).
- D가 이미 있으면: G의 전적을 D로 옮기고 G를 삭제. 닉네임은 D 것을 유지. 진행 중인 방의 자리도 D로 바꾼다.
- 합치기 결과를 토스트로 알림: "디스코드 계정과 연결했어요. 기록이 합쳐졌어요."
## 5. 세션
- 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장.
- 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신).
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 그 게스트 계정에 다시 들어올 수 없다는 경고를 띄운다.
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
## 6. HTTP API
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | `/api/auth/guest` | `{nickname}` → 게스트 생성 + 세션 |
| GET | `/api/auth/discord/start` | 디스코드 로그인 시작 |
| GET | `/api/auth/discord/callback` | 디스코드 콜백 |
| POST | `/api/auth/logout` | 로그아웃 |
| POST | `/api/auth/logout-all` | 모든 기기 로그아웃 |
| GET | `/api/me` | 내 정보(id, 닉네임, 종류, 아바타, 설정) |
| PATCH | `/api/me` | `{nickname?, useDiscordAvatar?, settings?}` |
| GET | `/api/me/stats` | 게임별 전적 |
| DELETE | `/api/me` | 계정 삭제(확인 문구 입력 필요). 전적 익명화 |
## 7. 계정 페이지(/me)
- 큰 프로필 사진 + 닉네임 + "닉네임 바꾸기" 버튼(누르면 그 자리에서 편집, 저장/취소).
- 로그인 방식 표시: "게스트" 또는 "디스코드(이름)". 게스트면 "디스코드 연결하기(기록을 안전하게 보관)" 버튼.
- 화면 설정: 글자 크기(보통/크게/아주 크게), 다크 모드(자동/밝게/어둡게), 소리 켜기/끄기, 진동 켜기/끄기, 색약 모드.
- 내 전적: 게임별 판 수/승 수.
- 로그아웃, 모든 기기 로그아웃, 계정 삭제(맨 아래, 작게).

97
docs/06-lobby-rooms.md Normal file
View File

@@ -0,0 +1,97 @@
# 06. 로비와 방
## 1. 목표
- 방 만들기: 첫 화면에서 **게임 카드 누르기 → "방 만들기"** 두 번이면 대기실, 코드와 링크가 바로 보인다.
- 방 참여: 링크를 누르면 바로 대기실(처음이면 닉네임 한 칸). 코드를 받았으면 첫 화면 "코드로 참여"에 숫자 6개.
- 글을 몰라도 쓸 수 있게 큰 아이콘 + 짧은 글자.
## 2. 화면과 주소
| 주소 | 화면 |
|---|---|
| `/` | 홈: 위쪽에 큰 버튼 두 개 **[방 만들기] [코드로 참여]**, 그 아래 "진행 중인 게임으로 돌아가기"(있을 때만), 그 아래 게임 카드 목록 |
| `/g/:gameId` | 게임 소개: 한 줄 설명, 인원, 시간, 난이도, **[방 만들기] [빠른 시작] [규칙 보기]**, 열린 공개 방 목록 |
| `/r/:code` | 방(대기실 ↔ 게임 화면 ↔ 결과 화면이 같은 주소에서 전환) |
| `/j` | 코드 입력 전용 화면(숫자 키패드) |
| `/me` | 계정 |
| `/rules/:gameId` | 규칙 설명(그림 포함) |
초대 링크는 `https://<도메인>/r/123456`. 짧고 읽기 쉬워서 말로 불러 줘도 된다.
## 3. 방 코드
- 6자리 숫자(000000 제외, 같은 숫자 6개·연속 숫자 제외). 숫자만 쓰는 이유: 어르신·어린이가 키패드로 입력하기 가장 쉽고, 말로 전달하기 쉽다.
- 화면에는 `123 456`처럼 3자리씩 띄어 보여 준다. 입력은 띄어쓰기·하이픈 무시.
- 사용 중인 코드와 겹치지 않게 무작위 발급. 방이 끝나고 24시간 동안은 같은 코드 재사용 금지(옛 링크로 엉뚱한 방에 들어가는 것 방지).
- 코드 입력 화면: 6칸 큰 숫자 칸, 휴대폰 숫자 키패드(`inputmode="numeric"`), 6자리가 채워지면 자동으로 입장 시도. 클립보드에 6자리 숫자나 초대 링크가 있으면 "붙여넣기" 버튼을 크게 표시.
## 4. 방 만들기
1. 게임 선택(홈 카드 또는 홈의 [방 만들기] → 게임 고르기 화면).
2. 바로 대기실 생성. 모든 옵션은 **기본값으로 이미 정해져 있다**(인원은 게임 기본 인원, 공개 여부는 "친구만(비공개)").
3. 대기실에서 방장이 바꿀 수 있는 것: 게임 모드/옵션(시간 제한 포함 — 게임마다 다름), 최대 인원(게임의 최소~최대 범위 안), 공개/비공개, 관전 허용, 채팅 허용.
4. 옵션은 "기본 설정" 몇 개만 보이고 나머지는 "자세한 설정" 접기 안에 둔다.
## 5. 대기실 화면 (위에서 아래 순서, 모바일 세로 기준)
1. **초대 카드**(가장 눈에 띄게): 큰 글씨 코드 `123 456`
- [코드 복사] [링크 복사] [공유하기](모바일: 공유 시트 → 카카오톡 등) [QR 보기](같은 공간의 가족이 폰으로 찍어서 입장)
- 복사하면 버튼이 1.5초간 "복사했어요 ✓"로 바뀜 + 진동(지원 기기).
- 공유 문구: "OO님이 오목 방에 초대했어요! 코드 123 456 · https://<도메인>/r/123456"
2. **자리 목록**: 게임 인원만큼 자리 칸. 각 칸: 프로필, 닉네임, 방장 왕관, 준비 체크, 연결 상태 점(초록/노랑/회색). 빈자리는 점선 "+ 초대하기"(누르면 초대 카드로 스크롤).
3. **게임 설정 요약**: "오목 · 렌주룰 · 한 수 30초" + 방장에게만 [설정 바꾸기].
4. **아래 고정 버튼**(엄지 닿는 곳):
- 방장: [게임 시작] — 최소 인원이 안 되면 비활성 + "1명 더 필요해요" 안내. 모두 준비가 아니어도 시작 가능하되 "준비 안 된 사람이 있어요. 시작할까요?" 확인.
- 나머지: [준비하기] / [준비 취소].
5. 채팅(접을 수 있음), 관전자 수.
방장 기능: 내보내기, 방장 넘기기, 자리 순서 섞기(선 정하기), 게임 바꾸기(같은 방 유지 — 친구들끼리 여러 게임 이어서 하기). 방장이 나가면 가장 먼저 들어온 사람이 방장.
## 6. 링크로 들어오기
1. `/r/123456` 접속.
2. 로그인 안 됨 → 같은 화면 위에 작은 창: 방 정보("오목 · 방장 OO · 1/2명") + 닉네임 칸(추천 이름 채워짐) + [이 이름으로 입장] + 작게 [디스코드로 로그인].
3. 입장 → 빈자리가 있으면 **자동으로 자리에 앉는다**. 없으면 관전자로 들어가고 "자리가 나면 앉을 수 있어요".
4. 방이 없거나 끝났으면: "이 방은 끝났어요" + [같은 게임 새 방 만들기] [홈으로].
## 7. 빠른 시작
- 게임 상세의 [빠른 시작]: 같은 게임의 공개 대기실 중 사람이 가장 많은(가득 차지 않은) 방에 들어간다. 없으면 공개 방을 새로 만들고 기다린다.
- 기다리는 동안 "다른 사람을 기다리는 중… (공개 방)" + [친구 초대하기] 버튼.
## 8. 게임 중
- 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링.
- 메뉴(⋯): 규칙 보기, 소리, 글자 크기, 무르기 요청(지원 게임), 무승부 제안(지원 게임), 기권, 나가기.
- 나가기를 누르면: "게임 중에 나가면 OO 처리돼요. 정말 나갈까요?"(게임별: 2인 대전은 기권패, 카드 게임은 다이 후 자리 비움 등).
- 관전자는 [자리 나면 참가] 대기 가능(다음 판부터).
## 9. 게임 끝
- 결과 화면: 순위, 점수, 핵심 장면 요약(예: "흑 5목 완성"), [한 판 더] [게임 바꾸기] [나가기].
- [한 판 더]는 투표: 자리에 앉은 사람 모두 누르면 같은 설정으로 즉시 새 게임(선/딜러는 규칙에 따라 교대). 안 누르는 사람이 있으면 그 사람이 [관전하기]로 일어나거나 방장이 [게임 시작]으로 바로 시작할 수 있다.
- 기록: 끝난 게임은 `game_results`에 저장, 방 안에서 "이번 방 전적"(예: OO 3승 2패) 표시.
## 10. 공개 방 목록
- 게임 상세 화면에서만 보인다(홈을 복잡하게 하지 않음). 정렬: 곧 시작할 방(빈자리 1개) 우선.
- 각 줄: 방 이름(기본 "OO님의 방"), 인원, 모드, [참여].
## 11. 서버 방 모델
```ts
type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken';
interface Room {
code: string; // 6자리
id: string; // 내부 ULID
hostId: UserId;
gameId: GameId;
options: unknown; // 게임 optionsSchema로 검증된 값
visibility: 'private' | 'public';
maxPlayers: number; // 게임 min~max 범위
graceSec: number; // 연결 끊김 유예(기본 60초). 턴 시간은 게임 옵션
allowSpectators: boolean;
chat: { enabled: boolean; filter: boolean };
seats: Array<{ userId: UserId; ready: boolean } | null>;
spectators: Set<UserId>;
presence: Map<UserId, { connected: boolean; since: number; autoCount: number }>;
status: RoomStatus;
seq: number;
game?: { state: unknown; rng: RngState; deadline: number | null; startedAt: number; seedHash: string };
rematchVotes: Set<UserId>;
sessionStats: Map<UserId, { wins: number; played: number }>;
createdAt: number; lastActiveAt: number;
}
```
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.

119
docs/07-ui-ux.md Normal file
View File

@@ -0,0 +1,119 @@
# 07. UI/UX 설계
## 1. 디자인 원칙
1. **한 화면에 할 일 하나.** 지금 눌러야 하는 버튼이 가장 크고 진하다.
2. **글자 + 아이콘.** 아이콘만 있는 버튼 금지(닫기 ✕ 제외).
3. **지금 상황을 문장으로.** 화면 맨 위에 "내 차례예요. 돌을 놓을 곳을 눌러 주세요."
4. **할 수 있는 것만 누를 수 있게.** 둘 수 있는 곳·낼 수 있는 카드만 밝게, 나머지는 흐리게. 잘못 누르면 짧게 흔들리고 이유를 한 줄로("같은 색 카드만 낼 수 있어요").
5. **실수 방지.** 큰 결정(기권, 나가기, 올인, 다이)은 확인. 단, 매 턴 확인창은 금지(느려짐) — 대신 "두 번 눌러 확정" 옵션(바둑·오목 오착 방지, 기본 켜짐: 첫 탭은 미리보기 돌, 같은 곳을 다시 탭하면 착수).
6. **기다림을 보여 준다.** 남은 시간 링, "OO님이 생각 중…", 연결 상태.
7. **가볍게.** 장식 이미지 최소, 애니메이션은 짧게(150~250ms), `prefers-reduced-motion` 존중.
## 2. 디자인 토큰
### 2.1 색 (밝은 테마 / 어두운 테마)
| 토큰 | 밝게 | 어둡게 | 용도 |
|---|---|---|---|
| `--bg` | #F7F5F0 | #16181D | 배경(따뜻한 미색) |
| `--surface` | #FFFFFF | #20232A | 카드, 패널 |
| `--text` | #1C1E21 | #F2F3F5 | 본문(명암비 15:1 이상) |
| `--text-sub` | #4A4F57 | #B8BCC4 | 보조 글(4.5:1 이상) |
| `--primary` | #2563EB | #5B8DEF | 주요 버튼(파랑) |
| `--primary-text` | #FFFFFF | #0B1020 | 주요 버튼 글자 |
| `--success` | #15803D | #4ADE80 | 준비, 성공 |
| `--warning` | #B45309 | #FBBF24 | 시간 부족, 연결 불안정 |
| `--danger` | #B91C1C | #F87171 | 기권, 나가기 |
| `--felt` | #1F6B4A | #17513A | 카드 게임 테이블 |
| `--wood` | #E3B76B | #B98C45 | 바둑판·오목판·장기판 |
| `--focus` | #F59E0B | #F59E0B | 키보드 포커스 테두리 3px |
- 색만으로 정보를 전달하지 않는다(내 차례 = 색 + 글자 + 테두리). 색약 모드: 카드 무늬에 모양 추가(♦에 빗금 등), 팀 색을 파랑/주황 조합으로.
### 2.2 글자
- 글꼴: Pretendard Variable(자체 호스팅, KS X 1001 서브셋 + 필요한 문자).
- 크기 단계(기본 "보통"): 본문 17px, 작은 글 15px(최소), 버튼 18px, 제목 22/28px. 줄 간격 1.5.
- 글자 크기 설정: 보통(100%) / 크게(120%) / 아주 크게(140%). 루트 `font-size`만 바꾸고 모든 크기는 `rem`으로.
- 굵기: 본문 500, 버튼·제목 700. 300 이하 금지.
### 2.3 크기·간격
- 터치 영역 최소 48×48px, 주요 버튼 높이 56px, 버튼 사이 간격 최소 8px.
- 간격 단위 4px(4/8/12/16/24/32).
- 모서리 둥글기 12px(버튼), 16px(카드).
- 바둑판처럼 칸이 작은 보드: 칸 크기가 32px 미만이면 "확대 보기" 버튼 + 두 손가락 확대 지원, "두 번 눌러 확정" 기본 켜짐.
## 3. 공통 화면 틀
```
┌──────────────────────────┐
│ ← 홈 오목 · 123 456 ⋯ │ ← 상단 바(56px): 뒤로, 게임·코드, 메뉴
│ 내 차례예요 (●흑) ◔ 23초 │ ← 상태 줄: 지금 할 일 + 타이머
├──────────────────────────┤
│ 상대 정보 (이름, 시간) │
│ │
│ 게임 보드 │ ← 화면 폭에 맞춰 정사각형
│ │
│ 내 정보 / 내 손패 │
├──────────────────────────┤
│ [주요 행동 버튼들] │ ← 하단 고정, 엄지 영역
│ 💬 채팅 😊 반응 │
└──────────────────────────┘
```
- PC(가로 넓음): 보드 가운데, 오른쪽 패널에 플레이어 목록·채팅·기록.
- 카드 게임: 원형 테이블 배치, 나는 항상 아래. 상대는 인원에 따라 위/좌/우에 자동 배치.
## 4. 공통 컴포넌트
| 컴포넌트 | 동작 |
|---|---|
| `BigButton` | 56px, 종류 primary/secondary/danger, 로딩 표시, 두 번 눌림 방지 |
| `CodeDisplay` | `123 456` 큰 글씨 + 복사 |
| `CopyButton` | 복사 후 1.5초 "복사했어요 ✓", 실패하면 텍스트 선택 상태로 보여 줌 |
| `ShareButton` | `navigator.share` 있으면 공유 시트, 없으면 링크 복사 |
| `QrModal` | QR 크게 + 코드 |
| `CodeInput` | 6칸, 숫자 키패드, 붙여넣기 감지, 다 채우면 자동 제출 |
| `Seat` | 프로필, 이름, 방장/준비/연결 상태 |
| `TurnTimer` | 원형 링, 10초 남으면 주황 + 똑딱 소리(설정), 5초 남으면 빨강 + 진동 |
| `Toast` | 위에서 내려오는 짧은 알림(3초), 스크린리더 `aria-live` |
| `ConfirmSheet` | 아래에서 올라오는 확인창(큰 버튼 두 개) |
| `Card` | 트럼프/화투/게임 카드 공용 SVG, 크기 3단계 |
| `RulesSheet` | 게임 중 규칙 요약(그림), 현재 상황 도움말 |
| `ConnectionBanner` | "다시 연결하는 중…" 띠 |
## 5. 초보자 도움
- 처음 하는 게임이면(기기 기준) 시작할 때 "30초 규칙 요약" 카드 3장(그림 위주), [건너뛰기].
- 게임 중 [?] → 지금 상황에서 할 수 있는 것 설명("지금은 낼 카드를 고르는 차례예요. 밝게 보이는 카드만 낼 수 있어요").
- 족보·점수가 복잡한 게임(섯다, 포커, 고스톱, 마작)은 내 패 옆에 현재 족보 이름 표시("지금: 7끗", "투페어"), 족보표 버튼.
- 힌트 옵션(방 설정): 끄면 하이라이트만, 켜면 추천 수 표시(초보 방).
## 6. 소리·진동
- 짧은 효과음만(돌 놓기, 카드 넘기기, 내 차례 알림, 시간 경고, 승리). 합쳐서 100KB 이하, 처음 사용자 조작 후에만 재생(브라우저 정책).
- 내 차례가 되었는데 탭이 백그라운드면: 탭 제목 깜빡임("● 내 차례예요") + 소리(설정).
- 진동(`navigator.vibrate`): 내 차례, 시간 5초 남음.
## 7. 접근성
- 키보드만으로 모든 조작 가능(보드는 방향키로 칸 이동 + Enter). 포커스 테두리 항상 보이게.
- 스크린리더: 보드 칸에 `aria-label`("가로 8, 세로 8, 빈칸"), 상대의 수를 `aria-live`로 읽어 줌("상대가 H8에 두었어요").
- 명암비 기준 WCAG 2.2 AA 이상, 큰 글자 모드에서 화면이 깨지지 않는지 테스트.
- 시스템 글자 크기·확대 존중(`user-scalable` 막지 않음).
## 8. 성능 예산
| 항목 | 예산 |
|---|---|
| 첫 화면 JS | 150KB gzip 이하 (React + 라우터 + 홈) |
| 게임 화면 하나 | 추가 80KB gzip 이하(lazy) |
| 글꼴 | 서브셋 woff2 2개(500/700) 합 400KB 이하, `font-display: swap` |
| 이미지 | 게임 카드 썸네일은 SVG 또는 WebP 10KB 이하 |
| 외부 요청 | 0개(디스코드 아바타 제외) |
## 9. 문구 규칙
- 존댓말 해요체, 짧게. "~하시겠습니까?" 대신 "~할까요?".
- 어려운 말 금지: "테이블" → "방", "세션" → 쓰지 않음, "로비" → "홈".
- 오류 문구는 원인 + 할 일: "방을 찾을 수 없어요. 코드를 다시 확인해 주세요."
## 10. 화면 목록과 상태 (구현 체크용)
- 홈(로그인 전/후, 진행 중 게임 있음)
- 닉네임 입력(게스트 시작 / 링크 입장)
- 코드 입력
- 게임 상세 + 공개 방 목록
- 대기실(방장/참가자/관전자, 인원 부족/가득 참)
- 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지)
- 결과 화면(승/패/무승부, 한 판 더 투표 중)
- 계정(게스트/디스코드)
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)

132
docs/08-data-model.md Normal file
View File

@@ -0,0 +1,132 @@
# 08. 데이터 모델 (SQLite)
## 1. 설정
- 파일: `data/app.db` (Docker 볼륨). `PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`
- 마이그레이션: `apps/server/src/db/migrations/NNN_name.sql`, 시작 시 `schema_migrations` 기준으로 순서대로 적용. 한 번 배포된 마이그레이션 파일은 수정 금지.
- ID: 사용자·방은 ULID(시간 정렬 가능) 문자열.
- 시각: epoch 밀리초 정수.
## 2. 테이블
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- ULID
kind TEXT NOT NULL CHECK (kind IN ('guest','member')),
nickname TEXT NOT NULL,
avatar_url TEXT, -- 디스코드 아바타
use_avatar INTEGER NOT NULL DEFAULT 1,
settings_json TEXT NOT NULL DEFAULT '{}', -- 글자 크기, 테마, 소리 등
nick_changed_at INTEGER, nick_change_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE oauth_accounts (
provider TEXT NOT NULL, -- 'discord'
provider_user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
username TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, provider_user_id)
);
CREATE INDEX oauth_accounts_user ON oauth_accounts(user_id);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
user_agent TEXT
);
CREATE INDEX sessions_user ON sessions(user_id);
CREATE TABLE rooms (
id TEXT PRIMARY KEY, -- ULID
code TEXT NOT NULL, -- 6자리
host_id TEXT NOT NULL,
game_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('lobby','playing','finished','paused','broken','closed')),
visibility TEXT NOT NULL,
config_json TEXT NOT NULL, -- 옵션, 인원, 턴 시간, 채팅 설정
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER
);
-- 열린 방끼리만 코드 중복 금지
CREATE UNIQUE INDEX rooms_open_code ON rooms(code) WHERE status <> 'closed';
CREATE INDEX rooms_status ON rooms(status);
CREATE TABLE room_state (
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
state_json TEXT NOT NULL, -- 자리, 게임 상태, RNG 상태, deadline, stateVersion
updated_at INTEGER NOT NULL
);
CREATE TABLE room_log (
room_id TEXT NOT NULL,
seq INTEGER NOT NULL,
game_no INTEGER NOT NULL, -- 방 안에서 몇 번째 게임인지
actor_id TEXT, -- null = 시스템(타이머 등)
kind TEXT NOT NULL, -- 'act' | 'timeout' | 'start' | 'seat' | ...
data_json TEXT NOT NULL,
at INTEGER NOT NULL,
PRIMARY KEY (room_id, seq)
);
CREATE TABLE games (
id TEXT PRIMARY KEY, -- ULID, 한 판
room_id TEXT NOT NULL,
game_no INTEGER NOT NULL,
game_id TEXT NOT NULL,
options_json TEXT NOT NULL,
seed_hash TEXT NOT NULL,
seed TEXT, -- 끝난 뒤에만 채움(공정성 확인)
started_at INTEGER NOT NULL,
ended_at INTEGER,
end_reason TEXT, -- 'normal' | 'resign' | 'abandoned' | 'void'
result_json TEXT
);
CREATE INDEX games_room ON games(room_id);
CREATE TABLE game_players (
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
seat INTEGER NOT NULL,
rank INTEGER, -- 1 = 1등, 무승부는 같은 순위
score REAL,
PRIMARY KEY (game_pk, user_id)
);
CREATE INDEX game_players_user ON game_players(user_id);
CREATE TABLE user_stats (
user_id TEXT NOT NULL,
game_id TEXT NOT NULL,
played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, game_id)
);
```
## 3. 쓰기 시점
| 사건 | 쓰기 |
|---|---|
| 방 생성 | `rooms` insert, `room_state` insert |
| 대기실 변경(자리, 옵션, 준비) | `room_state` update(+ `room_log`) |
| 게임 시작 | `games` insert, `room_state`, `room_log` |
| 게임 행동 / 시간 초과 | `room_log` insert + `room_state` update (한 트랜잭션) |
| 게임 끝 | `games` update(결과, 시드 공개), `game_players`, `user_stats` upsert (한 트랜잭션) |
| 방 닫힘 | `rooms.status='closed'`, `room_state` 삭제 |
## 4. 정리 작업(매시간)
- 만료된 세션 삭제.
- 90일 지난 `room_log` 삭제(결과는 `games`에 남음).
- 1년 이상 접속 없는 게스트: 사용자·전적 삭제.
- 닫힌 지 24시간이 지난 방의 코드는 자연히 재사용 가능(부분 유니크 인덱스).
## 5. 개인정보
- 저장하는 개인정보: 닉네임, 디스코드 사용자 ID/이름/아바타 해시, 접속 시각, User-Agent(세션 관리용).
- IP는 DB에 저장하지 않고 속도 제한용 메모리에만 둔다.
- 계정 삭제 시 `users.deleted_at` 설정 + 닉네임을 "탈퇴한 사용자"로, OAuth 연결·세션 삭제. 다른 사람의 전적 표시가 깨지지 않게 행은 유지.

117
docs/09-game-engine.md Normal file
View File

@@ -0,0 +1,117 @@
# 09. 게임 엔진 공통 설계
## 1. 목표
- 게임 규칙은 **순수 함수 모듈**로 만들고, 방·네트워크·저장은 공통 코드(GameRunner)가 맡는다. 새 게임 추가 = 규칙 모듈 + 화면 컴포넌트만 작성.
- 같은 입력이면 항상 같은 결과(결정적). 그래서 기록 재생, 버그 재현, 무작위 대국 테스트가 가능하다.
## 2. 인터페이스 (`packages/engine/src/types.ts`)
```ts
export type PlayerId = string;
export interface Rng {
next(): number; // [0,1)
int(maxExclusive: number): number;
shuffle<T>(arr: readonly T[]): T[];
}
export interface GameEvent { type: string; [k: string]: unknown } // 애니메이션·로그용, 공개 정보만
export interface GameResult {
ranking: PlayerId[][]; // [[1등], [공동 2등, 공동 2등], ...]
scores?: Record<PlayerId, number>;
summary: string; // "흑 5목 완성으로 OO님 승리"
reason: 'normal' | 'resign' | 'timeout' | 'abandoned' | 'draw-agreed';
}
export interface GameDefinition<S, A, V, O> {
id: string;
nameKo: string;
minPlayers: number;
maxPlayers: number;
stateVersion: number;
defaultOptions: O;
optionsSchema: ZodType<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 };
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)
onTimeout(state: S, player: PlayerId): A;
onLeave?(state: S, player: PlayerId): A | null; // 기권·자리 비움 처리
result(state: S): GameResult | null;
migrate?(old: unknown, fromVersion: number): S;
}
```
### 규칙
- `apply`는 `validate`가 통과한 행동만 받는다. `apply` 안에서도 불변식이 깨지면 예외를 던진다(GameRunner가 잡음).
- 상태는 **불변 객체처럼** 다룬다(새 객체 반환). 구현은 `structuredClone` 후 수정해도 된다(상태가 작음).
- 상태는 JSON으로 직렬화 가능해야 한다(Map/Set/클래스 금지). 저장·복구가 그대로 된다.
- `view`는 숨김 정보를 빼고, 화면에 필요한 계산 결과(예: 둘 수 있는 칸 목록 `legal`, 현재 족보 이름)를 넣어 준다. 클라이언트가 규칙을 몰라도 그릴 수 있어야 한다.
- 차례 확인을 포함한 **모든 합법성 판단은 `validate`가 한다.** `activePlayers`는 "지금 행동을 기다리는 사람"(화면 표시·타이머용)일 뿐, 러너가 이것으로 행동을 막지 않는다(기권·무르기 요청처럼 차례와 무관한 행동이 있기 때문).
- `onTimeout`이 만드는 시스템 전용 행동(예: `{type:'timeout'}`)은 `actionSchema`에 넣지 않는다. 그래서 클라이언트는 보낼 수 없고 서버 타이머만 만든다.
- 시간은 `ctx.now`로만 받는다. 타이머가 필요한 게임은 상태에 `turnStartedAt`, `timeLeft` 같은 값을 둔다.
## 3. 결정적 난수
- 알고리즘: sfc32(32비트 4개 상태). 시드는 서버가 `crypto.getRandomValues`로 128비트 생성.
- GameRunner가 저장된 RNG 상태로 `Rng`를 만들어 `ctx.rng`로 넘기고, 호출이 끝나면 새 RNG 상태를 저장한다. 게임 모듈은 RNG 상태를 직접 들고 있지 않는다.
- 셔플은 Fisher–Yates, `rng.int`는 거부 샘플링으로 편향 없이.
## 4. GameRunner (서버, `apps/server/src/rooms/game-runner.ts`)
```
handleAction(actor, cs, action):
if dedupe(actor, cs) return
parsed = def.actionSchema.safeParse(action) → 실패: reject
v = def.validate(state, actor, parsed) → 실패: reject(v.reason) (차례 확인 포함)
{state', events} = def.apply(state, actor, parsed, {rng, now}) (try/catch)
persist(log + state') (동기 트랜잭션)
state = state'; seq++
broadcast views; reschedule timer; if result → finish()
onTimer():
마감 = min(def.deadline(state), 연결 끊긴 사람이면 끊긴 시각 + 방의 유예 시간)
for p in timeoutPlayers where 마감 지남:
a = def.onTimeout(state, p) → handleAction(p, system, a) 와 같은 경로(검증 포함)
```
- "언제나 행동 가능" 행동(할리갈리 종 치기, 도블 정답, 마작 울기 선언, 무르기 요청)은 `validate`가 허용하면 된다(특수 경로 없음).
- 시간 설정(한 수 제한, 개인 시계 등)은 **게임 옵션**으로 각 게임 문서에서 정한다. 방 설정에는 연결 끊김 유예 시간(`graceSec`, 기본 60초)만 있다.
- 서버 재시작 후에는 마감을 "복구 시각 + 30초" 이전에는 집행하지 않는다(재접속 시간).
## 5. 공통 유틸 (`packages/engine`)
| 모듈 | 내용 |
|---|---|
| `rng.ts` | sfc32, 시드 생성/직렬화, shuffle |
| `cards/standard.ts` | 52장 + 조커 트럼프 카드 ID(`"S-A"`, `"H-10"`, `"JK1"`), 무늬·숫자 파싱 |
| `cards/hwatu.ts` | 화투 48장(+보너스패) ID와 속성(월, 광/열끗/띠/피, 쌍피 등). 섯다·고스톱 공용 |
| `turns.ts` | 시계 방향 다음 사람, 살아 있는 사람만 돌기, 선 정하기 |
| `betting.ts` | 베팅 라운드 엔진: 한국식(삥/체크/콜/따당/쿼터/하프/풀/다이) + 노리밋/팟리밋. 사이드 팟 계산. 포커 모든 모드와 섯다가 공유 |
| `grid.ts` | 격자 좌표, 방향, 연속 개수 세기(오목·오셀로·바둑 공용) |
| `testing.ts` | 무작위 대국 러너, 정보 유출 검사기, 직렬화 왕복 검사 |
## 6. 클라이언트 쪽 게임 모듈 (`apps/web/src/games/<id>/`)
```ts
export interface GameUI<V> {
id: string;
Board: React.ComponentType<{ view: V; me: PlayerId | null; act: (a: unknown) => void; pending: boolean }>;
statusText(view: V, me: PlayerId | null): string; // "내 차례예요" 등
OptionsForm?: React.ComponentType<...>; // 대기실 옵션(기본은 스키마로 자동 생성)
Rules: React.ComponentType; // 규칙 설명
}
```
- `games/registry.ts`에서 `lazy(() => import('./omok'))` 형태로 등록 → 게임 화면 코드는 그 게임을 할 때만 내려받는다.
- 게임 목록 메타데이터(이름, 인원, 썸네일, 난이도, 예상 시간)는 `packages/shared/src/catalog.ts` 하나에서 관리하고 서버·웹이 같이 쓴다.
## 7. 공통 테스트 도구 (모든 게임 필수)
1. **규칙 단위 테스트**: 게임 문서 10절의 시나리오를 그대로 테스트로.
2. **무작위 대국**: 시드 1,000개 × 게임 끝까지 무작위 합법 행동(각 게임은 테스트용 `legalActions(state, p)` 제공). 매 단계 확인:
- 예외 없음, `apply` 5ms 이내
- JSON 직렬화 왕복 후 같은 상태
- 같은 시드·같은 행동이면 같은 결과(결정성)
- 게임이 상한 턴 안에 끝남
- 게임별 불변식(카드 총수 보존, 칩 총합 보존 등)
3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공).
4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지.

49
docs/10-testing.md Normal file
View File

@@ -0,0 +1,49 @@
# 10. 테스트 계획
## 1. 원칙
- "될 것 같다"가 아니라 테스트 결과로 완료를 판단한다.
- 큰 테스트(부하, 장애 주입, E2E)를 돌리기 전에는 **먼저 Gitea 저장소에 커밋·푸시**하고 진행한다(사용자 요구사항). 결과는 커밋 해시와 함께 기록.
- 모든 PR/커밋 전: `bun run typecheck && bun run lint && bun test`.
## 2. 테스트 종류
| 종류 | 도구 | 대상 | 언제 |
|---|---|---|---|
| 규칙 단위 | `bun test` | `packages/games/*` | 매 커밋 |
| 무작위 대국/불변식 | `bun test` + `engine/testing.ts` | 모든 게임(시드 1,000개, CI에서는 200개) | 매 커밋 |
| 정보 유출 | 같은 도구 | 숨김 정보 게임 | 매 커밋 |
| 서버 통합 | `bun test` + 실제 WS 클라이언트 | 인증, 방 만들기/입장, 행동, 재접속, 강퇴, 한 판 더 | 매 커밋 |
| 복구 | 통합 테스트 | 서버 인스턴스 종료 → 같은 DB로 재시작 → 상태 동일 | 매 커밋 |
| E2E | Playwright(크로미움 + 모바일 뷰포트) | 게스트 시작 → 방 만들기 → 링크로 두 번째 사용자 입장 → 오목 한 판 끝까지 | 마일스톤마다 |
| 접근성 | axe-core(Playwright) | 주요 화면 | 마일스톤마다 |
| 성능 | Lighthouse(모바일) | 홈, 방 | 마일스톤마다, 예산 위반 시 실패 |
| 부하 | 자체 Bun 스크립트(가짜 클라이언트) | 동시 2,000 연결, 방 500개, 각 방 2초마다 행동 | 마일스톤 M2, M5, 출시 전 |
| 장애 주입 | 스크립트 | 게임 중 서버 강제 종료(SIGKILL), 네트워크 끊김, 느린 클라이언트 | 마일스톤 M2, 출시 전 |
## 3. 핵심 통합 시나리오 (M1에서 반드시 통과)
1. 게스트 A가 방 생성 → 코드 6자리 수신 → B가 코드로 입장 → 자동 착석 → A 시작 → 오목 끝까지 → 결과 저장 확인.
2. B가 링크로 입장(로그인 안 됨) → 닉네임 입력 → 자동 착석.
3. 게임 중 B의 WS를 강제로 끊음 → A 화면에 "연결 끊김" → B 재연결 → 같은 상태·같은 차례 수신.
4. 게임 중 서버 프로세스 SIGKILL → 재시작 → A, B 자동 재연결 → 수 이어서 두기 가능, 이전 수 모두 보존.
5. B가 자기 차례가 아닐 때 `act` 전송 → `reject`, 상태 변화 없음.
6. 같은 `cs`로 두 번 전송 → 한 번만 처리.
7. B가 다른 탭으로 접속 → 옛 탭 `bye: replaced`.
8. 턴 시간 초과 → `onTimeout` 행동 적용.
9. 방장이 나감 → 방장 자동 이전.
10. 초당 100개 메시지 전송 → 제한 동작, 서버 정상.
11. 잘못된 JSON / 스키마 위반 / 16KB 초과 메시지 → 오류 응답 또는 연결 종료, 서버 정상.
12. 디스코드 로그인 콜백 state 불일치 → 거절.
## 4. 부하 테스트 합격 기준
- 동시 연결 2,000, 진행 중 방 500개, 초당 행동 250개에서:
- 행동 → 상대 수신 p95 < 50ms(같은 머신 기준), p99 < 100ms
- 서버 CPU < 70%(4코어 기준), 메모리 < 1GB
- 오류 0, 연결 끊김 0
- 결과는 `docs/reports/load-YYYYMMDD.md`에 커밋 해시와 함께 기록.
## 5. 수동 점검표 (마일스톤마다)
- [ ] 실제 휴대폰(안드로이드 Chrome, 아이폰 Safari)에서 링크로 들어와 한 판
- [ ] 글자 "아주 크게" 설정에서 모든 화면이 깨지지 않음
- [ ] 다크 모드
- [ ] 와이파이 → LTE 전환 중 게임 이어짐
- [ ] 화면 끄고 1분 뒤 켰을 때 자동 복귀
- [ ] 카카오톡으로 링크 공유 시 미리보기 표시

55
docs/11-deployment-ops.md Normal file
View File

@@ -0,0 +1,55 @@
# 11. 배포와 운영
## 1. 구성
```
deploy/
├─ Dockerfile # 멀티 스테이지: bun install → web 빌드 → server 번들 → oven/bun:1-slim 실행 이미지
├─ docker-compose.yml # app + caddy, 볼륨: app-data(SQLite), caddy-data(인증서)
├─ Caddyfile # HTTPS 자동, /ws·/api → app:3000, 나머지 정적 파일
└─ .env.example # 필요한 환경 변수 목록(값 없음)
```
### 환경 변수
| 이름 | 설명 |
|---|---|
| `PUBLIC_ORIGIN` | `https://<도메인>` (Origin 검사, OAuth 리다이렉트, 공유 링크) |
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 앱 |
| `SESSION_SECRET` | 짧은 수명 서명 쿠키(OAuth state)용 32바이트 |
| `DB_PATH` | 기본 `/data/app.db` |
| `PORT` | 기본 3000 |
| `LOG_LEVEL` | info |
### Caddy
- `encode zstd gzip`
- `/assets/*`: `Cache-Control: public, max-age=31536000, immutable` (파일명에 해시)
- `index.html`: `no-cache` (배포 즉시 반영)
- 보안 헤더: CSP(04 문서 7절), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
- WebSocket 프록시는 Caddy 기본 지원. 연결 유지 시간 제한 없음.
## 2. 배포 절차
1. `main`에 커밋·푸시(Gitea `tkrmagid/joke-app`).
2. 서버에서 `git pull && docker compose -f deploy/docker-compose.yml up -d --build`.
3. 옛 컨테이너가 SIGTERM을 받으면 `bye: restart` 전송 후 종료(`stop_grace_period: 15s`), 새 컨테이너가 방 복구 후 시작.
4. 확인: `/healthz`(DB 열림, 방 복구 완료) 200, 로그에 오류 없음, 테스트 방 하나 만들어 오목 한 수.
5. 문제가 있으면 이전 이미지 태그로 되돌림(`docker compose` 이미지에 커밋 해시 태그).
## 3. 개발·테스트 환경
- 로컬: `bun install && bun run dev` → 웹 Vite(5173) + 서버(3000), Vite가 `/api`·`/ws`를 서버로 프록시.
- 디스코드 로그인 없이도 게스트로 모든 기능 테스트 가능(디스코드 값이 없으면 버튼 숨김).
- 공용 테스트 서버(사용자가 직접 접속해 보는 곳)는 배포 위치가 정해지면 같은 compose로 띄운다.
## 4. 백업과 복구
- 매일 04:00 `VACUUM INTO '/data/backup/app-YYYYMMDD.db'`, 14개 보관.
- 복구: 컨테이너 중지 → 백업 파일을 `app.db`로 복사 → 시작. 진행 중이던 방은 백업 시점 상태로 돌아간다.
## 5. 감시
- `/healthz` 1분마다 확인(외부 업타임 감시 또는 호스트 cron), 실패 3번이면 알림(디스코드 웹훅, 선택).
- 지표 `/internal/metrics`는 Caddy에서 외부 차단.
- 로그: Docker json-file, 크기 제한 50MB × 5.
## 6. 정해야 할 것 (사용자 결정 필요)
| 항목 | 선택지 | 기본 제안 |
|---|---|---|
| 서버 위치 | 이 호스트 / .5 Docker 호스트 / 외부 VPS | 개발 중에는 이 호스트에서 테스트, 공개용은 사용자 결정 |
| 도메인 | 예: `game.tkrmagid.kr` | 사용자 결정(DNS 설정 필요) |
| 디스코드 앱 | 개발자 포털에서 생성 → client ID/secret, Redirect URI 등록 | 도메인 정해진 뒤 |

52
docs/12-roadmap.md Normal file
View File

@@ -0,0 +1,52 @@
# 12. 로드맵
각 마일스톤은 "완료 기준"을 모두 통과해야 끝난다. 마일스톤이 끝날 때마다 Gitea에 푸시하고, 큰 테스트(부하·장애 주입·E2E)는 푸시한 커밋에서 돌린다.
## M0. 계획서 (현재)
- `docs/` 전체 작성 → Gitea 푸시.
- 완료 기준: 모든 공통 문서 + 26개 게임 문서 존재, 사용자 확인 항목 정리.
## M1. 플랫폼 뼈대 + 오목
- 모노레포, 공용 타입/스키마, 엔진(RNG·러너·테스트 도구), SQLite·마이그레이션.
- 게스트 로그인, 세션, `/api/me`, 닉네임 변경. (디스코드 로그인은 코드까지 작성, 실제 연결은 앱 정보 받은 뒤)
- 방: 생성, 6자리 코드, 링크 입장, 자동 착석, 준비, 시작, 강퇴, 방장 이전, 관전, 채팅/반응, 한 판 더.
- WS: 재접속, seq, 중복 방지, 속도 제한, 하트비트, 서버 재시작 복구.
- 웹: 홈, 코드 입력, 대기실(코드/링크 복사, 공유, QR), 오목 화면, 결과, 계정, 글자 크기/다크 모드.
- 완료 기준: `10-testing.md` 3절 12개 시나리오 자동 테스트 통과, 오목 규칙 테스트 통과, E2E 1개 통과, 성능 예산 통과.
## M2. 2인 전략 게임 + 안정성 검증
- 체스, 바둑, 장기, 오셀로. 개인 시계(초읽기/증가), 무르기 요청, 무승부 제안, 기권.
- 디스코드 로그인 실제 연결(앱 정보가 준비된 경우).
- 첫 부하 테스트·장애 주입 테스트 + 보고서.
- 테스트 서버 배포(위치가 정해진 경우).
## M3. 카드 게임 기반 + 섯다·포커
- 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟.
- 섯다 전 모드, 포커 전 모드.
- 공정성 확인(시드 해시 공개) 기능.
## M4. 가족 게임
- 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스.
## M5. 마작·고스톱
- 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드.
- 고스톱/맞고.
## M6. 뱅 + 실시간 반응 + 땅따먹기
- 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기.
## M7. 보드게임카페 인기 게임
- 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구.
## M8. 파티 게임
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).
## M9. 다듬기
- PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화.
- 2차 부하 테스트, 필요 시 방 단위 다중 프로세스 분산 검토.
- (선택) 혼자 연습용 간단한 컴퓨터 상대.
## 순서 원칙
- 플랫폼(M1)의 안정성 기능이 먼저다. 게임은 그 위에 하나씩 얹는다.
- 각 게임은 "규칙 모듈 + 테스트 통과 → 화면 → E2E 한 판" 순서로 완성하고 다음 게임으로 간다.
- 진행 중 사용자가 순서를 바꾸고 싶으면 이 문서를 먼저 고친다.

33
docs/13-decisions.md Normal file
View File

@@ -0,0 +1,33 @@
# 13. 사용자 결정이 필요한 항목
아래 항목은 사용자 확인이 필요하다. **답이 없으면 "기본안"으로 진행**하고, 나중에 바꿀 수 있게 옵션으로 만들어 둔다.
## 1. 운영 (진행에 꼭 필요)
| # | 항목 | 기본안 | 필요한 것 |
|---|---|---|---|
| 1 | 사이트 주소(도메인)와 서버 위치 | 개발 중에는 봇 호스트에서 테스트 | 도메인(예: `game.tkrmagid.kr`)과 DNS 연결, 공개 서버 위치 |
| 2 | 디스코드 로그인 | 게스트만으로 먼저 개발 | 디스코드 개발자 포털에서 앱 생성 → Client ID, Client Secret, Redirect URI(`https://<도메인>/api/auth/discord/callback`) 등록 |
## 2. 정책
| # | 항목 | 기본안 |
|---|---|---|
| 3 | 상표가 있는 게임 이름 | 화면에는 일반 명칭 + 괄호로 익숙한 이름(예: "숫자 타일(루미큐브 방식)"). 그림·카드는 모두 직접 제작 |
| 4 | 포커·섯다·고스톱 같은 웹보드 게임 | 국내에서 공개 서비스하면 보통 청소년이용불가 등급 대상이다. 기본안: 이 게임들을 "어른용" 묶음으로 따로 보여 주고, 처음 들어갈 때 "만 19세 이상" 확인을 받는다. 칩·점수는 방 안에서만 쓰고 돈·충전·환전은 없다. 누구나 들어오는 공개 서비스로 열기 전에는 등급분류 필요 여부를 따로 확인해야 한다 |
| 5 | 보석 상인(스플렌더 방식) 카드 수치 | 원작과 같은 수치를 별도 데이터 파일에 둔다(교체 쉬움). 공개 서비스라면 자체 수치로 바꾸기를 권장 |
## 3. 게임 규칙 기본값 (자세한 내용은 각 게임 문서)
| 게임 | 기본안 | 바꿀 수 있는 옵션 |
|---|---|---|
| 오목 | 한국식(흑백 모두 쌍삼 금지, 6목은 승리 아님) | 렌주룰, 자유룰(5목 이상 승 / 정확히 5목) |
| 바둑 | 위치 기준 슈퍼코, 덤 6.5(계가) | 삼패 무승부, 중국식 계가(덤 7.5) |
| 체스 | 3회 반복·50수는 자동 무승부, 라이브러리 chess.js | 엄격한 FIDE 신청 방식. 체스960은 자체 구현으로 후순위 |
| 장기 | 점수 판정·빅장 사용, 같은 국면 4번째 금지 | 동시 비공개 상차림 |
| 포커 | 7포커 베팅은 하프까지, 백스트레이트는 마운틴 다음, 하이로우의 로우는 한게임 방식 | 풀 베팅, 넷마블식 로우, 8탑 제한 |
| 섯다 | 땡잡이는 3광+7열끗, 동점은 재경기, 다음 선은 승자 | 나눠 갖기, 시계 방향 선 |
| 마작 | 반장전, 25000점 시작, 붉은 5 세 장, 쿠이탕 허용 | 동풍전, 3인 산마 |
| 고스톱 | 맞고 7점·고스톱 3점에 고/스톱, 3고부터 2배씩 | 피 0장 피박 인정, 멍박 |
| 윷놀이 | 빽도 사용, 참먹이를 지나야 나감, 윷가락 실제 확률(0.6) | 50:50 공정 윷, 낙 |
| 원카드 | 2→2장, A→3장, ♠A→5장, 조커 5/7장, 20장이면 파산 | 색깔 카드 모드 |
| 할리갈리 | 마지막 1명 남을 때까지(10분 제한) | 원작 종료 규칙 |
| 그림 맞히기 | 한 턴에 여러 명이 맞힐 수 있음(먼저 맞힐수록 높은 점수) | 한 명만 맞히기 |
| 구십구 | 공식 규칙서를 온라인에서 확인하지 못해 일부를 추정해 기본값으로 둠 | 클래식 99 규칙. 가지고 있는 실물 규칙서 사진이 있으면 정확히 맞출 수 있음 |

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# 온라인 보드게임 사이트 계획서
광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 한국형 온라인 보드게임 사이트.
구현은 이 문서들을 기준으로 하며, 결정이 바뀌면 문서를 먼저 고친다.
## 공통 문서
| 문서 | 내용 |
|---|---|
| [00-overview.md](00-overview.md) | 목표, 원칙(안정성·실시간·쉬움·가벼움), 범위, 성공 기준 |
| [01-research.md](01-research.md) | BGA 분석, 한국 인기 보드게임 조사, UI/UX 조사, 출처 |
| [02-architecture.md](02-architecture.md) | 기술 선택, 전체 구조, 저장소 구조, 처리 흐름 |
| [03-realtime-protocol.md](03-realtime-protocol.md) | WebSocket 메시지, 순서 보장, 재접속, 타이머, 제한 |
| [04-stability-security.md](04-stability-security.md) | 저장·복구, 장애 격리, 숨김 정보·공정성, 사행성·상표, 웹 보안 |
| [05-accounts-auth.md](05-accounts-auth.md) | 게스트·디스코드 로그인, 닉네임, 세션, 계정 페이지 |
| [06-lobby-rooms.md](06-lobby-rooms.md) | 방 만들기/참여, 코드·링크·QR·공유, 대기실, 빠른 시작, 한 판 더 |
| [07-ui-ux.md](07-ui-ux.md) | 디자인 원칙, 색·글자 토큰, 화면 틀, 컴포넌트, 접근성, 성능 예산 |
| [08-data-model.md](08-data-model.md) | SQLite 스키마, 쓰기 시점, 정리, 개인정보 |
| [09-game-engine.md](09-game-engine.md) | 게임 모듈 인터페이스, 결정적 난수, GameRunner, 공통 유틸, 공통 테스트 |
| [10-testing.md](10-testing.md) | 테스트 종류, 핵심 시나리오, 부하 기준, 수동 점검표 |
| [11-deployment-ops.md](11-deployment-ops.md) | Docker/Caddy 배포, 환경 변수, 백업, 감시, 결정 필요 항목 |
| [12-roadmap.md](12-roadmap.md) | 마일스톤 M0~M9와 완료 기준 |
| [13-decisions.md](13-decisions.md) | 사용자 결정이 필요한 항목과 기본안 |
## 게임 문서
[games/README.md](games/README.md) — 전체 게임 목록, 우선순위, 제외 이유, 상표 표시 방침.
게임별 문서는 `games/<id>.md`, 형식은 [games/_TEMPLATE.md](games/_TEMPLATE.md).

56
docs/games/README.md Normal file
View File

@@ -0,0 +1,56 @@
# 게임 목록
각 게임의 상세 규칙·엔진 설계·화면·테스트는 같은 폴더의 `<id>.md`. 모든 문서는 `_TEMPLATE.md` 형식을 따른다.
## 1. 1순위: 사용자가 지정한 게임
| id | 이름 | 인원 | 모드 | 마일스톤 |
|---|---|---|---|---|
| `omok` | 오목 | 2 | 자유룰 / 렌주룰 / 한국식(쌍삼 금지) | M1 |
| `chess` | 체스 | 2 | 표준, 체스960 | M2 |
| `baduk` | 바둑 | 2 | 9·13·19줄, 접바둑 | M2 |
| `seotda` | 섯다 | 2~10(모드별) | 2장 섯다, 3장 섯다, 특수패 옵션 | M3 |
| `poker` | 포커 | 2~10(모드별) | 텍사스 홀덤, 7포커, 7포커 하이로우, 바둑이, 인디언 포커, 5카드 드로우, 오마하 | M3 |
| `mahjong` | 마작 | 3~4 | 리치 마작 4인(동풍전/반장전), 3인 산마 | M5 |
| `bang` | 뱅 | 4~7 | 기본판 | M6 |
## 2. 2순위: 한국 전통·국민 게임 + 보드게임카페 인기 상위
| id | 이름 | 인원 | 선정 이유 | 마일스톤 |
|---|---|---|---|---|
| `janggi` | 장기 | 2 | 한국 전통 2인 전략 | M2 |
| `othello` | 오셀로 | 2 | 규칙 쉬움, 어린이·어르신 | M2 |
| `yut` | 윷놀이 | 개인 2~4, 팀전 2팀 최대 8 | 명절 국민 게임 | M4 |
| `one-card` | 원카드 (+ 색깔 카드 모드) | 2~9 (색깔 카드 2~10) | 판매 2위(우노/원카드) | M4 |
| `gostop` | 고스톱 / 맞고 | 2~5 (맞고 2, 고스톱 3, 광팔기 4~5) | 한국 대표 화투 게임 | M5 |
| `rummikub` | 숫자 타일(루미큐브 방식) | 2~4 | 판매 3위, 카페 2위 | M4 |
| `davinci-code` | 다빈치 코드 | 2~4 | 카페 1위, 판매 6위 | M4 |
| `yacht` | 요트 다이스 | 1~8 | 카페 7위, 쉬움 | M4 |
| `halli-galli` | 할리갈리 | 2~6 | 판매 1위(실시간 반응) | M6 |
| `marble` | 주사위 땅따먹기(부루마블 방식) | 2~4 | 판매 9위, 국민 게임 | M6 |
## 3. 3순위: 보드게임카페·파티 인기
| id | 이름 | 인원 | 마일스톤 |
|---|---|---|---|
| `dalmuti` | 달무티 | 4~8 | M7 |
| `splendor` | 보석 상인(스플렌더 방식) | 2~4 | M7 |
| `quoridor` | 쿼리도 | 2, 4 | M7 |
| `las-vegas` | 라스베가스 | 2~5 | M7 |
| `cockroach-poker` | 바퀴벌레 포커 | 2~6 | M7 |
| `ninety-nine` | 구십구 | 2~10 | M7 |
| `dobble` | 도블(같은 그림 찾기) | 2~8 | M8 |
| `catchmind` | 그림 맞히기 | 2~10 | M8 |
| `mafia` | 마피아 | 4~12 | M8 |
인원은 각 게임 문서에서 원작 기준으로 최종 확정한다(이 표와 다르면 게임 문서가 우선).
## 4. 제외한 게임과 이유
| 게임 | 이유 |
|---|---|
| 젠가, 루핑루이, 할리갈리 컵스, 햄버거 타이쿤, 라쿠카라차 | 손 조작·장난감 장치가 핵심이라 화면으로 옮기면 재미가 사라짐 |
| 우봉고, 김밥 요리사 | 실물 조각/손동작 속도가 핵심. 후순위로 화면용 변형을 검토할 수 있음 |
| 텔레스트레이션 | 그림 맞히기(`catchmind`)와 겹침. 이후 모드로 추가 검토 |
| 커피 러시 | 국내 작가 신작, 규칙·구성물 저작권 정리가 필요. 사용자 요청 시 검토 |
| 글룸헤이븐, 테라포밍 마스 등 대형 전략 게임 | 플레이 시간이 길고 구성 요소가 많아 "가볍고 쉬운" 목표와 맞지 않음 |
| 5초 준다, 갈팡질팡 | 말로 하는 파티 게임(음성 필요) |
## 5. 상표·이름 표시 (사용자 결정 필요)
루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 모두의 마블/부루마블, 도블, 쿼리도, 달무티 등은 제품 상표다. 친구끼리 쓰는 비공개 사이트라면 익숙한 이름이 가장 쉽지만, 누구나 들어오는 공개 서비스라면 일반 명칭(예: "숫자 타일 게임")을 쓰고 설명에 "OO과 비슷한 규칙"이라고만 적는 편이 안전하다. 기본안: **화면 이름은 일반 명칭 + 괄호로 익숙한 이름**(예: "숫자 타일(루미큐브 방식)"), 그림·카드 디자인은 모두 직접 제작.

43
docs/games/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,43 @@
# <게임 한국어 이름> (`<game-id>`)
> 마일스톤: M? · 인원: 최소 N ~ 최대 M명 · 예상 시간: 약 N분 · 난이도: 쉬움/보통/어려움
## 1. 개요
- 한 줄 설명, 한국에서의 인지도/플레이 맥락
- 인원 근거(원작/전통 규칙의 최소·최대 인원)
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
## 3. 구성물
## 4. 준비(셋업)
## 5. 진행 규칙
- 턴 구조, 가능한 행동, 각 행동의 합법 조건을 빠짐없이
## 6. 승패와 점수 계산
- 정확한 계산식과 예시, 동점 처리
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
## 8. 엔진 설계
### 8.1 상태(State)
```ts
// 서버만 보는 전체 상태
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
### 8.3 공개/비공개 정보 (view)
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
### 8.6 종료 조건과 결과(GameResult)
## 9. UI/UX
- 화면 배치(모바일 세로 / PC), 조작 방법, 합법 수 하이라이트, 애니메이션, 초보자 도움말
## 10. 테스트 체크리스트
- [ ] 구체적인 시나리오
## 11. 참고 자료
## 12. 메모 (상표·법적 주의 등)

272
docs/games/baduk.md Normal file
View File

@@ -0,0 +1,272 @@
# 바둑 (`baduk`)
> 마일스톤: M2 · 인원: 최소 2 ~ 최대 2명 · 예상 시간: 9줄 약 10~20분, 13줄 약 30분, 19줄 약 60~120분 · 난이도: 어려움
## 1. 개요
- 흑과 백이 번갈아 교차점에 돌을 놓아 더 많은 집(영역)을 차지하는 2인 전략 게임. 한국에서 어린이 교육(바둑 학원)부터 어르신 여가까지 폭넓게 즐기며, 한국기원 규칙(일본식과 같은 계가 방식, 덤 6집 반)이 국내 표준이다.
- 인원 근거: 원작이 흑·백 2인 게임. 최소 2, 최대 2. 그 외는 관전자.
- 완전 정보 게임. 숨길 정보는 없고 서버 내부 값(RNG 상태)만 view에서 제외한다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `boardSize` | 판 크기 | `9` / `13` / `19` | `19` (방 만들기 화면에서 초보에게 9줄 추천 문구) |
| `scoring` | 계가 방식 | `territory`(한국·일본식 집 계산) / `area`(중국식 영역 계산) | `territory` |
| `komi` | 덤 (백에게 더하는 점수, 0.5 단위, -10 ~ 10) | 숫자 | 맞바둑: `territory` 6.5, `area` 7.5 / 접바둑: 0.5 |
| `handicap` | 접바둑 치석 수 (흑이 미리 놓는 돌) | 0, 2~9 (9줄은 0, 2~5) | 0 |
| `koRule` | 패 규칙 | `positionalSuperko`(동형 반복 금지) / `korean`(단순 패 + 삼패 등 장생형 반복 시 무승부) | `positionalSuperko` |
| `colorAssignment` | 흑백 결정 | `nigiri`(무작위) / `hostBlack` / `hostWhite` | `nigiri` (접바둑이면 방장이 하수/상수 지정) |
| `timeControl.kind` | 시간 방식 | `none` / `byoyomi`(기본 시간 + 초읽기) / `fischer` | `byoyomi` |
| `timeControl.mainSec` | 기본 시간 | 0 ~ 3600 | 9줄 180, 13줄 600, 19줄 1200 |
| `timeControl.periodSec` | 초읽기 1회 시간 | 10 / 20 / 30 / 60 | 30 |
| `timeControl.periods` | 초읽기 횟수 | 1 ~ 5 | 3 |
| `timeControl.incSec` | `fischer` 수당 추가 | 0 ~ 30 | 10 |
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` |
| `atariWarning` | 단수 경고 힌트(내 돌이 단수에 몰리면 표시) | `true` / `false` | `true` |
| `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 |
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 |
## 3. 구성물
- 판: 9×9, 13×13, 19×19 교차점. 화점(별 표시): 9줄 C3·G3·C7·G7·E5, 13줄 D4·K4·D10·K10·G7, 19줄 D4·K4·Q4·D10·K10·Q10·D16·K16·Q16.
- 좌표 표기: 열은 A부터, `I`를 건너뛴다(A B C D E F G H J K …). 행은 아래에서 위로 1부터. 내부 좌표 `(x, y)`: `x` 왼→오 0부터, `y` 위→아래 0부터.
- 흑돌·백돌: 개수 제한 없음. 따낸 돌(사석/포로) 수를 각자 센다.
## 4. 준비(셋업)
1. 흑백 결정: `nigiri`면 시드 RNG로 무작위. 접바둑이면 하수가 흑.
2. 맞바둑: 빈 판, 흑이 먼저 둔다.
3. 접바둑(`handicap = N`): 흑이 아래 고정 위치에 N개를 놓고(엔진이 자동 배치), 백이 먼저 둔다. 위치(흑 기준, 한국·일본 관례):
| N | 19줄 | 13줄 | 9줄 |
|---|---|---|---|
| 2 | Q16, D4 | K10, D4 | G7, C3 |
| 3 | + Q4 | + K4 | + G3 |
| 4 | + D16 | + D10 | + C7 |
| 5 | 4점 + K10 | 4점 + G7 | 4점 + E5 |
| 6 | 4점 + D10, Q10 | 4점 + D7, K7 | — |
| 7 | 6점 + K10 | 6점 + G7 | — |
| 8 | 4점 + D10, Q10, K4, K16 | 4점 + D7, K7, G4, G10 | — |
| 9 | 8점 + K10 | 8점 + G7 | — |
(참고: GTP 프로토콜은 3점의 세 번째 돌을 D16에 두는 등 관례가 다르다. 본 서비스는 위 표를 고정 사용한다.)
4. 덤: 맞바둑 기본 6.5(`area`는 7.5). 접바둑은 0.5(무승부 방지). `area` 접바둑은 추가로 백에게 N점 보정(중국 규칙의 치석 보정, 9.2절 참고).
5. 시계 초기화, 포로 0, `positionHistory`에 초기 국면 해시 추가.
## 5. 진행 규칙
### 5.1 턴
- 차례인 플레이어는 착수 또는 패스 중 하나를 한다.
- 용어: 연결(chain) = 상하좌우로 이어진 같은 색 돌 묶음. 활로(liberty) = 연결에 상하좌우로 맞닿은 빈 교차점.
### 5.2 착수 처리 순서 (서버)
1. 빈 교차점인지 확인.
2. 돌을 놓는다.
3. 놓은 돌에 맞닿은 상대 연결 중 활로가 0인 것을 모두 판에서 들어내고 내 포로에 더한다.
4. 놓은 돌이 속한 내 연결의 활로가 0이면 자충수(자살수) → 불법. 원상복구하고 거부.
5. 패 규칙 검사 (5.3). 위반이면 원상복구하고 거부.
6. 확정: 기보 기록, 국면 해시를 `positionHistory`에 추가, 연속 패스 카운트 0, 차례 넘김.
### 5.3 패(ko) 규칙 — 선택과 근거
- 기본값 `positionalSuperko`(동형 반복 금지): 착수 결과의 판 전체 배치(차례와 무관, 돌 색 배치만)가 이 대국에서 이전에 한 번이라도 나왔던 배치와 같으면 그 착수는 불법. 단순 패(바로 되따기 금지)는 이 규칙에 자동 포함된다.
- 근거 1: 한국기원 규칙의 삼패·사패·장생 처리("어느 쪽도 양보하지 않으면 무승부/무효")는 심판 판단이 필요한데, 온라인에서는 판정할 심판이 없고 어린이·어르신에게 "판 무효"는 납득하기 어렵다.
- 근거 2: 동형 반복 금지는 중국·AGA·뉴질랜드·Tromp-Taylor 규칙에서 쓰는 검증된 방식이고, 해시 집합 조회만으로 결정적으로 판정 가능하다. 무한 반복이 원천 봉쇄되어 서버 안정성에도 유리하다.
- 근거 3: 실전에서 단순 패 이외의 동형 반복은 극히 드물어, 한국식과 결과가 달라지는 경우가 거의 없다.
- 패스는 판을 바꾸지 않으므로 슈퍼코 검사 대상이 아니다(항상 합법).
- 옵션 `korean`: 단순 패만 금지(직전 상대 착수로 돌 1개를 따냈고 그 돌이 1점 단독 연결이며, 내가 그 자리에 두어 정확히 그 돌 하나를 되따는 경우 금지). 판 전체 배치 + 둘 차례가 같은 국면이 세 번째로 나타나면 즉시 무승부(`reason: 'repetition'`)로 종료(삼패·순환패·장생에 해당).
- 해시: 길이 `size*size` 문자열(`.`, `b`, `w`) 그대로를 Set에 저장하거나, 고정 상수 테이블의 Zobrist 64비트 해시(게임 RNG와 무관한 상수)를 쓴다. 19줄 300수 기준 문자열 방식도 약 110KB로 충분히 작다.
### 5.4 패스와 종국
- 패스는 언제나 합법. 패스가 연속 2번(흑·백 각 1번) 나오면 대국 정지 → 사석 확인 단계(`scoring`)로 전환.
- `korean`·`territory`에서 패스로 상대에게 포로를 주지 않는다(일본식처럼 패스는 무료). `area`도 마찬가지.
### 5.5 사석 확인 단계 (사활 합의)
1. 진입 시 서버가 "사석 후보"를 자동 제안한다(아래 휴리스틱). 제안은 참고용이며 최종은 합의로 정한다.
2. 두 플레이어는 연결(정확히는 같은 색의 "그룹": 같은 색 돌들 중 빈칸·상대 사석만을 거쳐 이어지는 묶음)을 탭해서 사석/생석을 토글한다. 빈 영역을 탭해서 빅(세키) 표시를 토글할 수 있다.
3. 표시가 바뀌면 양쪽 `agreed`가 모두 해제된다. 현재 표시를 기준으로 계가 미리보기가 실시간으로 갱신된다.
4. 두 플레이어가 같은 표시 상태에 대해 모두 "동의"하면 계가 확정 → 종료.
5. 어느 한쪽이 "이의 – 대국 재개"를 누르면 표시를 모두 버리고 `playing`으로 복귀한다. 재개 후 첫 수는 재개를 요청한 사람의 상대가 둔다(일본기원 규약 제9조와 같은 방식). 재개 후 다시 연속 2패스가 나오면 5.5를 다시 진행한다.
6. 사석 확인 단계에서 시계는 멈춘다. 대신 `scoringDeadlineSec` 제한이 있다(8.5).
- 자동 제안 휴리스틱(단순·결정적): (a) 활로 1개인 연결 중 그 활로에 상대가 두면 따내지고 되따기도 불가능한 것, (b) 상대 영역(상대 돌로만 둘러싸인 빈 영역)에 완전히 갇혀 있고 그 영역 안에서 두 눈(서로 떨어진 두 개 이상의 단독 빈 영역)이 없는 연결. 오판이 있어도 플레이어가 고치면 되므로 단순하게 둔다. 향후 KataGo 같은 엔진 기반 추정은 별도 작업으로 분리.
### 5.6 공배와 빅(세키)
- 공배(dame): 흑·백 양쪽 생석에 모두 닿은 빈 영역. 아무의 집도 아니다.
- 빅(세키): 서로 따낼 수 없이 공배를 공유하며 사는 형태. `territory`에서는 빅 안의 집(눈)은 집으로 세지 않는다(한국기원·일본 규칙). `area`에서는 빅의 돌과 그 눈은 해당 색 영역으로 센다.
- 빅 자동 추정(사석 확인 단계 진입 시 제안): 한 색 C의 돌로만 둘러싸인 빈 영역 R에 대해, R을 둘러싼 C 연결 중 하나라도 "R 밖의 활로가 모두 공배이고 그 공배가 상대 생석 연결의 활로이기도 한" 경우 R을 빅 후보로 표시. 플레이어가 최종 수정한다.
## 6. 승패와 점수 계산
### 6.1 계가 알고리즘 (공통 전처리)
1. 합의된 사석을 판에서 제거하고, 제거한 돌 수를 상대의 포로에 더한다(`territory`에서만 의미 있음).
2. 빈 교차점을 상하좌우 연결로 영역 분할(flood fill).
3. 각 영역의 경계 돌 색 집합을 구한다.
- 흑만 → 흑 영역, 백만 → 백 영역, 둘 다 또는 없음(빈 판) → 중립(공배).
- 빅으로 표시된 영역 → `territory`에서는 중립, `area`에서는 경계 색의 영역.
### 6.2 `territory` (한국·일본식, 기본)
- 흑 점수 = 흑 영역 교차점 수 + 흑이 따낸 돌(대국 중 + 사석)
- 백 점수 = 백 영역 교차점 수 + 백이 따낸 돌 + 덤
- 한국 기원식 실물 계가(따낸 돌로 상대 집 메우기)와 결과가 같다: `(흑집 - 백포로) - (백집 - 흑포로) = (흑집 + 흑포로) - (백집 + 백포로)`.
- 자기 집 안에 착수하면 그 점은 집이 아니게 되므로 1집 손해, 상대 집 안에 착수해 결국 사석이 되면 상대 포로 +1 — 일본식 규칙과 동일하게 자연히 반영된다.
### 6.3 `area` (중국식)
- 흑 점수 = 판 위 흑 생석 수 + 흑 영역 수
- 백 점수 = 판 위 백 생석 수 + 백 영역 수 + 덤 (+ 접바둑이면 치석 수 N)
- 포로는 세지 않는다. 공배는 아무에게도 주지 않는다(중국 규칙의 공배 나누기는 쓰지 않음; 모든 공배를 메운 결과와 같아지도록 사용자에게 "공배를 메운 뒤 패스하세요" 안내).
### 6.4 예시 (9줄)
- 흑이 E열(x=4) 전체 9개, 백이 F열(x=5) 전체 9개를 이어 판을 둘로 나눔. 흑 쪽(A~D열 36점) 안에 백 사석 1개(B5). 대국 중 흑이 따낸 돌 2개, 백이 따낸 돌 3개.
- `territory`, 덤 6.5: 흑 = 36(B5 포함) + 2 + 1 = 39, 백 = 27(G~J열) + 3 + 6.5 = 36.5 → 흑 2.5집 승.
- `area`, 덤 7.5: 흑 = 9 + 36 = 45, 백 = 9 + 27 + 7.5 = 43.5 → 흑 1.5점 승.
### 6.5 승패와 동점
- 점수가 높은 쪽 승. 표시: "흑 2집 반 승"(`territory`) / "흑 1.5점 승"(`area`).
- 덤이 정수일 때만 동점(빅/무승부, `reason: 'jigo'`) 가능. 기본 덤은 0.5 단위라 무승부 없음.
- 그 외 종료: 불계(기권, `resign`), 시간패(`timeLoss`), 무승부 합의(`drawAgreed`), `koRule=korean`의 반복 무승부(`repetition`).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 한국기원식(기본): `territory` + 덤 6.5 + 빅 안의 집 불인정. 패 규칙만 동형 반복 금지로 단순화(5.3 근거). 순수 한국기원식을 원하면 `koRule=korean`.
- 중국식: `area` + 덤 7.5 + 접바둑 치석 보정.
- 덤 없는 바둑(친선 "맞바둑 덤 없음"): `komi = 0` (무승부 가능).
- 접바둑 자유 치석(흑이 원하는 곳에 배치)은 v1 미지원.
- 초읽기 대신 피셔 방식도 선택 가능.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Color = 'black' | 'white';
type Cell = 0 | 1 | 2;
type Pt = { x: number; y: number };
interface BadukState {
options: BadukOptions;
size: 9 | 13 | 19;
board: Cell[]; // y*size + x
players: Record<Color, PlayerId>;
turn: Color;
phase: 'playing' | 'scoring' | 'finished';
captures: Record<Color, number>; // 각 색이 따낸 상대 돌 수(대국 중)
consecutivePasses: number;
moves: ({ kind: 'place'; color: Color; pt: Pt; captured: Pt[] } | { kind: 'pass'; color: Color })[];
positionHistory: string[]; // 슈퍼코: 착수 결과 배치 해시 (Set으로 재구성)
situationCounts: Record<string, number>; // koRule=korean: (배치+차례) 등장 횟수
koPoint: Pt | null; // koRule=korean의 단순 패 금지점
scoringMarks: {
dead: number[]; // 사석으로 표시된 교차점 index
seki: number[]; // 빅으로 표시된 빈 영역의 대표 index
agreed: Record<Color, boolean>;
startedAt: number;
resumeRequestedBy: Color | null;
} | null;
clock: {
mainMs: Record<Color, number>;
periodsLeft: Record<Color, number>;
inByoyomi: Record<Color, boolean>;
turnStartedAt: number;
};
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
draw: { pendingBy: Color | null; lastOfferMove: Record<Color, number> };
result: BadukResult | null;
rng: RngState;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `place` | `{ x, y }` | 차례인 플레이어, `playing` | 판 안, 빈칸, 자충수 아님("둘 곳의 활로가 없어 둘 수 없어요"), 패 규칙 위반 아님("패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요" / "같은 모양이 반복되어 둘 수 없어요") |
| `pass` | `{}` | 차례인 플레이어, `playing` | 항상 가능. 연속 2회째면 `scoring` 진입 |
| `toggleDead` | `{ x, y }` | 두 플레이어, `scoring` | 그 점에 돌이 있어야 함. 해당 그룹 전체 토글, 양쪽 `agreed` 해제 |
| `toggleSeki` | `{ x, y }` | 두 플레이어, `scoring` | 빈 교차점. 해당 빈 영역 토글, `agreed` 해제 |
| `agreeScore` | `{}` | 두 플레이어, `scoring` | 본인 `agreed = true`. 둘 다 true면 계가 확정, 종료 |
| `resumePlay` | `{}` | 두 플레이어, `scoring` | 표시 초기화, `playing` 복귀, 차례 = 요청자의 상대, `consecutivePasses = 0` |
| `requestUndo` | `{}` | 플레이어, `playing` | `undo != off`, 횟수 남음, 대기 요청 없음, 본인 수(착수 또는 패스) 존재. 1수 또는 2수 |
| `respondUndo` | `{ accept }` | 상대 | 수락 시 `moves`를 처음부터 재생해서 판·포로·`positionHistory`·`koPoint` 재구성(역연산보다 안전). 시계는 되돌리지 않음 |
| `offerDraw` | `{}` | 플레이어, `playing` | `drawOffer=true`, 대기 없음, 직전 거절 이후 본인 5수 이상 |
| `respondDraw` | `{ accept }` | 상대 | 수락 → 무승부 |
| `cancelOffer` | `{}` | 요청자 | 대기 요청 취소 |
| `resign` | `{}` | 플레이어, `playing`/`scoring` | 언제든 |
| `timeout` | `{}` | 시스템 | 8.5 |
- 대기 요청은 요청받은 쪽이 `place`/`pass`를 하면 자동 거절.
- 시계 처리(`place`/`pass` 시, `byoyomi`): `elapsed = now - turnStartedAt`. 기본 시간이 남아 있으면 먼저 차감, 부족분은 초읽기에서 처리: 초읽기 1회 시간 안이면 그 회차 유지(다음 수에 다시 전체 `periodSec`), 넘기면 넘긴 만큼 회차 소진. `periodsLeft`가 0이 되는 순간 이미 시간패이므로 그 액션은 거부.
- 이벤트: `stonePlaced`(따낸 좌표 포함), `captured`, `pass`, `atari`(힌트용), `scoringStarted`, `marksChanged`, `playResumed`, `gameEnded` 등.
### 8.3 공개/비공개 정보 (view)
- 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과.
- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점), `atariWarnings`.
- view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `colorAssignment=nigiri`의 흑백 결정 1회뿐. 대국 진행에는 RNG를 쓰지 않는다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`:
- `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn] * periodSec*1000` (현재 회차를 다 쓰면 다음 회차가 이어지므로 이 합이 최종 시간패 시각)
- `playing` + `fischer`: `turnStartedAt + mainMs[turn]`
- `playing` + `none`: `turnStartedAt + idleLimitSec*1000`
- `scoring`: `scoringMarks.startedAt + scoringDeadlineSec*1000`
- `finished`: `null`
- `activePlayers`: `playing`이면 차례인 1명. `scoring`이면 아직 `agreed`하지 않은 플레이어 전원(동시 행동 단계).
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply` 처리:
- `playing`: 해당 플레이어 시간패 (`timeLoss`). 바둑은 자동 착수를 하지 않는다(무작위 수는 판을 망치므로).
- `scoring`: 시간 내 합의하지 못한 플레이어는 현재 표시에 동의한 것으로 간주(`agreeScore`). 두 사람 모두 미응답이면 현재 표시대로 계가 확정. 표시가 비어 있으면 모든 돌을 생석으로 계가.
- 연결 끊김: 서버는 끊긴 플레이어가 행동해야 할 때 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout` 호출. 시간 제한이 긴 19줄에서도 방치가 길어지지 않게 하기 위함.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface BadukResult {
ranking: { playerId: PlayerId; rank: 1 | 2 }[]; // 무승부면 둘 다 1
winner: Color | null;
reason: 'score' | 'jigo' | 'resign' | 'timeLoss' | 'drawAgreed' | 'repetition';
score?: {
method: 'territory' | 'area';
black: { territory: number; captures: number; stones: number; total: number };
white: { territory: number; captures: number; stones: number; komi: number; handicapComp: number; total: number };
margin: number; // 승자 기준 차이 (예: 2.5)
ownership: (0 | 1 | 2)[]; // 교차점별 귀속(표시용)
};
summaryKo: string; // 예: "흑 2집 반 승", "백 불계승", "흑 시간승"
}
```
## 9. UI/UX
- 모바일 세로: 상단 상대 정보(따낸 돌, 시계 + 초읽기 남은 횟수 점 3개), 가운데 정사각 판, 하단 내 정보와 버튼(패스, 무르기, 무승부, 기권, 규칙 보기).
- 19줄은 교차점 간격이 약 18px라 "탭 → 미리보기 돌 + 확대 돋보기 → 확인 버튼" 2단계 착수가 기본. 9줄은 한 번 탭 착수 옵션 제공.
- 마지막 수 표시, 따낸 돌 사라지는 애니메이션과 효과음(끌 수 있음), 단수 경고(옵션) 시 해당 연결 테두리 깜빡임.
- 패/자충 금지점을 탭하면 이유 말풍선.
- 초읽기 진입 시 음성/효과음 카운트("10, 9, …")와 화면 테두리 색 변화(어르신 배려: 큰 숫자).
- 패스 버튼은 실수 방지를 위해 확인 모달. 상대가 패스하면 "상대가 패스했어요. 끝내려면 패스하세요" 안내.
- 사석 확인 단계: 판 위에 사석은 반투명 + ×, 집은 작은 사각 표시, 하단에 "흑 39 : 백 36.5" 미리보기, 큰 "동의" / "이의(대국 재개)" 버튼. 상대가 표시를 바꾸면 바뀐 그룹을 강조.
- 규칙 보기: 따내기, 자충수, 패, 집 세는 법 그림 설명. 초보 모드에서 9줄 추천.
- 관전자: 계가 미리보기 보기 가능, 버튼 없음.
## 10. 테스트 체크리스트
- [ ] 1점 따내기, 여러 연결 동시 따내기, 가장자리·귀에서 따내기.
- [ ] 자충수 거부. 단, 놓는 순간 상대를 따내서 활로가 생기는 수는 합법.
- [ ] 단순 패: 따낸 직후 바로 되따기 거부, 다른 곳 한 수(팻감) 후 되따기 허용.
- [ ] `positionalSuperko`: 삼패 순환에서 이전 배치를 재현하는 수가 거부되는지.
- [ ] `koRule=korean`: 같은 국면(배치+차례) 3회째에 무승부 종료.
- [ ] 연속 2패스 → `scoring`. 패스-착수-패스는 진입 안 함.
- [ ] 사석 토글 후 상대가 동의해 있던 상태가 해제되는지, 두 사람 동의 시 계가 확정.
- [ ] 이의 → 재개 후 첫 수 차례가 요청자의 상대인지, 재개 후 다시 2패스 시 재진입.
- [ ] 6.4 예시 국면에서 `territory`/`area` 점수가 39 : 36.5, 45 : 43.5로 계산.
- [ ] 빅 영역: `territory`에서 빅 안 눈이 집으로 안 세어지고, `area`에서 세어짐.
- [ ] 접바둑 N=2~9 배치가 표와 일치, 백이 먼저 둠, 덤 0.5, `area`에서 백 +N 보정.
- [ ] 초읽기: 기본 시간 소진 후 30초 안에 두면 회차 유지, 넘기면 회차 감소, 마지막 회차 초과 시 시간패, 그 뒤 도착 착수 거부.
- [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가.
- [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제.
- [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원.
- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints`/`atariWarnings` 없음.
- [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과.
## 11. 참고 자료
- 한국기원 바둑 규칙 안내: https://www.baduk.or.kr/story/badukRule.asp
- 나무위키 「계가」: https://namu.wiki/w/%EA%B3%84%EA%B0%80 , 「삼패」: https://namu.wiki/w/%EC%82%BC%ED%8C%A8 , 「바둑/국가별 룰」: https://namu.wiki/w/%EB%B0%94%EB%91%91/%EA%B5%AD%EA%B0%80%EB%B3%84%20%EB%A3%B0
- 위키백과 「덤 (바둑)」: https://ko.wikipedia.org/wiki/%EB%8D%A4_(%EB%B0%94%EB%91%91)
- 일본기원 규약(1989) 영문: https://www.cs.cmu.edu/~wjh/go/rules/Japanese.html (사활 다툼 시 재개, 빅 안의 집 불인정)
- Sensei's Library "Superko", "Handicap placement": https://senseis.xmp.net/?Superko , https://senseis.xmp.net/?HandicapPlacement
- Tromp-Taylor 규칙(영역 계산·동형 반복 금지의 간결한 정의): https://tromp.github.io/go.html
## 12. 메모 (상표·법적 주의 등)
- "바둑", "Go", "Weiqi", "Baduk"은 전통 게임 명칭으로 상표 문제 없음. "한국기원 공인" 같은 표현은 쓰지 않는다.
- 사활 자동 추정은 보조 기능일 뿐이며, 결과는 반드시 두 플레이어의 합의로 확정된다는 점을 UI에 명시.
- 금전·베팅 요소 없음.

327
docs/games/bang.md Normal file
View File

@@ -0,0 +1,327 @@
# 뱅! (`bang`)
> 마일스톤: M6 · 인원: 최소 4 ~ 최대 7명 · 예상 시간: 약 20~40분 · 난이도: 어려움
## 1. 개요
- 서부극 배경의 정체 숨기기 + 카드 전투 게임. 각자 비밀 역할(보안관/부관/무법자/배신자)을 받고, 보안관만 정체를 공개한다. "뱅!"(사격)과 "빗나감!"(회피)을 주고받으며 상대 팀을 제거한다.
- 한국에서는 보드게임 카페 정식 한글판(2000년대 후반~)으로 널리 알려진 대표 마피아류 카드 게임이다. 친구끼리 "누가 무법자냐"를 추리하는 맛이 핵심.
- 인원 근거: 원작(dV Giochi, 2002) 기본판 박스 표기 4~7인. 3인 규칙과 8인 규칙(확장 "닷지 시티")은 기본판 범위 밖이므로 이 문서에서는 다루지 않는다(12장 참고).
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `characterDraft` | 캐릭터 배정 방식 | `random`(1장 무작위) / `choose2`(2장 중 1장 선택) | `random` |
| `turnSeconds` | 내 턴 제한 시간(초) | 30 / 60 / 90 / 120 | 60 |
| `reactionSeconds` | 반응(빗나감!/맥주/결투 응수/잡화점 선택) 제한 시간(초) | 8 / 15 / 25 | 15 |
| `autoDefend` | 반응 시간 초과 시 가능한 방어 카드를 자동으로 사용 | on / off | on |
| `autoBarrel` | 술통/주르도네 판정을 묻지 않고 자동 실행 | on / off | on |
| `revealRoleOnDeath` | 사망 시 역할 공개(원작 규칙) | on 고정 | on |
| `showDistanceHelper` | 각 플레이어까지 거리와 사거리 내 여부 표시 | on / off | on |
| `beginnerHints` | 카드별 "지금 쓸 수 있는 이유/없는 이유" 툴팁 | on / off | on |
원작 규칙 자체를 바꾸는 하우스 룰은 7장에 따로 둔다.
## 3. 구성물
### 3.1 역할 카드
보안관 1, 부관 2, 무법자 3, 배신자 1 (총 7장, 인원에 맞게 사용).
### 3.2 캐릭터 16종
| 캐릭터(한글 표기 / 원어) | 생명 | 능력 |
|---|---|---|
| 바트 캐시디 (Bart Cassidy) | 4 | 생명을 1 잃을 때마다 덱에서 카드 1장을 뽑는다(잃은 생명 수만큼). |
| 블랙 잭 (Black Jack) | 4 | 1단계에서 두 번째로 뽑은 카드를 모두에게 공개하고, 그것이 하트 또는 다이아몬드면 1장을 더 뽑는다. |
| 캘러미티 자넷 (Calamity Janet) | 4 | 뱅!을 빗나감!으로, 빗나감!을 뱅!으로 쓸 수 있다. 빗나감!을 뱅!으로 쓰면 "턴당 뱅! 1회" 제한에 포함된다. 결투/인디언!에서도 빗나감!을 뱅!으로 낼 수 있다. |
| 엘 그링고 (El Gringo) | 3 | 다른 플레이어가 낸 카드 때문에 생명을 잃을 때마다(1점당 1회) 그 플레이어의 손패에서 무작위 1장을 가져온다. 다이너마이트 피해에는 발동하지 않는다. 상대 손패가 없으면 아무것도 없다. |
| 제시 존스 (Jesse Jones) | 4 | 1단계 첫 번째 카드를 덱 대신 원하는 플레이어의 손패에서 무작위로 가져올 수 있다. 두 번째 카드는 덱에서. |
| 주르도네 (Jourdonnais) | 4 | 항상 술통을 장착한 것으로 간주한다. 실제 술통도 있으면 판정을 두 번 할 수 있다. |
| 킷 칼슨 (Kit Carlson) | 4 | 1단계에서 덱 위 3장을 보고 2장을 고른 뒤 나머지 1장을 덱 맨 위에 덮어 둔다. |
| 럭키 듀크 (Lucky Duke) | 4 | "판정"(draw!)을 할 때마다 덱 위 2장을 공개하고 그중 1장을 결과로 고른다. 두 장 모두 버림 더미로. |
| 폴 리그레트 (Paul Regret) | 3 | 항상 머스탱을 장착한 것으로 간주(남이 볼 때 거리 +1). 실제 머스탱이 있으면 +2. |
| 페드로 라미레스 (Pedro Ramirez) | 4 | 1단계 첫 번째 카드를 버림 더미 맨 위에서 가져올 수 있다. 두 번째는 덱에서. |
| 로즈 둘란 (Rose Doolan) | 4 | 항상 조준경을 장착한 것으로 간주(내가 볼 때 거리 -1). 실제 조준경이 있으면 -2. |
| 시드 케첨 (Sid Ketchum) | 4 | 언제든(자기 턴, 반응 중, 빈사 상태 포함) 손패 2장을 버려 생명 1을 회복할 수 있다. 여러 번 가능. 최대 생명을 넘을 수 없다. 2인 남았을 때도 사용 가능. |
| 슬랩 더 킬러 (Slab the Killer) | 4 | 그가 쏜 뱅!을 막으려면 빗나감! 효과가 2번 필요하다(술통 성공 1회 = 1번). 개틀링에는 적용되지 않는다. |
| 수지 라파예트 (Suzy Lafayette) | 4 | 손패가 0장이 되는 순간 덱에서 1장을 뽑는다. |
| 벌처 샘 (Vulture Sam) | 4 | 다른 캐릭터가 제거되면 그 플레이어의 손패와 장착 카드를 모두 자기 손패로 가져온다. |
| 윌리 더 키드 (Willy the Kid) | 4 | 자기 턴에 뱅!을 몇 장이든 낼 수 있다. |
### 3.3 놀이 카드 80장 (무늬·숫자 포함, "판정"에 사용)
갈색 테두리 = 즉시 효과 후 버림. 파란 테두리 = 자기(또는 대상) 앞에 장착되어 계속 효과.
무늬 기호: ♠ 스페이드, ♥ 하트, ♦ 다이아몬드, ♣ 클럽. 숫자: 2~10, J, Q, K, A.
| 카드(한글 / 원어) | 종류 | 장수 | 각 장의 무늬·숫자 |
|---|---|---|---|
| 뱅! (BANG!) | 갈색 | 25 | A♠ / 2♦ 3♦ 4♦ 5♦ 6♦ 7♦ 8♦ 9♦ 10♦ J♦ Q♦ K♦ A♦ / 2♣ 3♣ 4♣ 5♣ 6♣ 7♣ 8♣ 9♣ / Q♥ K♥ A♥ |
| 빗나감! (Missed!) | 갈색 | 12 | 10♣ J♣ Q♣ K♣ A♣ / 2♠ 3♠ 4♠ 5♠ 6♠ 7♠ 8♠ |
| 맥주 (Beer) | 갈색 | 6 | 6♥ 7♥ 8♥ 9♥ 10♥ J♥ |
| 패닉! (Panic!) | 갈색 | 4 | J♥ Q♥ A♥ 8♦ |
| 캣 벌루 (Cat Balou) | 갈색 | 4 | K♥ 9♦ 10♦ J♦ |
| 결투 (Duel) | 갈색 | 3 | Q♦ J♠ 8♣ |
| 잡화점 (General Store) | 갈색 | 2 | 9♣ Q♠ |
| 인디언! (Indians!) | 갈색 | 2 | K♦ A♦ |
| 역마차 (Stagecoach) | 갈색 | 2 | 9♠ 9♠ |
| 개틀링 (Gatling) | 갈색 | 1 | 10♥ |
| 살롱 (Saloon) | 갈색 | 1 | 5♥ |
| 웰스 파고 (Wells Fargo) | 갈색 | 1 | 3♥ |
| 술통 (Barrel) | 파란색 | 2 | Q♠ K♠ |
| 머스탱 (Mustang) | 파란색 | 2 | 8♥ 9♥ |
| 조준경 (Scope / Appaloosa) | 파란색 | 1 | A♠ |
| 감옥 (Jail) | 파란색 | 3 | J♠ 10♠ 4♥ |
| 다이너마이트 (Dynamite) | 파란색 | 1 | 2♥ |
| 볼캐닉 (Volcanic) — 무기, 사거리 1 | 파란색 | 2 | 10♠ 10♣ |
| 스코필드 (Schofield) — 무기, 사거리 2 | 파란색 | 3 | J♣ Q♣ K♠ |
| 레밍턴 (Remington) — 무기, 사거리 3 | 파란색 | 1 | K♣ |
| 카빈 (Rev. Carabine) — 무기, 사거리 4 | 파란색 | 1 | A♣ |
| 윈체스터 (Winchester) — 무기, 사거리 5 | 파란색 | 1 | 8♠ |
합계: 갈색 63장 + 파란색 17장 = 80장. (장수 합계는 테스트로 검증한다. 10장 참고)
기본 무기 "콜트 45"(사거리 1)는 카드가 아니라 무기가 없을 때의 기본값이다.
## 4. 준비(셋업)
1. 인원별 역할 구성(무작위로 비공개 배정, 보안관만 공개):
| 인원 | 보안관 | 부관 | 무법자 | 배신자 |
|---|---|---|---|---|
| 4 | 1 | 0 | 2 | 1 |
| 5 | 1 | 1 | 2 | 1 |
| 6 | 1 | 1 | 3 | 1 |
| 7 | 1 | 2 | 3 | 1 |
2. 좌석 순서는 방 입장 순서를 RNG로 섞어서 결정한다(시계 방향 = 배열 순서).
3. 캐릭터: `random`이면 16장 중 1장씩 무작위, `choose2`면 2장씩 보여 주고 선택(제한 시간 초과 시 첫 번째).
4. 최대 생명 = 캐릭터 생명, 보안관은 +1. 현재 생명 = 최대 생명.
5. 80장 덱을 섞고 각자 현재 생명 수만큼 손패로 받는다.
6. 보안관이 첫 턴을 가진다. 진행은 시계 방향.
## 5. 진행 규칙
### 5.1 용어
- **판정(draw!)**: 덱 맨 위 1장을 공개하고 무늬/숫자를 확인한 뒤 버림 더미로 보낸다. 럭키 듀크는 2장 공개 후 선택.
- **거리**: 살아 있는 플레이어만 세어, 좌석 기준 시계 방향과 반시계 방향 중 짧은 쪽 칸 수.
- `거리(A→B) = max(1, 좌석거리 + (B의 머스탱 ? 1 : 0) + (B가 폴 리그레트 ? 1 : 0) - (A의 조준경 ? 1 : 0) - (A가 로즈 둘란 ? 1 : 0))`
- **사거리**: 장착 무기의 숫자(없으면 1). 뱅!은 `거리(A→B) <= 사거리`인 대상에게만.
- 패닉!은 무기와 무관하게 `거리(A→B) <= 1` 대상에게만. 캣 벌루·결투는 거리 무관.
예시(5인, 살아 있는 순서 A-B-C-D-E): A→C 좌석거리 = min(2, 3) = 2. C가 머스탱 장착 시 3. A가 조준경 장착 시 2. A가 레밍턴(3)이면 사격 가능, 스코필드(2)면 불가. B가 사망하면 A→C 좌석거리는 1이 된다.
### 5.2 턴 구조
턴 시작 시 순서대로:
1. **다이너마이트 판정**(장착 중일 때): 결과가 ♠2~♠9면 폭발 — 생명 3 손실, 다이너마이트 버림. 아니면 다이너마이트를 왼쪽(시계 방향 다음)의 살아 있는 플레이어 앞으로 넘긴다. 폭발로 죽으면 턴 종료.
2. **감옥 판정**(장착 중일 때): ♥면 탈출 — 감옥 버리고 정상 진행. 아니면 감옥 버리고 이번 턴 전체를 건너뛴다(카드도 뽑지 않음).
3. **1단계: 뽑기** — 덱에서 2장. 캐릭터 예외: 블랙 잭, 제시 존스, 킷 칼슨, 페드로 라미레스.
4. **2단계: 카드 내기** — 원하는 만큼, 원하는 순서로. 제한:
- 뱅!은 턴당 1장(볼캐닉 장착 또는 윌리 더 키드는 무제한).
- 같은 이름의 파란 카드는 한 사람 앞에 1장까지. 무기는 1개만(새 무기를 내면 기존 무기는 버림).
- 빗나감!은 자기 턴에 능동적으로 낼 수 없다(반응 전용). 단 캘러미티 자넷은 빗나감!을 뱅!으로 낼 수 있다.
- 맥주는 생명이 최대치이거나 살아 있는 플레이어가 2명뿐이면 효과가 없으므로 내지 못하게 막는다(사유 표시).
5. **3단계: 버리기** — 손패가 현재 생명보다 많으면 초과분을 골라 버린다. 그 후 턴 종료.
### 5.3 카드별 효과와 합법 조건
| 카드 | 대상 | 효과 / 조건 |
|---|---|---|
| 뱅! | 다른 1명, 사거리 내 | 대상은 빗나감!(슬랩 상대 시 2장)을 내거나 술통 판정 성공 시 회피, 아니면 생명 1 손실. |
| 빗나감! | 반응 전용 | 뱅!·개틀링 1발을 무효화. |
| 맥주 | 자신 | 생명 +1(최대 초과 불가). 2명 남으면 무효(사용 불가). 자기 턴 외에는 빈사(생명 0 이하) 때만 사용 가능. |
| 살롱 | 전원 | 살아 있는 모든 플레이어 생명 +1(최대 초과 불가). 2명 남아도 유효. |
| 역마차 | 자신 | 덱에서 2장. |
| 웰스 파고 | 자신 | 덱에서 3장. |
| 잡화점 | 전원 | 살아 있는 인원 수만큼 덱에서 공개. 낸 사람부터 시계 방향으로 1장씩 고른다. |
| 패닉! | 거리 1 이내 다른 1명 | 대상의 손패 무작위 1장 또는 장착 카드 지정 1장을 내 손패로. |
| 캣 벌루 | 다른 1명(거리 무관) | 대상의 손패 무작위 1장 또는 장착 카드 지정 1장을 버림. |
| 결투 | 다른 1명(거리 무관) | 대상부터 번갈아 뱅!을 1장씩 버린다. 먼저 못 내거나 안 낸 쪽이 생명 1 손실. 결투 중 뱅!은 턴당 제한에 포함 안 됨. 빗나감!/술통 불가. |
| 인디언! | 다른 모두 | 각자 뱅! 1장을 버리거나 생명 1 손실. 빗나감!/술통 불가. |
| 개틀링 | 다른 모두 | 각자에게 뱅!(거리 무관). 빗나감!/술통 가능. 턴당 뱅! 제한에 포함 안 됨. 슬랩 능력 미적용. |
| 술통 | 자신 장착 | 뱅!/개틀링 대상이 되면 판정: ♥면 빗나감! 1회 효과. |
| 머스탱 | 자신 장착 | 남이 나를 볼 때 거리 +1. |
| 조준경 | 자신 장착 | 내가 남을 볼 때 거리 -1. |
| 감옥 | 보안관 제외 다른 1명, 이미 감옥 없는 대상 | 대상의 다음 턴 시작에 판정. 감옥에 갇혀 있어도 뱅! 등의 대상이 되며 반응도 가능. |
| 다이너마이트 | 자신 장착 | 5.2의 1번. 폭발 피해는 "누가 낸 카드"가 아니므로 처치 보상/엘 그링고 없음. |
| 무기 | 자신 장착 | 사거리 변경. 볼캐닉은 사거리 1 + 뱅! 무제한. |
패닉!/캣 벌루 대상은 "다른 플레이어"로 한정한다(자기 자신 불가, 결정 사항).
### 5.4 반응 창(Reaction window)
턴 진행은 다음 반응이 모두 끝날 때까지 멈춘다. 각 반응에는 `reactionSeconds` 타이머가 있다.
- **뱅! 반응**: 대상에게 `missedNeeded`(기본 1, 슬랩의 뱅!은 2). 순서: (1) 술통 판정(실제 술통, 주르도네 능력 각각 1회, `autoBarrel`이면 자동) → 성공마다 needed -1. (2) 남은 needed만큼 빗나감!(캘러미티는 뱅!도) 제출 또는 포기. 포기/시간 초과 시 생명 1 손실.
- 슬랩 상대로 빗나감!이 1장뿐이면 내 봐야 소용없으므로 UI에서 "막을 수 없음"을 안내하되 낼 수는 있다(효과 없음 → 실제로는 막지 못함). 엔진은 needed를 모두 채울 때만 카드 제출을 받는다(부분 제출 금지, 결정 사항).
- **개틀링 / 인디언!**: 대상 전원에게 동시에 반응 창을 연다. 각자의 응답은 도착 순서대로 개별 처리된다.
- **결투**: 하나의 반응이 번갈아 넘어간다. 현재 차례만 active. 뱅! 1장 제출 시 상대에게 넘어가고, 포기/시간 초과 시 그 사람이 생명 1 손실, 결투 종료. 피해의 원인 제공자는 결투 상대(처치 보상 계산용).
- **잡화점**: 선택자가 1명씩 차례로 active.
- **빈사(dying)**: 생명이 0 이하가 되면 즉시 빈사 반응. 맥주(2명 남았으면 불가)나 시드 케첨 능력으로 생명을 1 이상으로 만들면 생존. 필요한 맥주 수 = `1 - 현재생명`. 못 하면 제거.
### 5.5 생명 손실 처리 순서(결정 사항)
1. 생명 감소 → 2. 빈사 판정/반응 → 3. 생존 시에만 바트 캐시디/엘 그링고 능력 발동 → 4. 수지 라파예트 확인(어느 시점이든 손패 0장이 되면 즉시 1장).
원작은 빈사와 능력 발동 순서를 명확히 정하지 않아, "죽은 사람은 능력을 쓰지 않는다"로 정한다. 7장의 `drawBeforeDeath` 옵션으로 반대 해석(먼저 뽑고 그 카드로 살 수 있음)을 고를 수 있게 한다.
### 5.6 제거(사망)
1. 역할 공개.
2. 보상/벌칙: 처치자가 있고 제거된 사람이 무법자면 처치자(무법자여도)가 덱에서 3장. 보안관이 부관을 처치하면 보안관은 손패와 장착 카드를 모두 버린다.
3. 제거된 사람의 손패와 장착 카드: 벌처 샘이 살아 있으면 그의 손패로, 아니면 버림 더미로.
4. 승리 조건 확인(6장). 끝났으면 즉시 종료.
5. 현재 턴의 플레이어가 죽었으면 남은 반응을 정리하고 다음 살아 있는 플레이어의 턴으로.
처치자: 뱅!/개틀링/인디언!/결투를 낸 사람(결투는 승자). 다이너마이트는 처치자 없음.
### 5.7 덱 소진
덱이 비면 버림 더미 전체를 섞어 새 덱으로 만든다. 둘 다 비었으면 뽑기는 그냥 생략한다(드묾).
## 6. 승패와 점수 계산
- 보안관 팀(보안관+부관) 승리: 무법자 전원과 배신자가 제거됨.
- 무법자 팀 승리: 보안관이 제거됨. 단 그때 살아 있는 사람이 배신자 혼자뿐이면 배신자 승리.
- 배신자 승리: 배신자가 마지막 생존자(보안관을 마지막에 처치).
- 승리는 팀 단위: 이미 죽은 같은 팀원도 승리.
- 예: 보안관·부관·배신자 생존 상태에서 배신자가 보안관을 쏴 죽이면 → 배신자 혼자가 아니므로 무법자 승리.
- 동점/무승부 없음. 연쇄 사망(개틀링 등)은 응답 도착 순서대로 처리하며, 각 사망 직후 승리 판정.
- 점수(방 내 기록용): 승리 팀 각 1점. 순위 표시는 승리자 → 패배자(사망 순서 역순).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 키 | 내용 | 기본값 |
|---|---|---|
| `drawBeforeDeath` | 바트 캐시디/엘 그링고가 빈사 판정 전에 카드를 받아 그 카드로 살아날 수 있음 | off |
| `beerAtFullHealth` | 생명 최대치에서도 맥주를 낼 수 있음(효과 없음, 버리기 용도) | off |
| `selfTargetCatBalou` | 캣 벌루/패닉!을 자기 장착 카드에 사용 가능(예: 자기 다이너마이트 제거) | off |
| `deputyCount7` | 7인에서 부관 2 대신 부관 1 + 무법자 4 | off |
확장 "닷지 시티"(8인, 녹색 카드, 캐릭터 15종 추가)는 향후 별도 옵션으로 추가 예정. 이번 범위 아님.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Suit = 'S' | 'H' | 'D' | 'C';
type Rank = 2|3|4|5|6|7|8|9|10|'J'|'Q'|'K'|'A';
type CardKind = 'bang'|'missed'|'beer'|'saloon'|'stagecoach'|'wellsFargo'|'generalStore'|'panic'|'catBalou'
|'duel'|'indians'|'gatling'|'barrel'|'mustang'|'scope'|'jail'|'dynamite'
|'volcanic'|'schofield'|'remington'|'carabine'|'winchester';
interface Card { id: number; kind: CardKind; suit: Suit; rank: Rank } // id 0..79 고정
type Role = 'sheriff'|'deputy'|'outlaw'|'renegade';
type CharacterId = 'bart'|'blackJack'|'calamity'|'elGringo'|'jesse'|'jourdonnais'|'kit'|'lucky'
|'paul'|'pedro'|'rose'|'sid'|'slab'|'suzy'|'vulture'|'willy';
interface PlayerState {
id: PlayerId; seat: number; role: Role; character: CharacterId;
characterOffer?: CharacterId[]; // choose2 단계
maxLife: number; life: number; alive: boolean;
hand: number[]; // 카드 id
inPlay: number[]; // 장착 카드 id (무기 포함)
}
type Reaction =
| { kind: 'bang'; target: PlayerId; source: PlayerId; via: 'bang'|'gatling'; needed: number; barrelTried: boolean; jourTried: boolean; deadline: number }
| { kind: 'indians'; target: PlayerId; source: PlayerId; deadline: number }
| { kind: 'duel'; actor: PlayerId; other: PlayerId; deadline: number }
| { kind: 'store'; picker: PlayerId; order: PlayerId[]; revealed: number[]; deadline: number }
| { kind: 'dying'; player: PlayerId; killer: PlayerId | null; deadline: number }
| { kind: 'luckyPick'; player: PlayerId; purpose: 'dynamite'|'jail'|'barrel'; options: [number, number]; deadline: number }
| { kind: 'kitKeep'; player: PlayerId; options: [number, number, number]; deadline: number }
| { kind: 'choose2'; player: PlayerId; deadline: number };
interface BangState {
options: BangOptions;
rng: RngState;
players: PlayerState[]; // 좌석 순
deck: number[]; // 맨 위 = 배열 끝
discard: number[]; // 맨 위 = 배열 끝
turn: { player: PlayerId; phase: 'setup'|'start'|'draw'|'play'|'discard'; bangsPlayed: number; deadline: number; blackJackReveal?: number };
reactions: Reaction[]; // 비어 있지 않으면 턴 진행 정지
lastCheck?: { player: PlayerId; cardIds: number[]; purpose: string; success: boolean }; // UI 표시용(공개 정보)
winner: null | 'law' | 'outlaws' | 'renegade';
deathOrder: PlayerId[];
log: PublicLogEntry[]; // 공개 로그(비밀 카드 내용 없음)
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `pickCharacter` | `{ character }` | choose2 단계, 본인 | 제안 2장 중 하나 |
| `draw` | `{ jesseFrom?: PlayerId; pedroDiscard?: boolean }` | 현재 플레이어, phase=draw | 제시는 손패 1장 이상인 다른 생존자, 페드로는 버림 더미 비어 있지 않음, 해당 캐릭터만 |
| `kitKeep` | `{ keep: [id, id] }` | 킷 칼슨, kitKeep 반응 | options 중 서로 다른 2장 |
| `luckyPick` | `{ cardId }` | 럭키 듀크, luckyPick 반응 | options 중 1장 |
| `play` | `{ cardId; as?: 'bang'|'missed'; target?: PlayerId; targetCard?: number | 'hand' }` | 현재 플레이어, phase=play, reactions 비어 있음 | 손패에 있음 / 카드별 대상·거리·사거리 / 뱅! 횟수 / 파란 카드 중복 / 감옥은 보안관 불가 / 맥주 조건 / `as`는 캘러미티만 |
| `respond` | `{ cardIds: number[] }` | 반응 대상자 | bang: 빗나감!(캘러미티는 뱅!도) 정확히 needed장 / indians: 뱅! 1장 / duel: 뱅! 1장(캘러미티는 빗나감!도) / dying: 맥주 정확히 필요한 수, 2명 남았으면 불가 |
| `useBarrel` | `{}` | 뱅! 반응 대상, autoBarrel off | 술통 또는 주르도네, 해당 판정 미사용 |
| `takeHit` | `{}` | bang/indians/duel 반응 대상 | 해당 반응의 active |
| `giveUp` | `{}` | dying 반응 대상 | 즉시 제거 |
| `storePick` | `{ cardId }` | store 반응의 picker | revealed 안에 있음 |
| `sidHeal` | `{ cardIds: [id, id] }` | 시드 케첨, 자기 턴 play 단계 또는 자신이 대상인 반응 중 | 손패 2장, 생명 < 최대 |
| `endPlay` | `{}` | 현재 플레이어, phase=play, reactions 비어 있음 | 손패 > 생명이면 phase=discard로, 아니면 턴 종료 |
| `discard` | `{ cardIds }` | 현재 플레이어, phase=discard | 개수 = 손패 - 생명 |
`play`의 세부 검증 예: 뱅!은 `target != actor && alive && distance(actor, target) <= range(actor) && (bangsPlayed < 1 || 볼캐닉 || 윌리)`. 거부 사유 예: "사거리가 닿지 않습니다(거리 3, 사거리 2)", "이번 턴에는 뱅!을 이미 썼습니다", "보안관은 감옥에 넣을 수 없습니다".
### 8.3 공개/비공개 정보 (view)
- 모두에게 공개: 좌석, 캐릭터, 생명/최대 생명, 손패 장수, 장착 카드, 보안관 정체, 사망자 역할, 덱 장수, 버림 더미 전체, 판정 결과, 잡화점 공개 카드, 블랙 잭의 공개 카드, 반응 진행 상황(누가 무엇을 기다리는지), 공개 로그.
- 본인에게만: 자기 역할, 자기 손패 내용, 킷 칼슨이 본 3장, choose2 후보.
- 비공개(누구에게도 보내지 않음): 덱 순서, 다른 사람 손패 내용, 다른 사람 역할(사망 전), RNG 상태.
- 패닉!/캣 벌루/제시/엘 그링고의 "무작위 손패" 선택은 서버 RNG로 하므로 클라이언트에 손패 순서/인덱스를 노출하지 않는다. 가져간 카드 내용은 가져간 사람과 잃은 사람에게만 보이고, 캣 벌루로 버린 카드는 버림 더미에 공개.
- 관전자(viewer=null): 모두 공개 정보만. 게임 종료 후 전체 공개.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
좌석 섞기, 역할 배정, 캐릭터 배정/후보, 덱 셔플(시작 및 버림 더미 재사용), 패닉!/캣 벌루/제시/엘 그링고의 무작위 손패 선택, 시간 초과 시 무작위 버리기.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 반응이 있으면 가장 이른 반응 마감, 없으면 턴 마감.
- `onTimeout(player)`:
- choose2 → 첫 후보. kitKeep → 위 2장. luckyPick → 성공하는 카드 우선(없으면 첫 장).
- draw 단계 → 기본 `draw`(능력 미사용).
- play 단계 → `endPlay`. discard 단계 → 초과분을 RNG로 골라 버림.
- bang 반응 → `autoDefend`면 술통 → 필요한 빗나감! 제출, 불가면 `takeHit`. off면 `takeHit`.
- indians/duel → `autoDefend`면 뱅! 1장, 없으면 `takeHit`.
- dying → 맥주로 살 수 있으면 자동 사용(시드는 손패 2장씩 자동 사용), 아니면 `giveUp`.
- store → revealed[0].
- 연결 끊김 120초 이상: 이후 모든 차례를 즉시 onTimeout으로 처리(봇 모드). 재접속 시 해제.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface BangResult {
winnerTeam: 'law' | 'outlaws' | 'renegade';
players: { id: PlayerId; role: Role; character: CharacterId; alive: boolean; won: boolean; deathOrder: number | null }[];
ranking: PlayerId[]; // 승자(생존자 우선) → 패자(늦게 죽은 순)
summary: string; // 예: "무법자 승리! 보안관이 3라운드에 쓰러졌습니다."
}
```
## 9. UI/UX
- 모바일 세로: 상단 원형 테이블(플레이어 아바타 + 생명 탄환 아이콘 + 장착 카드 축소판 + 손패 장수), 하단 손패 가로 스크롤(카드 높이 120px 이상), 그 사이 행동 버튼(턴 종료 등 48px 이상).
- 카드를 탭하면 쓸 수 있는 대상 플레이어가 초록 테두리로 강조되고 사거리 밖은 회색 + "거리 3 / 사거리 2" 표시. 쓸 수 없는 카드는 흐리게 + 이유 툴팁.
- 반응 창: 화면 중앙 큰 모달 "뱅! 공격을 받았습니다 — [빗나감! 내기] [맞기]" + 원형 타이머. 슬랩 상대 시 "빗나감! 2장 필요" 문구.
- 판정 애니메이션: 카드 뒤집기 1초, 성공/실패 색상.
- 역할 확인: 길게 누르면 내 역할 카드가 보이는 방식(옆 사람 훔쳐보기 방지).
- "규칙 보기": 카드 도감(카드 탭 시 효과 설명), 역할별 승리 조건 요약. 초보자 힌트 on이면 "부관은 보안관을 지켜야 해요" 같은 역할 팁.
- PC: 테이블 중앙, 손패 하단, 우측 로그 패널.
## 10. 테스트 체크리스트
- [ ] 덱 생성: 80장, 카드 종류별 장수와 무늬·숫자가 3.3 표와 정확히 일치.
- [ ] 4/5/6/7인 역할 분배가 표와 일치하고 보안관 생명 +1, 시작 손패 = 생명.
- [ ] 거리: 5인에서 중간 1명 사망 후 거리 감소, 머스탱+폴 리그레트 = +2, 조준경+로즈 = -2, 최소 1.
- [ ] 뱅! 1회 제한: 두 번째 뱅! 거부. 볼캐닉/윌리는 허용. 캘러미티의 빗나감!-as-뱅!도 1회에 포함.
- [ ] 슬랩의 뱅!: 빗나감! 1장 제출 거부, 술통 성공 1 + 빗나감! 1로 회피 성공. 슬랩의 개틀링은 1장으로 회피.
- [ ] 주르도네 + 술통: 판정 2회, 럭키 듀크가 아닌 경우 각 1장씩 공개.
- [ ] 다이너마이트: ♠2~♠9 폭발(3 피해), ♠10/♥ 등은 다음 생존자로 이동. 다이너마이트 판정 후 감옥 판정 순서. 폭발로 무법자 사망 시 3장 보상 없음.
- [ ] 감옥: 보안관 대상 거부, ♥ 탈출 시 정상 턴, 실패 시 1단계 포함 턴 전체 건너뜀.
- [ ] 맥주: 2명 남았을 때 빈사에서 사용 불가 → 제거. 생명 -1 상태 빈사에서 맥주 2장 필요. 살롱은 2명에서도 회복.
- [ ] 결투 왕복: A가 B에게 결투, B 뱅! → A 뱅! → B 포기 → B 생명 1 손실, 처치자 A. A가 먼저 포기하면 A 손실, 처치자 B.
- [ ] 인디언!/개틀링 동시 반응: 3명이 서로 다른 순서로 응답, 도착 순서대로 처리, 모두 끝나기 전 현재 플레이어 `play` 거부.
- [ ] 잡화점: 생존자 수만큼 공개, 낸 사람부터 시계 방향 선택, 시간 초과 시 첫 장 자동 선택.
- [ ] 처치 보상/벌칙: 무법자 처치 시 처치자 3장(처치자가 무법자여도). 보안관이 부관 처치 시 보안관 손패·장착 전부 버림.
- [ ] 벌처 샘: 사망자 카드 전부 손패로. 보안관-부관 벌칙과 동시 발생 시 각각 정상 처리.
- [ ] 승리 판정: 보안관·부관·배신자 생존 중 보안관 사망 → 무법자 승리. 배신자 1대1에서 보안관 처치 → 배신자 승리.
- [ ] 수지 라파예트: 결투 중 마지막 뱅!을 내 손패 0 → 즉시 1장.
- [ ] 엘 그링고: 뱅!으로 1 피해 시 공격자 손패 무작위 1장, 다이너마이트 피해 시 없음, 공격자 손패 0이면 없음.
- [ ] 시간 초과: 턴 중 무응답 → endPlay → 손패 초과분 자동 버림. 반응 무응답 + autoDefend on → 빗나감! 자동.
- [ ] 연결 끊김 120초 → 봇 모드, 재접속 시 복귀, 그동안 상태 일관성 유지.
- [ ] 비공개 정보 누출 검사: 모든 플레이어/관전자 view를 직렬화해 다른 사람 손패 카드 id, 생존자 역할(보안관·본인 제외), 덱 순서, 킷 칼슨 후보가 포함되지 않는지 자동 검사.
- [ ] 같은 시드 + 같은 액션 로그 재생 시 최종 상태 동일.
## 11. 참고 자료
- dV Giochi, BANG! 공식 규칙서(영문, 4th edition) — https://www.dvgiochi.com/giochi/bang/
- 나무위키 "뱅!(보드게임)" — https://namu.wiki/w/뱅!
- BoardGameGeek, BANG! — https://boardgamegeek.com/boardgame/3955/bang
- BGG 스레드 "Card by Card Explanations for BANG!" — https://boardgamegeek.com/thread/668345/card-by-card-explanations-for-bang
- UltraBoardGames, BANG! 카드 설명 — https://www.ultraboardgames.com/bang/cards.php
- 오픈소스 구현 albertoxamin/bang (덱 무늬·숫자 및 캐릭터 생명 교차 확인) — https://github.com/albertoxamin/bang/blob/HEAD/backend/bang/cards.py
## 12. 메모 (상표·법적 주의 등)
- "BANG!"은 dV Giochi의 상표이며, 카드 일러스트·캐릭터 이름(바트 캐시디 등)도 저작물이다. 게임 규칙 자체는 보호 대상이 아니지만 이름·그림을 그대로 쓰면 안 된다.
- 표시 이름 후보(사용자 선택): "황야의 총잡이", "서부 결투", "보안관과 무법자". 캐릭터는 자체 이름과 능력 설명으로 대체(능력 로직은 그대로 두고 표시 이름만 교체하는 매핑 테이블 사용).
- 무기 이름 Winchester/Remington/Colt 등은 실존 총기 회사 상표이므로 "권총(사거리 1)", "장총(사거리 3)" 같은 일반 명칭 사용 권장.
- 아동 이용자 고려: 총기·사망 표현을 순화하는 "어린이 표현 모드"(예: 물총/탈락) 검토. 도박 요소 없음.

242
docs/games/catchmind.md Normal file
View File

@@ -0,0 +1,242 @@
# 그림 맞히기 (`catchmind`)
> 마일스톤: M8 · 인원: 최소 2 ~ 최대 10명 · 예상 시간: 약 10~20분 · 난이도: 쉬움
## 1. 개요
- 한 사람(출제자)이 제시어를 그림으로 그리고, 나머지가 채팅으로 정답을 맞히는 실시간 게임. 차례대로 돌아가며 출제자를 맡는다.
- 한국에서는 넷마블 "캐치마인드"(2000년대)와 방송·모바일 앱으로 전 연령에 익숙한 형식이다. 글자를 몰라도 그림으로 참여할 수 있어 어린이·어르신 모두 즐기기 좋다.
- 인원 근거: 출제자 1명 + 맞히는 사람 1명 이상이면 성립하므로 최소 2명. 최대는 캐치마인드 방 정원 관행과 채팅 가독성(모바일 한 화면)을 고려해 10명.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `guessMode` | 맞히기 방식 | `multi`(여러 명이 맞힐 때까지) / `firstOnly`(첫 정답자가 나오면 즉시 종료, 원조 캐치마인드식) | `multi` |
| `rounds` | 라운드 수(1라운드 = 모두 1번씩 출제) | 1 / 2 / 3 | 2 |
| `drawSeconds` | 그리기 제한 시간 | 60 / 80 / 100 / 120 | 80 |
| `wordChoice` | 출제자가 3개 중 고르기 | on / off(무작위 1개) | on |
| `categories` | 사용할 단어 분야 | 3.2 분야 다중 선택 | 전체 |
| `difficulty` | 단어 난이도 | `easy` / `normal` / `mixed` | `mixed` |
| `customWords` | 방장 추가 단어(줄바꿈 구분, 최대 200개, 필터 통과분만) | 텍스트 | 없음 |
| `customOnly` | 추가 단어만 사용 | on / off | off |
| `hints` | 힌트 단계 | `none` / `basic`(글자 수) / `full`(글자 수 + 초성 + 한 글자) | `full` |
| `closeHint` | "아까워요" 개인 알림 | on / off | on |
| `letterReport` | 맞히는 사람이 "글자를 썼어요" 신고 투표 | on / off | on |
## 3. 구성물
### 3.1 그림판
- 논리 좌표: 1000 × 1000 정사각형(정수). 모든 기기에서 같은 비율로 축소/확대.
- 도구: 펜(굵기 4단계: 4, 8, 16, 32), 지우개(굵기 동일 4단계), 색 12개(검정, 흰색, 회색, 빨강, 주황, 노랑, 초록, 하늘, 파랑, 보라, 분홍, 갈색), 되돌리기(마지막 획 삭제), 전체 지우기.
- 채우기(페인트통)는 기기별 래스터 결과 차이 때문에 1차 범위에서 제외.
### 3.2 단어 목록 (한국어, 어린이 안전)
- 파일: `words.ko.json` — `{ word, aliases[], category, difficulty }`. 목표 600개 이상.
- 선정 기준: 2~5음절, 그림으로 그릴 수 있는 구체 명사 위주, 초등 저학년이 아는 말, 실존 인물·상표·캐릭터·정치·종교·폭력·신체 노출·질병 관련 단어 제외.
- 분야와 예시(쉬움 위주):
| 분야 키 | 이름 | 예시 |
|---|---|---|
| `animal` | 동물 | 고양이, 강아지, 기린, 코끼리, 펭귄, 거북이, 토끼, 사자, 문어, 나비 |
| `food` | 음식 | 김밥, 떡볶이, 피자, 라면, 수박, 아이스크림, 바나나, 케이크, 만두, 붕어빵 |
| `object` | 물건 | 우산, 안경, 가위, 시계, 열쇠, 연필, 의자, 냉장고, 칫솔, 선풍기 |
| `vehicle` | 탈것 | 자전거, 비행기, 기차, 배, 버스, 헬리콥터, 로켓, 소방차 |
| `nature` | 자연 | 무지개, 화산, 눈사람, 해바라기, 구름, 번개, 바다, 산 |
| `place` | 장소 | 학교, 병원, 놀이터, 수영장, 도서관, 동물원 |
| `job` | 직업 | 소방관, 요리사, 의사, 경찰관, 우체부, 화가 |
| `sport` | 운동·놀이 | 축구, 줄넘기, 수영, 연날리기, 그네, 미끄럼틀 |
| `body` | 몸 | 눈, 코, 손, 발, 귀, 이빨 (노출 관련 제외) |
| `season` | 계절·명절 | 송편, 떡국, 크리스마스트리, 눈싸움, 부채, 단풍 |
- `aliases` 예: `휴대폰` ↔ `핸드폰`, `휴대전화`, `스마트폰` / `경찰관` ↔ `경찰`. 별칭은 같은 대상을 가리키는 흔한 말만 넣고 상위·하위 개념(예: `아이스크림` → `아이스크림콘`)은 넣지 않는다.
- `customWords`는 같은 금칙어 필터를 통과해야 하며, 1~10자 한글/영문/숫자만 허용.
## 4. 준비(셋업)
1. 좌석 순서를 RNG로 섞는다. 이 순서가 출제 순서.
2. 옵션에 맞는 단어 풀을 만들고 RNG로 섞는다. 게임 중 같은 단어는 다시 나오지 않는다.
3. 점수 0으로 시작. 첫 출제자의 `choosing` 단계로.
## 5. 진행 규칙
### 5.1 한 차례(턴)의 흐름
1. **고르기(choosing, 10초)**: 출제자에게 단어 3개 제시(`wordChoice` off면 1개 자동). 시간 초과 시 첫 단어.
2. **그리기(drawing, `drawSeconds`)**: 출제자는 그리기만 가능(채팅 금지, 미리 정한 반응 버튼만). 나머지는 채팅으로 추측.
3. **공개(reveal, 5초)**: 정답과 이번 턴 점수 표시, 그림 저장(선택).
4. 다음 출제자. 모든 라운드가 끝나면 게임 종료.
### 5.2 그리기 종료 조건
- 시간 만료.
- `multi`: 출제자를 제외한 접속 중인 전원이 맞힘.
- `firstOnly`: 첫 정답자가 나오는 즉시.
- 출제자 연결 끊김 10초 경과(출제자 0점, 정답 공개).
- `letterReport` 신고가 맞히는 사람 과반(아직 못 맞힌 사람 포함 출제자 제외 전원 기준)에 도달 → 즉시 종료, 출제자 이번 턴 0점(맞힌 사람 점수는 유지).
### 5.3 정답 판정(정규화)
정답 후보 = `word` + `aliases`. 입력과 후보 모두 같은 함수로 정규화한 뒤 완전 일치 비교.
```
normalize(s): // 호출 전: 원문이 호환 자모로만 되어 있으면 정답 판정 생략
1. NFKC 정규화 (전각→반각, NFD로 분리된 한글 자모 → 완성형 결합)
2. 제로폭 문자(U+200B~U+200D, U+FEFF) 제거
3. 공백(모든 유니코드 공백) 제거
4. 문장부호·기호 제거: . , ! ? ~ - _ ' " ( ) [ ] … · 와 이모지
5. 영문 소문자화
6. 끝말 허용: 결과가 정답 + ("요" | "다" | "이다" | "입니다" | "인가" | "인가요") 형태면 끝말 제거 후 비교
```
- 예: `"아이스 크림!"` → `아이스크림` 정답. `"사과요"` → `사과` 정답. macOS 등에서 온 NFD 입력(조합형 자모 U+1100~U+11FF로 분리된 `사과`)도 NFKC로 완성형 `사과`가 됨. 주의: NFKC는 호환 자모(U+3131~U+318E)를 조합형으로 바꿔 `ㅅㅏㄱㅘ` 같은 낱자 입력을 `사과`로 결합해 버린다(실제 확인함). 그래서 정규화 전에 원문 검사를 먼저 한다: 원문(공백 제외)이 호환 자모로만 이루어져 있으면 정답 판정을 하지 않고 아래 초성 규칙으로 처리한다.
- 초성만 입력(`ㅅㄱ`)은 정답 아님. 호환 자모(U+3131~U+318E)만으로 된 메시지가 정답의 초성과 같으면 다른 사람에게 보이지 않게 막는다(힌트 유출 방지).
- **아까워요(`closeHint`)**: 정답이 아니고, 입력과 정답을 자모 단위로 분해(초성·중성·종성, 겹받침·이중모음도 분해)한 뒤 레벤시테인 거리가 1(정답 3음절 이하) 또는 2(4음절 이상) 이하이면 보낸 사람에게만 "아까워요!" 표시. 메시지는 다른 사람에게 그대로 보인다.
- 정답 메시지 자체는 다른 사람에게 보내지 않고 "○○님이 맞혔어요!" 시스템 메시지로 대체.
- 정답을 포함하지만 정답이 아닌 메시지(예: "사과나무 아님")도 다른 사람에게 보내지 않고, 보낸 사람에게 "정답이 들어간 말은 보낼 수 없어요" 안내.
- 이미 맞힌 사람의 채팅은 출제자와 이미 맞힌 사람에게만 보인다(스포일러 방지). 시스템이 "맞힌 사람끼리 대화" 라벨 표시.
### 5.4 힌트 공개 시점(`hints=full`)
- 시작 즉시: 글자 수(`○○○`, 띄어쓰기 위치 포함).
- 50% 경과: 모든 글자의 초성(`ㅇㅇㅅㅋㄹ`).
- 75% 경과: RNG로 고른 한 글자를 완성형으로 공개(`ㅇ이ㅅㅋㄹ`). 단, 2글자 이하 단어는 이 단계 생략(정답이 거의 드러남).
- `basic`은 글자 수만, `none`은 아무것도 없음.
- 힌트는 이미 맞힌 사람·출제자에게도 같은 화면 요소로 표시(정답은 별도 표시).
### 5.5 그림 스트리밍(WebSocket)
- 클라이언트 샘플링: Pointer Events(`getCoalescedEvents` 사용), 논리 좌표로 변환 후 정수 반올림, 직전 점과 거리 2 미만이면 버림.
- 배치: 50ms마다 또는 점 32개가 쌓이면 1개 메시지로 전송(최대 초당 20회). 획 시작/끝은 즉시 전송.
- 메시지(JSON, 짧은 키):
```json
{"t":"act","a":{"type":"draw","op":{"k":"b","s":17,"c":3,"w":2,"e":0}}}
{"t":"act","a":{"type":"draw","op":{"k":"p","s":17,"q":5,"d":[412,330,3,-1,4,0,5,2]}}}
{"t":"act","a":{"type":"draw","op":{"k":"e","s":17}}}
```
- `k`: `b`(획 시작: `s` 획 번호, `c` 색 인덱스, `w` 굵기 인덱스, `e` 지우개 0/1), `p`(점 묶음: `q` 배치 순번, `d` 첫 점은 절대 좌표, 이후는 이전 점과의 차이), `e`(획 끝), `u`(되돌리기), `x`(전체 지우기).
- 서버 검증: 출제자 본인, drawing 단계, 획 번호는 직전 시작 획 번호 + 1, 배치 순번 연속, 점 개수 1~64, 좌표 0~1000, 초당 30 메시지 이하, 턴당 누적 점 30,000 이하(초과 시 거부하고 출제자에게 안내).
- 서버는 검증 통과 즉시 다른 참가자에게 그대로 중계(브로드캐스트)하고 턴의 그림 기록에 추가한다. 재접속/중간 입장 관전자는 기록 전체를 한 번에 받아 다시 그린다.
- 수신 측 렌더링: 받은 점을 이어 중간점 2차 곡선으로 부드럽게 그림. 패킷 지연으로 늦게 와도 순서는 WebSocket(TCP) 순서 보장에 의존.
- 대역폭 추정: 초당 20 메시지 × 약 120바이트 = 약 2.4KB/s/시청자. 10명 방에서 서버 송신 약 22KB/s.
- 프레임워크 요구사항: 그림 액션은 다른 액션처럼 로그에 남겨 재생 가능해야 하지만, 매 액션마다 전체 view를 다시 보내면 안 된다. `apply`가 내보내는 `canvasOp` 이벤트만 증분 전송하고, 전체 그림은 (재)접속 시에만 view로 보낸다. 상태의 그림 기록은 추가 전용 배열로 두고 액션마다 깊은 복사하지 않는다.
## 6. 승패와 점수 계산
- `multi` 모드: k번째 정답자(1부터) 점수 = `max(10 - 2 × (k - 1), 4)` → 10, 8, 6, 4, 4, ...
- 출제자 점수 = 정답자 1명당 2점, 최대 12점. 아무도 못 맞히면 0점.
- `firstOnly` 모드: 정답자 10점, 출제자 5점.
- 신고 투표로 종료된 턴: 출제자 0점.
- 최종 순위: 총점 내림차순 → 동점이면 정답 횟수 많은 순 → 그래도 같으면 공동 순위.
- 예(5인, multi): 출제자 A, C가 1등·E가 2등으로 맞히고 B·D 실패 → C 10, E 8, A 4.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `firstOnly` | 원조 캐치마인드처럼 첫 정답자만 득점, 즉시 다음 문제 | off(`multi`) |
| `answererDraws` | 정답자가 다음 출제자가 됨(원조 방식, `firstOnly`에서만) | off |
| `noHints` | 힌트 없음(고수 방) | off |
| `speedBonus` | 남은 시간 비율 × 5점 추가 | off |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
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' };
interface ChatEntry { id: number; from: PlayerId; text: string; at: number; audience: 'all' | 'solvers' | 'self'; kind: 'chat' | 'correct' | 'close' | 'blocked' }
interface CatchmindState {
options: CatchmindOptions;
rng: RngState;
order: PlayerId[];
scores: Record<PlayerId, number>;
correctCounts: Record<PlayerId, number>;
wordPool: WordEntry[]; // 남은 단어(섞인 순서)
round: number; // 1..rounds
drawerIndex: number;
turn: {
phase: 'choosing' | 'drawing' | 'reveal';
drawer: PlayerId;
choices: WordEntry[]; // choosing 단계 후보
word: WordEntry | null;
startedAt: number; endsAt: number;
hintStage: 0 | 1 | 2; // 0 글자 수, 1 초성, 2 한 글자
revealIndex: number | null; // RNG로 고른 공개 글자 위치
solvers: { id: PlayerId; at: number; points: number }[];
reports: PlayerId[];
canvas: CanvasOp[]; // 추가 전용
totalPoints: number;
lastStroke: number; lastBatch: number;
};
chat: ChatEntry[];
finished: boolean;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `chooseWord` | `{ index: 0 \| 1 \| 2 }` | 출제자, choosing | 후보 범위 |
| `draw` | `{ op: CanvasOp }` | 출제자, drawing | 5.5의 서버 검증 |
| `guess` | `{ text }` | 출제자 제외, drawing | 1~40자, 초당 3개 이하. 이미 맞힌 사람이면 일반 채팅으로 처리 |
| `chat` | `{ text }` | 출제자 제외, choosing/reveal | 1~100자, 레이트 리밋, 정답 포함 여부 검사 |
| `reaction` | `{ code }` | 출제자, drawing | 미리 정한 반응 목록("거의 다 왔어요", "아니에요", "비슷해요") |
| `report` | `{}` | 출제자 제외, drawing, `letterReport` on | 1인 1회 |
| `tick` | `{}` | 서버 타이머 | 힌트 단계 진행, 시간 만료 처리 |
### 8.3 공개/비공개 정보 (view)
- 모두: 순서, 점수, 라운드, 출제자, 단계, 남은 시간, 힌트(글자 수/초성/공개 글자), 그림 기록, 정답자 목록(이름과 순서), 공개 채팅.
- 출제자: 위 + 고르기 후보 + 정답 단어.
- 이미 맞힌 사람: 위 + 정답 단어 + `solvers` 채팅.
- 아직 못 맞힌 사람·관전자: 정답 단어 없음, `solvers` 채팅 없음, 다른 사람의 `close`/`blocked` 메시지 없음.
- reveal 단계부터는 모두에게 정답 공개.
- 단어 풀(앞으로 나올 단어)은 누구에게도 보내지 않는다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
좌석(출제) 순서, 단어 풀 셔플, `wordChoice` off일 때 단어, 75% 힌트 글자 위치.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: `turn.endsAt`과 다음 힌트 시점 중 이른 시각(서버가 `tick` 적용).
- `onTimeout`: choosing → `chooseWord {index: 0}`. drawing/reveal은 `tick`으로 진행.
- 출제자 연결 끊김: choosing 중이면 즉시 자동 선택 후 10초 대기, drawing 중 10초 이상이면 턴 종료. 재접속하지 않은 플레이어의 다음 출제 차례는 건너뜀(점수 유지).
- 맞히는 사람 연결 끊김: `multi`의 "전원 정답" 판정에서 제외.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface CatchmindResult {
ranking: { rank: number; id: PlayerId; score: number; correct: number }[];
turns: { drawer: PlayerId; word: string; solvers: PlayerId[] }[];
summary: string; // 예: "1등 지민님 54점! 가장 많이 맞힌 사람도 지민님"
}
```
게임 종료: 마지막 라운드의 마지막 출제자 턴의 reveal 종료 시.
## 9. UI/UX
- 모바일 세로: 상단 정답 힌트 줄(`○○○` 큰 글씨) + 남은 시간 막대. 그 아래 정사각형 그림판(화면 폭 100%). 출제자는 그림판 아래 도구 막대(색 12개 원형 48px, 굵기 4단계, 지우개, 되돌리기, 전체 지우기). 맞히는 사람은 그림판 아래 채팅 목록 + 입력창(입력창은 키보드 위에 고정).
- 점수판: 상단 가로 스크롤 아바타 + 점수, 맞힌 사람은 초록 체크.
- 정답 시: 화면 가운데 "정답!" 애니메이션 + 효과음, 입력창에 정답이 그대로 남지 않게 비움.
- 어린이·어르신 배려: "쉬운 단어만" 기본 프리셋, 단어 고르기 버튼에 그림 아이콘, 큰 글자 모드. 음성 입력(브라우저 받아쓰기)도 같은 정규화로 판정됨.
- 그림 저장: 턴 종료 시 그림을 PNG로 내려받기(클라이언트 렌더).
- PC: 그림판 왼쪽, 채팅 오른쪽.
## 10. 테스트 체크리스트
- [ ] 정규화: `"아이스 크림!"`, `"아이스크림요"`, NFD 입력, 전각 영문(`ABC`), 제로폭 문자 포함 입력이 모두 정답 처리.
- [ ] 초성만 입력(`ㅇㅇㅅㅋㄹ`)은 정답 아님 + 다른 사람에게 비공개. 호환 자모 낱자 입력 `ㅅㅏㄱㅘ`도 (NFKC로 `사과`가 되지만) 정답 아님.
- [ ] 끝말 허용이 정답 자체를 깎지 않음: 정답 `바다`에 `바` 입력은 오답, `바다요`는 정답.
- [ ] 정답 포함 비정답 메시지("사과나무") 다른 사람에게 비공개, 보낸 사람에게 안내.
- [ ] 아까워요: `코끼리` 정답에 `코끼라` 입력 → 본인에게만 close 알림, 다른 사람에게는 일반 채팅.
- [ ] multi 점수: 4명 순서대로 정답 시 10/8/6/4, 출제자 8점. 출제자 최대 12점 상한.
- [ ] firstOnly: 첫 정답 즉시 턴 종료, 정답자 10·출제자 5.
- [ ] 힌트: 50%에 초성, 75%에 한 글자. 2글자 단어는 75% 단계 생략.
- [ ] 그림 검증: 출제자 아닌 사람의 draw 거부, 좌표 1001 거부, 점 65개 배치 거부, 획 번호 건너뛰기 거부, 초당 31번째 메시지 거부.
- [ ] 재접속: 그리기 도중 재접속한 맞히는 사람이 같은 그림 기록 전체를 받음(획 수·점 수 일치).
- [ ] 출제자 연결 끊김 10초 → 턴 종료, 정답 공개, 다음 출제자.
- [ ] 신고 과반 → 턴 종료, 출제자 0점.
- [ ] 비공개 누출 검사: 못 맞힌 사람·관전자 view와 이벤트에 정답 단어, 다른 후보 단어, 단어 풀, solvers 채팅이 없는지 자동 검사(그리기 중 모든 시점).
- [ ] 같은 단어가 한 게임에서 두 번 나오지 않음.
- [ ] 같은 시드 + 액션 로그 재생 시 점수·그림 기록 동일.
## 11. 참고 자료
- 나무위키 "캐치마인드" — https://namu.wiki/w/캐치마인드
- Wikipedia "Pictionary" (그림 맞히기 일반 규칙) — https://en.wikipedia.org/wiki/Pictionary
- Unicode Standard Annex #15 정규화 형식(NFKC) — https://unicode.org/reports/tr15/
- 한글 음절 분해(유니코드 한글 음절 공식: (음절 - 0xAC00) = (초성×21 + 중성)×28 + 종성) — https://www.unicode.org/versions/latest/ch03.pdf (3.12절)
- MDN Pointer Events / getCoalescedEvents — https://developer.mozilla.org/docs/Web/API/PointerEvent/getCoalescedEvents
## 12. 메모 (상표·법적 주의 등)
- "캐치마인드"는 넷마블의 상표이고 "Pictionary"는 Mattel의 상표다. 표시 이름 후보: "그림 맞히기", "그려서 맞혀요", "쓱싹 퀴즈".
- 사용자가 그린 그림과 채팅은 사용자 생성 콘텐츠다. 부적절한 그림 신고 → 방장 강퇴 기능, 서버에는 턴 종료 후 그림 기록을 보관하지 않음(재생용 액션 로그 보관 기간은 짧게).
- 단어 목록은 직접 작성한 것을 사용(타 서비스 단어 목록 복제 금지).
- 도박 요소 없음.

212
docs/games/chess.md Normal file
View File

@@ -0,0 +1,212 @@
# 체스 (`chess`)
> 마일스톤: M2 · 인원: 최소 2 ~ 최대 2명 · 예상 시간: 블리츠 약 10분, 래피드 약 20~40분 · 난이도: 보통~어려움
## 1. 개요
- 백과 흑이 16개씩의 기물로 상대 킹을 체크메이트하면 이기는 서양 장기. 한국에서는 학교 방과후 수업과 온라인(chess.com, lichess)으로 어린이·청소년층 인지도가 높다.
- 인원 근거: 원작이 2인 게임. 최소 2, 최대 2.
- 규칙 기준: FIDE 체스 규칙(Laws of Chess, 2023년 1월 시행판). 온라인 환경에 맞춘 차이는 7장과 6장에 명시.
- 완전 정보 게임. view에서 제외할 것은 RNG 상태뿐.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `variant` | 변형 | `standard` / `chess960` | `standard` |
| `timeControl` | 시간 방식(피셔: 기본 시간 + 수당 추가) | 프리셋: 블리츠 `3+2`, `5+0`, `5+3` / 래피드 `10+0`, `10+5`, `15+10` / 클래식 `30+0` / `none` / 사용자 지정(1~90분, 0~60초) | `10+5` |
| `drawMode` | 반복·50수 무승부 처리 | `auto`(3회 반복·50수 즉시 자동 무승부) / `fide`(3회 반복·50수는 클레임, 5회 반복·75수는 자동) | `auto` |
| `colorAssignment` | 백흑 결정 | `random` / `hostWhite` / `hostBlack` / `alternate` | `random` |
| `autoQueen` | 승진 시 퀸 자동 선택(설정 끄면 선택 창) | `true` / `false` | `false` (선택 창, 퀸이 첫 번째 큰 버튼) |
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `1` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 |
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 |
| `hints` | 초보 도움말(이동 가능 칸 표시, 공격받는 기물 경고) | `off` / `moves` / `movesAndThreats` | `moves` |
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
## 3. 구성물
- 8×8 칸(64칸), a1이 흑색 칸(백 기준 왼쪽 아래가 어두운 칸).
- 각 색: 킹 1, 퀸 1, 룩 2, 비숍 2, 나이트 2, 폰 8.
- 좌표: 열 a~h(백 기준 왼→오), 행 1~8(백 쪽부터). 기보는 SAN(예: `Nf3`, `exd5`, `O-O`, `e8=Q+`) 저장, 내부 처리는 UCI(`e2e4`, `e7e8q`).
## 4. 준비(셋업)
- `standard`: 백 1행 a~h: R N B Q K B N R, 2행 폰. 흑은 8행·7행에 대칭(퀸은 자기 색 칸: 백 Q d1, 흑 Q d8). FEN `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1`.
- `chess960`: 시드 RNG로 0~959 중 번호 n을 뽑아 백 1행 배치를 만들고 흑은 같은 열에 대칭 배치. 폰 배치는 동일. 번호 → 배치(Scharnagl 방식):
1. `b1 = n % 4; n = floor(n/4)` → 밝은 칸 비숍을 열 `2*b1 + 1`(b, d, f, h)에.
2. `b2 = n % 4; n = floor(n/4)` → 어두운 칸 비숍을 열 `2*b2`(a, c, e, g)에.
3. `q = n % 6; n = floor(n/6)` → 남은 6칸 중 왼쪽부터 `q`번째(0부터)에 퀸.
4. `n`(0~9)로 남은 5칸 중 나이트 두 자리: 0:(0,1) 1:(0,2) 2:(0,3) 3:(0,4) 4:(1,2) 5:(1,3) 6:(1,4) 7:(2,3) 8:(2,4) 9:(3,4).
5. 남은 3칸에 왼쪽부터 R, K, R.
- 검증: n = 518 → RNBQKBNR(표준 배치).
- 백이 먼저 둔다. 시계: 양쪽 `baseSec`. 첫 수 규칙은 8.5 참고.
## 5. 진행 규칙
### 5.1 기물 이동
- 킹: 8방향 1칸. 공격받는 칸으로는 못 간다.
- 퀸: 가로·세로·대각 어느 거리든(가로막히면 정지).
- 룩: 가로·세로 어느 거리든. 비숍: 대각 어느 거리든. 나이트: L자(2+1), 다른 기물을 뛰어넘음.
- 폰: 앞으로 1칸(빈칸), 처음 위치에서는 2칸(두 칸 모두 빈칸). 대각 앞 1칸으로만 잡는다.
- 잡기: 상대 기물이 있는 칸으로 이동하면 그 기물을 제거. 자기 기물 칸으로는 못 간다.
### 5.2 특수 규칙
- 앙파상(en passant): 상대 폰이 직전 수에 처음 위치에서 2칸 전진해 내 폰 바로 옆에 섰을 때, 바로 다음 수에 한해 그 폰이 1칸만 전진한 것처럼 대각선으로 잡을 수 있다.
- 승진(promotion): 폰이 마지막 행에 도달하면 같은 색 퀸·룩·비숍·나이트 중 하나로 반드시 바꾼다(기존 기물 수와 무관).
- 캐슬링(standard): 킹과 룩이 모두 한 번도 움직이지 않았고, 사이 칸이 모두 비었으며, 킹이 현재 체크 상태가 아니고, 킹이 지나가는 칸과 도착 칸이 공격받지 않을 때. 킹이 룩 쪽으로 2칸 이동하고 룩이 킹이 넘어간 칸으로 이동. 킹사이드: Ke1→g1, Rh1→f1. 퀸사이드: Ke1→c1, Ra1→d1(b1은 비어야 하지만 공격받아도 무관).
- 캐슬링(chess960): 결과 위치는 표준과 동일(킹사이드: 킹 g, 룩 f / 퀸사이드: 킹 c, 룩 d). 조건: 해당 킹·룩 미이동, 킹의 현재 칸~도착 칸 사이 모든 칸(양 끝 포함)과 룩의 현재 칸~도착 칸 사이 모든 칸이 그 킹과 룩을 제외하고 비어 있음, 킹이 현재 체크 아님, 킹이 지나가는 칸·도착 칸이 공격받지 않음. 킹이나 룩이 이미 도착 칸에 있을 수 있다(이동 0칸).
- 체크: 킹이 공격받는 상태. 자기 킹을 공격받게 두는 수(핀 된 기물 이동, 공격받는 칸으로 킹 이동 포함)는 불법.
### 5.3 턴 구조
- 차례인 플레이어는 합법 수 하나를 둔다. 패스 없음.
- 합법 수가 없으면: 체크 상태면 체크메이트(패배), 아니면 스테일메이트(무승부).
## 6. 승패와 점수 계산
- 승리: 체크메이트, 상대 기권, 상대 시간패(단 아래 예외), 상대 연결 끊김 시간 초과.
- 무승부:
1. 스테일메이트.
2. 기물 부족(데드 포지션의 결정적 부분집합, 자동): 킹 대 킹 / 킹+비숍 대 킹 / 킹+나이트 대 킹 / 킹+비숍(들) 대 킹+비숍(들)에서 모든 비숍이 같은 색 칸.
3. 3회 반복: 같은 국면(같은 차례, 같은 기물 배치, 같은 캐슬링 권리, 같은 앙파상 가능 여부 — 앙파상 칸은 실제로 합법적 앙파상 잡기가 가능할 때만 국면에 포함)이 3번째 나옴. `auto`면 즉시 무승부, `fide`면 차례인 플레이어가 클레임 가능, 5번째는 자동 무승부.
4. 50수 규칙: 양쪽 모두 50수(100 플라이) 동안 폰 이동·잡기 없음. `auto`면 즉시 무승부, `fide`면 클레임 가능, 75수(150 플라이)는 자동. 단 그 수가 체크메이트면 체크메이트 우선.
5. 무승부 합의.
6. 시간패 예외(FIDE 6.9): 시간이 다 된 쪽의 상대가 어떤 합법 수순으로도 체크메이트할 수 없으면 무승부. 판정 함수 `cannotMate(side)`: `side`가 킹만 있음 / 킹+나이트 1개만 있고 상대가 킹만 / 킹+비숍 1개만 있고 상대가 킹만 / `side`가 킹+같은 색 칸 비숍들만 있고 상대도 킹+같은 색 칸 비숍들만(또는 킹만). 이 외에는 메이트 가능으로 보수적으로 판단(→ 시간 초과한 쪽 패배). 예: 백이 시간 초과, 흑이 킹+나이트, 백이 킹+퀸이면 흑이 이론상 헬프메이트 가능하므로 흑 승.
- 점수 개념 없음. 결과 요약: "백 승 (체크메이트, 34수)".
- 대국 취소(`aborted`): 백 또는 흑이 첫 수를 `firstMoveSec` 안에 두지 않으면 결과 없이 취소(승패 기록 없음).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- `drawMode=auto`(기본): chess.com처럼 3회 반복과 50수를 클레임 없이 자동 무승부. 어린이·어르신이 "클레임" 개념을 몰라 무한 반복하는 일을 막기 위함.
- `drawMode=fide`: FIDE 9.2/9.3/9.6 그대로(클레임 버튼 제공 + 5회 반복·75수 자동).
- Chess960: 무작위 첫 배치. 오프닝 암기 부담을 없앤 변형.
- 터치 무브(손 댄 기물은 움직여야 함), 불법 수 둔 사람 패배 등 오프라인 규칙은 온라인에서 적용하지 않는다(불법 수는 서버가 거부).
## 8. 엔진 설계
### 8.0 라이브러리 vs 자체 구현 평가
| 후보 | 장점 | 단점 |
|---|---|---|
| chess.js (BSD-2-Clause, TypeScript) | 오래 쓰인 검증된 수 생성, FEN/PGN/SAN, `isCheckmate/isStalemate/isInsufficientMaterial/isThreefoldRepetition/isDrawByFiftyMoves` 제공, 서버·클라이언트 공용, 라이선스 자유로움 | Chess960 캐슬링 미지원(이슈 #67, #441 등 장기 미해결). 3회 반복 판정의 국면 동일성 기준, 기물 부족 기준을 우리 규칙과 맞춰 확인 필요 |
| chessops (TypeScript, lichess 개발자 제작) | Chess960 포함 각종 변형 지원, lichess에서 실사용 | GPL-3.0 이상. 클라이언트 번들에 포함하면 클라이언트 코드 공개 의무 발생 가능. 서버 전용으로 쓰면 배포가 아니므로 일반적으로 문제 없으나 법적 판단 필요 |
| 자체 구현 | 의존성·라이선스 없음, 960 포함 완전 통제 | 수 생성 버그 위험. perft 테스트로 검증 필수, 개발 시간 증가 |
- 권장: v1(standard)은 **chess.js를 서버에서만** 사용해 판정하고, 클라이언트는 서버 view의 `legalMoves`로 하이라이트(클라이언트 라이브러리 불필요 → 번들 경량화, 판정 이중화 불일치 방지). 대신 아래를 어댑터에서 우리 규칙으로 직접 판정한다: 반복 국면 키(앙파상 정규화), 50/75수, 기물 부족 목록, 시간패 예외 `cannotMate`.
- Chess960(후속 마일스톤): (a) chessops를 서버 전용으로 도입(GPL 적용 범위를 사용자/법률 검토 후) 또는 (b) 자체 구현 + perft 검증. 사용자가 오픈소스 공개를 원치 않고 법률 검토도 부담이면 (b). 이 문서의 상태·액션 정의는 두 방식 모두에 맞도록 FEN(960은 Shredder-FEN: 캐슬링 권리를 룩의 열 문자로 표기, 예 `HAha`)을 기준으로 한다.
- 어느 쪽이든 perft로 검증: 초기 국면 깊이 1~5 = 20 / 400 / 8,902 / 197,281 / 4,865,609, "Kiwipete" 국면(`r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1`) 깊이 1~4 = 48 / 2,039 / 97,862 / 4,085,603. 960은 공개된 chess960 perft 세트 사용.
### 8.1 상태(State)
```ts
type Color = 'white' | 'black';
interface ChessState {
options: ChessOptions;
players: Record<Color, PlayerId>;
startFen: string; // 960 배치 포함
chess960Index: number | null;
fen: string; // 현재 국면 (권위 있는 단일 진실)
moves: { uci: string; san: string; color: Color; at: number; fenAfter: string }[];
repetition: Record<string, number>; // 국면 키 -> 등장 횟수
phase: 'playing' | 'finished';
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; started: Record<Color, boolean> };
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number> };
claimable: 'threefold' | 'fiftyMove' | null; // drawMode=fide에서 차례인 쪽이 클레임 가능한 사유
result: ChessResult | null;
rng: RngState;
}
// 국면 키 = FEN의 앞 4필드(배치, 차례, 캐슬링 권리, 앙파상 칸).
// 단 앙파상 칸은 합법적인 앙파상 잡기가 실제로 있을 때만 남기고 아니면 '-'로 정규화.
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `move` | `{ from: 'e2', to: 'e4', promotion?: 'q'\|'r'\|'b'\|'n' }` | 차례인 플레이어, `playing` | 합법 수 목록에 존재. 승진 수인데 `promotion` 없으면 거부("승진할 기물을 골라 주세요"). 승진 수가 아닌데 있으면 무시. 실패 사유 예: "그 기물은 그렇게 움직일 수 없어요", "킹이 공격받게 되어 둘 수 없어요", "지금은 상대 차례예요" |
| `castle` | `{ side: 'king' \| 'queen' }` | 차례인 플레이어 | 5.2 조건. 960에서 킹 이동과 혼동되는 경우(킹이 1칸 옆 룩 칸 등) 명시적으로 사용. standard에서는 `move e1g1`도 캐슬링으로 인정 |
| `claimDraw` | `{}` | 차례인 플레이어, `drawMode=fide` | `claimable != null` (FIDE: 클레임하는 수를 두기 직전 또는 직후 국면). 수락 없이 즉시 무승부 |
| `requestUndo` | `{}` | 플레이어 | `undo != off`, 횟수 남음, 대기 없음, 본인 수 존재. 1 또는 2 플라이 |
| `respondUndo` | `{ accept }` | 상대 | 수락 시 `moves` 끝에서 제거하고 `startFen`에서 재생해 `fen`·`repetition` 재구성. 시계는 되돌리지 않음 |
| `offerDraw` | `{}` | 플레이어 | `drawOffer=true`, 대기 없음, 거절 후 본인 3수 이상 |
| `respondDraw` | `{ accept }` | 상대 | 수락 → 무승부 |
| `cancelOffer` | `{}` | 요청자 | |
| `resign` | `{}` | 플레이어 | 언제든(첫 수 전이면 `aborted`로 처리) |
| `timeout` | `{}` | 시스템 | 8.5 |
- 수 처리 순서: 시계 차감(0 이하면 시간패 처리, 6장 예외 포함) → 합법성 → 적용 → 증가분 가산 → 종료 판정 순서: 체크메이트 > 스테일메이트 > 기물 부족 > (75수/5회 반복 자동) > (`auto`면 50수/3회 반복) → `claimable` 갱신.
- 대기 요청은 요청받은 쪽이 수를 두면 자동 거절.
- 이벤트: `moved`(SAN, 잡힌 기물, 체크 여부), `check`, `checkmate`, `stalemate`, `promoted`, `castled`, `enPassant`, `drawClaimable`, `gameEnded` 등.
### 8.3 공개/비공개 정보 (view)
- 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과.
- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용). `hints=movesAndThreats`면 `threatenedSquares`(내 기물 중 공격받고 방어 안 된 칸).
- 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성).
- 관전자: `legalMoves`/`threatenedSquares` 없음.
- 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `colorAssignment=random`의 색 결정.
2. `chess960` 배치 번호 0~959 선택.
대국 중에는 RNG를 쓰지 않는다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 시계 시작: 각 색의 첫 수는 `firstMoveSec` 제한만 있고 본 시계를 차감하지 않는다(lichess 방식). 백이 첫 수를 두면 흑의 첫 수 제한이 시작되고, 흑이 첫 수를 두면 백의 본 시계가 시작된다.
- `deadline(state)`:
- 해당 색 첫 수 전: `turnStartedAt + firstMoveSec*1000`
- 피셔: `turnStartedAt + remainingMs[turn]`
- `none`: `turnStartedAt + idleLimitSec*1000`
- 종료: `null`
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- 첫 수 제한 초과 → `aborted`(승패 없음).
- 그 외 → `player` 시간패. 단 상대가 `cannotMate`면 무승부(`timeoutVsInsufficient`).
- 자동 수는 두지 않는다(체스에서 무작위 수는 승패를 왜곡).
- 연결 끊김: 끊긴 플레이어의 차례에 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout`. 상대 차례 중에 끊겼다면 자기 차례가 되는 순간부터 유예 계산.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface ChessResult {
ranking: { playerId: PlayerId; rank: 1 | 2 }[]; // 무승부면 둘 다 1, aborted면 빈 배열
winner: Color | null;
reason: 'checkmate' | 'resign' | 'timeLoss' | 'stalemate' | 'insufficientMaterial'
| 'threefold' | 'fivefold' | 'fiftyMove' | 'seventyFiveMove' | 'drawAgreed'
| 'timeoutVsInsufficient' | 'aborted';
pgn: string; // 태그(Variant, FEN 포함) + SAN 기보
moveCount: number; // 전체 수(풀무브)
summaryKo: string; // "흑 승 (체크메이트, 28수)"
}
```
## 9. UI/UX
- 모바일 세로: 상단 상대(잡은 기물, 시계), 가운데 정사각 판(폰 360px에서 칸 45px — 탭 정확도를 위해 칸 전체를 터치 영역으로), 하단 내 시계와 버튼 줄(무르기, 무승부, 기권, 기보, 규칙 보기). 판은 내 색이 아래로 오게 자동 회전.
- 조작: 기물 탭 → 이동 가능 칸 점 표시(잡기 칸은 테두리 링) → 도착 칸 탭. 드래그도 지원. 다른 내 기물 탭 시 선택 변경.
- 승진: 도착 시 큰 4개 버튼(퀸, 룩, 비숍, 나이트) 모달.
- 캐슬링: 킹 선택 시 캐슬링 도착 칸(standard: g1/c1, 960: 룩 칸도 탭 가능) 표시.
- 체크 시 킹 칸 빨간 배경 + "체크!" 말풍선, 체크메이트·스테일메이트 결과 모달에 이유 설명(초보용: "킹이 피할 곳이 없어요").
- 마지막 수 출발·도착 칸 강조, 이동 애니메이션 150ms 내외(저사양 고려).
- 규칙 보기: 기물별 움직임 애니메이션 카드, 특수 규칙(앙파상·캐슬링·승진) 그림 설명.
- 시계 10초 이하 시 빨간색 + 효과음(끌 수 있음).
- 관전자: 판 방향 전환 버튼, 기보 탐색(로컬).
## 10. 테스트 체크리스트
- [ ] perft: 초기 국면 깊이 1~4(가능하면 5), Kiwipete 깊이 1~3이 8.0의 수치와 일치(어댑터 경유로 검증).
- [ ] 캐슬링 금지 조건: 킹 체크 중, 지나가는 칸 공격받음, 도착 칸 공격받음, 룩 이동 이력, 킹 이동 이력. 퀸사이드에서 b1만 공격받는 경우는 허용.
- [ ] 앙파상: 2칸 전진 직후에만 가능, 한 수 늦으면 불가. 앙파상으로 자기 킹이 노출되는(가로 핀) 경우 불법.
- [ ] 승진: 4종 모두 가능, `promotion` 누락 시 거부, 잡으면서 승진.
- [ ] 체크메이트·스테일메이트 판정(예: 스칼라 메이트 4수, 퀸으로 인한 스테일메이트).
- [ ] 3회 반복: 앙파상 칸이 FEN에 있지만 실제 잡기 불가능한 경우 같은 국면으로 취급, 캐슬링 권리가 달라지면 다른 국면.
- [ ] 50수: `auto`는 100플라이 째 무승부, 단 그 수가 체크메이트면 메이트 우선. `fide`는 클레임 버튼 활성화, 150플라이 자동.
- [ ] 기물 부족: K vs K, KB vs K, KN vs K, 같은 색 비숍끼리 자동 무승부. KNN vs K는 자동 무승부 아님.
- [ ] 시간패 예외: 시간 초과한 쪽 상대가 킹만 → 무승부, 상대 킹+나이트이고 시간 초과한 쪽이 킹+퀸 → 상대 승.
- [ ] 첫 수 제한 초과 → `aborted`, 랭킹 비어 있음.
- [ ] 피셔 증가분: 수를 둔 뒤 가산, 0 이하로 도착한 수는 거부.
- [ ] Chess960: n=518이 표준 배치, 무작위 1000회 생성 시 비숍 반대 색·킹이 두 룩 사이 조건 항상 만족, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동).
- [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화.
- [ ] 연결 끊김 유예 후 시간패 처리.
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음.
- [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과).
## 11. 참고 자료
- FIDE Laws of Chess (2023년 1월 1일 시행): https://handbook.fide.com/chapter/E012023 (3.8 캐슬링, 3.7 앙파상·승진, 5.2 스테일메이트·데드 포지션, 6.9 시간패 예외, 9.2 3회 반복, 9.3 50수, 9.6 5회 반복·75수)
- FIDE 부록 Chess960 규칙(Guidelines III): 같은 핸드북
- Wikipedia "Fischer random chess": https://en.wikipedia.org/wiki/Fischer_random_chess (Scharnagl 번호 체계)
- chess.js (BSD-2-Clause): https://github.com/jhlywa/chess.js , Chess960 미지원 이슈: https://github.com/jhlywa/chess.js/issues/67
- chessops (GPL-3.0-or-later): https://github.com/niklasf/chessops
- Chess Programming Wiki "Perft Results": https://www.chessprogramming.org/Perft_Results
## 12. 메모 (상표·법적 주의 등)
- "체스", "Chess"는 일반 명칭. "Chess960"/"Fischer Random"은 일반적으로 쓰이는 변형 명칭이지만 "피셔 랜덤" 대신 "체스960" 표기를 권장.
- 라이선스: chess.js는 BSD-2(저작권 고지 포함 필요). chessops 도입 시 GPL-3 의무(특히 클라이언트 배포) 검토 필요 — 사용자 결정 사항.
- 금전·베팅 요소 없음. 레이팅(Elo 등)은 범위 밖(방 단위 대국만).

View File

@@ -0,0 +1,190 @@
# 바퀴벌레 포커 (`cockroach-poker`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 6명 · 예상 시간: 약 15~20분 · 난이도: 쉬움
## 1. 개요
- 해충 카드를 뒤집어 건네며 "이건 쥐야"라고 말하면, 받은 사람이 진실/거짓을 맞히거나 몰래 보고 다른 사람에게 떠넘기는 블러핑 게임. 같은 해충 4장을 앞에 모으는 사람이 진다(패배자 1명을 정하는 게임).
- 한국에서는 보드게임 카페 입문용 파티 게임으로 인기가 높고, 규칙이 단순해 어린이·어른이 함께 하기 좋다.
- 인원 근거: 원작(Drei Magier Spiele, 2004, 자크 젤리거) 박스 표기 2~6인. 2인은 원작의 별도 2인 규칙을 따른다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `loseCount` | 같은 종류가 몇 장 쌓이면 패배 | 3 / 4 / 5 | 3~6인 4, 2인 5(원작) |
| `turnSeconds` | 카드 건네기/판단 제한 시간 | 15 / 30 / 60 | 30 |
| `showSeenBy` | 현재 카드를 이미 본 사람 표시 | on 고정 | on |
| `claimHistory` | 이번 라운드의 주장 이력(누가 무엇이라 했는지) 표시 | on / off | on |
| `quickChat` | 정해진 문구 버튼("정말이야!", "의심스러운데?") | on / off | on |
## 3. 구성물
- 카드 64장: 해충 8종 × 8장.
| 종류 키 | 표시 이름 |
|---|---|
| `cockroach` | 바퀴벌레 |
| `bat` | 박쥐 |
| `fly` | 파리 |
| `toad` | 두꺼비 |
| `rat` | 쥐 |
| `scorpion` | 전갈 |
| `spider` | 거미 |
| `stinkbug` | 노린재 |
어린이 대상 서비스이므로 그림은 귀여운 캐릭터풍 자체 제작 아트를 쓴다(원작 아트 사용 금지).
## 4. 준비(셋업)
1. 64장을 RNG로 섞는다.
2. 2인: 섞은 뒤 10장을 보지 않고 제거(공개하지 않음). 남은 54장을 27장씩.
3. 3~6인: 전부 나눈다. 좌석 순(첫 플레이어부터)으로 1장씩 돌려 나누므로 앞 좌석이 1장 더 받을 수 있다.
| 인원 | 손패 장수 |
|---|---|
| 2 | 27, 27 (10장 제거) |
| 3 | 22, 21, 21 |
| 4 | 16 × 4 |
| 5 | 13, 13, 13, 13, 12 |
| 6 | 11, 11, 11, 11, 10, 10 |
4. 첫 플레이어는 RNG로 정하고, 좌석은 그 사람부터 시작하게 배치한다(첫 플레이어가 가장 많이 받음).
5. 각자 앞의 "앞마당"(공개 카드 영역)은 비어 있다.
## 5. 진행 규칙
### 5.1 라운드 시작
- 라운드 시작 플레이어(시작자)가 손패 1장을 골라 뒷면으로 다른 플레이어 1명에게 건네며 해충 종류 1개를 주장한다. 주장은 진실이어도 거짓이어도 된다.
- 시작자의 손패가 0장이면 그 즉시 시작자가 진다(6장).
### 5.2 카드를 받은 사람의 선택
받은 사람(수신자)은 둘 중 하나를 고른다.
1. **판정**: "진실이다" 또는 "거짓이다"라고 말하고 카드를 공개한다.
- 맞히면: 카드를 건넨 사람(직전 전달자)이 그 카드를 자기 앞마당에 공개해 놓는다.
- 틀리면: 수신자가 자기 앞마당에 놓는다.
2. **떠넘기기**: 카드를 몰래 확인한 뒤, 이 카드를 아직 보지 않은 다른 플레이어에게 건네며 다시 주장한다(같은 주장이든 다른 주장이든 자유).
- "본 사람" = 원래 건넨 시작자 + 이 카드를 본 모든 수신자. 이 사람들에게는 건넬 수 없다.
- 건넬 수 있는 사람이 없으면(나머지 전원이 이미 봄) 떠넘기기 불가, 반드시 판정.
- 손패가 0장인 사람에게도 건넬 수 있다(손패와 무관).
- 2인 규칙: 떠넘기기 없음. 항상 판정.
### 5.3 라운드 종료와 다음 시작자
- 앞마당에 카드를 받은 사람이 다음 라운드 시작자.
- 판정 대상은 언제나 **가장 마지막 주장**이다.
### 5.4 예시(4인 A·B·C·D)
1. A가 실제 "거미"를 B에게 건네며 "파리"라고 주장.
2. B가 몰래 보고(거미) C에게 "거미"라고 주장(진실). 본 사람 = {A, B}.
3. C가 "거짓이다" 판정 → 공개하니 거미 → 주장은 진실이었으므로 C가 틀림 → C의 앞마당에 거미. C가 다음 시작자.
4. 만약 C가 떠넘기기를 골랐다면 보고 나서 D에게만 건넬 수 있다. D는 떠넘길 사람이 없으므로 반드시 판정.
## 6. 승패와 점수 계산
- 패배 조건(먼저 성립하는 즉시 종료):
1. 어떤 플레이어의 앞마당에 같은 종류가 `loseCount`장(기본 4, 2인 5) 모였다.
2. 라운드 시작자가 되어야 하는데 손패가 0장이다.
- 패배자 1명, 나머지 전원 공동 승리.
- 순위(방 기록용 보조 표시): 패배자 꼴찌. 나머지는 "앞마당 최다 동일 종류 장수"가 적은 순 → 앞마당 총 장수가 적은 순 → 그래도 같으면 공동 순위.
- 점수: 승리자 각 1점, 패배자 0점.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `loseCount=3` | 짧은 게임(어린이 모드 추천) | off |
| `eightKindsRule` | 앞마당에 8종을 모두 1장 이상 모아도 패배(일부 하우스 룰) | off |
| `twoPlayerPass` | 2인에서도 수신자가 보고 되돌려 줄 수 있음(원작에 없음) | off |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Kind = 'cockroach' | 'bat' | 'fly' | 'toad' | 'rat' | 'scorpion' | 'spider' | 'stinkbug';
interface Card { id: number; kind: Kind } // id 0..63
interface CockroachState {
options: CockroachOptions;
rng: RngState;
order: PlayerId[]; // 좌석
hands: Record<PlayerId, number[]>;
fronts: Record<PlayerId, number[]>; // 공개 앞마당
removed: number[]; // 2인 제거 10장(비공개)
round: {
starter: PlayerId;
card: number | null; // 건네는 중인 카드
holder: PlayerId | null; // 현재 수신자(판단할 사람). null이면 시작자가 고르는 중
lastPasser: PlayerId | null;
claim: Kind | null; // 마지막 주장
seen: PlayerId[]; // 이 카드를 본 사람
claims: { from: PlayerId; to: PlayerId; claim: Kind }[];
deadline: number;
};
loser: PlayerId | null;
lossReason: 'four' | 'emptyHand' | null;
lastReveal?: { card: number; claim: Kind; call: 'true' | 'false'; caller: PlayerId; correct: boolean; takenBy: PlayerId };
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `offer` | `{ cardId; to; claim }` | 시작자, `card == null` | 손패에 있음, `to` != 본인, `claim`이 8종 중 하나 |
| `call` | `{ call: 'true' \| 'false' }` | 현재 holder, 아직 peek 안 함 | — |
| `peek` | `{}` | 현재 holder, 아직 안 봄 | 2인이 아님(`twoPlayerPass` 제외), 떠넘길 대상(`seen`에 없고 본인 아닌 사람)이 1명 이상. 이후 holder는 `seen`에 추가되고 view로 카드 내용을 받으며 `call` 불가 |
| `pass` | `{ to; claim }` | peek 완료한 holder | `to`가 `seen`에 없고 본인 아님, `claim`이 8종 중 하나 |
클라이언트는 서버가 카드 내용을 보내기 전에는 카드를 알 수 없으므로 "보기"와 "넘기기"를 두 액션으로 나눈다. 원작에서 "보고 나면 반드시 넘겨야 한다"는 규칙을 그대로 강제한다(peek 후 call 거부, 사유 "카드를 이미 봤으니 다른 사람에게 넘겨야 해요").
### 8.3 공개/비공개 정보 (view)
- 모두: 좌석, 각자 손패 장수, 모든 앞마당, 시작자, 현재 holder, 현재 주장, 주장 이력, `seen` 목록, 공개된 판정 결과.
- 본인: 자기 손패 내용. 건네는 중인 카드 내용은 `seen`에 있는 사람(시작자와 peek한 사람)에게만.
- 비공개: 다른 사람 손패, 건네는 중인 카드(미확인자·관전자), 2인 제거 카드(진행 중 비공개, 게임 종료 후 전체 손패와 함께 공개).
- 관전자: 공개 정보만.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
덱 셔플, 2인 제거 카드(셔플 결과의 앞 10장), 첫 시작자, 시간 초과 시 무작위 선택.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `activePlayers`: card 없으면 시작자, 있으면 holder.
- `onTimeout`:
- 시작자 → RNG로 손패 1장, 다음 좌석 플레이어에게, 주장은 RNG 종류.
- holder(미확인) → `call` 중 RNG(true/false).
- holder(peek 완료) → `seen`에 없는 첫 좌석 플레이어에게 "실제 종류"로 `pass`.
- 연결이 90초 이상 끊긴 플레이어는 차례가 오면 즉시 onTimeout 처리. 수신 대상으로는 계속 지정 가능(지정 시 바로 자동 판정).
### 8.6 종료 조건과 결과(GameResult)
```ts
interface CockroachResult {
loser: PlayerId;
reason: 'four' | 'emptyHand';
losingKind?: Kind;
ranking: PlayerId[][]; // 공동 순위 그룹, 마지막 그룹 = [loser]
fronts: Record<PlayerId, Record<Kind, number>>;
summary: string; // 예: "민수님 앞에 쥐 4장! 민수님 패배, 나머지 모두 승리"
}
```
## 9. UI/UX
- 모바일 세로: 상단 상대 플레이어 카드(아바타 + 손패 장수 + 앞마당 종류별 개수 배지, 3장째부터 노란색 경고, 위험 종류는 빨간색). 가운데 "건네지는 카드" 애니메이션과 말풍선 주장("이건 쥐야!"). 하단 내 손패(종류별 묶음 + 개수 배지, 탭으로 선택).
- 건네기 흐름: 카드 선택 → 받을 사람 탭(건넬 수 없는 사람은 회색) → 8종 그림 버튼 중 주장 선택(그림 + 글자, 각 64px 이상) → 확인.
- 판정 화면: 큰 버튼 두 개 "진짜!"(초록), "거짓말!"(빨강) + "몰래 보고 넘기기"(파랑, 넘길 사람이 없으면 비활성 + 이유).
- 공개 애니메이션: 카드 뒤집기 후 "맞혔어요!/틀렸어요!"와 앞마당으로 날아가는 연출.
- 초보자 도움말: "같은 벌레 4장 모이면 져요", 위험 경고 툴팁.
## 10. 테스트 체크리스트
- [ ] 인원별 손패 장수가 4장 표와 일치, 2인은 10장 제거 후 27장씩, 카드 총합 보존.
- [ ] 판정 정답(진실 주장에 "진실") → 전달자 앞마당, 오답 → 수신자 앞마당, 받은 사람이 다음 시작자.
- [ ] 떠넘기기 시 `seen`에 있는 사람(시작자 포함)에게 pass 거부.
- [ ] 나머지 전원이 본 상태의 holder는 peek 거부, call만 가능.
- [ ] peek 후 call 거부.
- [ ] 2인: peek 거부, 같은 종류 5장에서 패배(4장에서는 계속).
- [ ] 같은 종류 4장 도달 즉시 종료(다른 종류 4장은 무관).
- [ ] 다음 시작자의 손패가 0장 → 즉시 패배(reason emptyHand).
- [ ] 손패 0장인 사람에게 카드 건네기 허용, 그가 떠넘기기/판정 정상 동작.
- [ ] 시간 초과: 시작자 무응답 → 자동 offer, holder 무응답 → 자동 call, peek 후 무응답 → 자동 pass.
- [ ] 비공개 누출 검사: 미확인 holder·관전자·다른 플레이어 view에 건네는 카드의 kind가 없는지, 다른 사람 손패 내용이 없는지, 2인 제거 카드가 진행 중에 없는지.
- [ ] 같은 시드 + 액션 로그 재생 시 동일 결과.
## 11. 참고 자료
- 원작 규칙(영문) 요약, UltraBoardGames "How to play Cockroach Poker" — https://www.ultraboardgames.com/cockroach-poker/game-rules.php
- Wikipedia "Cockroach Poker" — https://en.wikipedia.org/wiki/Cockroach_Poker
- BoardGameGeek "Cockroach Poker" — https://boardgamegeek.com/boardgame/11971/cockroach-poker
- University of Waterloo 빠른 규칙 요약(PDF) — https://uwaterloo.ca/childrens-communication-lab/sites/default/files/uploads/documents/cockroach-poker.pdf
## 12. 메모 (상표·법적 주의 등)
- "Cockroach Poker / Kakerlakenpoker / 바퀴벌레 포커"는 Drei Magier Spiele(현 Schmidt Spiele)의 제품명이다. 규칙은 보호 대상이 아니지만 이름과 아트는 사용하지 않는다. 표시 이름 후보: "벌레 떠넘기기", "거짓말 벌레", "벌레 블러핑".
- 이름에 "포커"가 들어가지만 칩·베팅이 전혀 없는 게임이므로 도박 관련 이슈 없음. 다만 표시 이름에서 "포커"를 빼는 편이 어린이 서비스 인상에 좋다.
- 벌레 그림을 싫어하는 이용자를 위해 "동물 테마" 스킨(종류 키는 동일, 그림만 교체) 검토.

242
docs/games/dalmuti.md Normal file
View File

@@ -0,0 +1,242 @@
# 달무티 (`dalmuti`)
> 마일스톤: M7 · 인원: 최소 4 ~ 최대 8명 · 예상 시간: 한 라운드 약 10분 (기본 3라운드 약 30분) · 난이도: 쉬움
## 1. 개요
- 신분 계급이 있는 버리기형 카드 게임이다. 숫자가 낮을수록 좋은 카드다. 앞사람과 같은 장수를 더 낮은 숫자로 내고, 손패를 먼저 다 털수록 다음 라운드에서 높은 신분이 된다. 높은 신분은 세금으로 낮은 신분의 좋은 카드를 받는다.
- 원작은 리처드 가필드의 "The Great Dalmuti"(1995)다. 한국에서는 "위대한 달무티"로 학교와 모임 파티 게임으로 많이 알려졌다. 같은 계열 민속 게임으로 "대통령(President)", "대부호(大富豪)"가 있다.
- 인원 근거: 원작 박스 기준 4~8명. 나무위키도 4~8명(5~7명 권장)이다. 2~3명이나 9명 이상은 비공식이라 지원하지 않는다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `rounds` | 라운드 수 | 1 / 3 / 5 / 7 | 3 |
| `scoring` | 최종 순위 방식 | `rank-points`(라운드별 순위 점수 합) / `last-round`(마지막 라운드 순위) | `rank-points` |
| `firstRanking` | 첫 라운드 신분 정하기 | `draw`(카드 뽑기, 원작) / `random` | `draw` |
| `revolution` | 혁명 규칙 사용 | on / off | on |
| `jesterTaxable` | 세금 낼 때 광대도 "좋은 카드"로 내야 하나 | on / off | off |
| `passLocksOut` | 한 번 패스하면 그 트릭에서 다시 못 냄(하우스 룰) | on / off(원작) | off |
| `removeHighCards` | 9~12 카드를 빼고 하기(손패가 많을 때, 나무위키 언급) | on / off | off |
| `turnSeconds` | 턴 제한 시간 | 15 / 20 / 30 / 45 | 20 |
## 3. 구성물
- 숫자 카드 78장: 숫자 n인 카드가 n장이다(1이 1장, 2가 2장, …, 12가 12장).
- 광대(Jester) 2장. 단독으로 내면 13이고, 다른 카드와 함께 내면 그 숫자로 변한다(와일드).
- 합계 80장. `removeHighCards = on`이면 9~12(42장)를 빼고 38장으로 한다.
- 카드 이름(원작 계급 이름, 화면 표시용)
| 숫자 | 이름 | 장수 |
|---|---|---|
| 1 | 달무티 | 1 |
| 2 | 대주교 | 2 |
| 3 | 시종장 | 3 |
| 4 | 남작부인 | 4 |
| 5 | 수녀원장 | 5 |
| 6 | 기사 | 6 |
| 7 | 재봉사 | 7 |
| 8 | 석공 | 8 |
| 9 | 요리사 | 9 |
| 10 | 양치기 | 10 |
| 11 | 광부 | 11 |
| 12 | 농노 | 12 |
| 13(광대) | 광대 | 2 |
- 플레이어 신분(라운드 순위)
- 1위: 대달무티
- 2위: 소달무티
- 3위 ~ N-2위: 평민
- N-1위: 소농노
- N위: 대농노
- 4인이면 평민이 없다.
## 4. 준비(셋업)
1. **첫 라운드 신분**(`firstRanking = draw`): 섞은 덱에서 각자 1장씩 뽑는다. 숫자가 낮을수록 높은 신분이고, 광대는 13으로 본다. 같은 숫자끼리는 그 사람들만 다시 뽑아 순서를 정한다. 엔진은 RNG로 같은 절차를 시뮬레이션하고 결과를 애니메이션으로 보여 준다.
2. **자리와 순서**: 신분 순서대로 시계 방향으로 앉는다(대달무티 → 소달무티 → 평민들 → 소농노 → 대농노 → 다시 대달무티). 턴 순서도 이 순서다.
3. **분배**: 덱을 섞어 대달무티부터 신분 순서대로 1장씩 전부 나눠 준다. 나누어떨어지지 않으면 앞 신분이 1장씩 더 받는다.
| 인원 | 손패(80장) |
|---|---|
| 4 | 20 × 4 |
| 5 | 16 × 5 |
| 6 | 14, 14, 13, 13, 13, 13 |
| 7 | 12, 12, 12, 11, 11, 11, 11 |
| 8 | 10 × 8 |
4. **혁명 확인 단계**(`revolution = on`): 모든 플레이어가 손패를 확인하고 "확인"을 누른다. 광대 2장을 모두 가진 사람에게만 "혁명 선언" 버튼이 함께 보인다. 모든 사람이 같은 단계를 거치므로 단계가 생긴 것만으로 광대 소유자가 드러나지 않는다.
- **혁명**: 광대 2장을 가진 사람(대농노 제외)이 선언하면 광대 2장을 공개한다. 이번 라운드는 세금을 걷지 않고, 신분은 그대로다.
- **대혁명**: 대농노가 광대 2장을 가지고 선언하면 신분 순서가 완전히 뒤집힌다. 대농노가 대달무티, 대달무티가 대농노가 되고 평민 순서도 뒤집힌다. 자리와 턴 순서도 뒤집히고 세금은 없다.
- 선언은 선택이다. 선언하지 않으면 평소대로 세금을 걷는다.
5. **세금**(혁명이 없을 때)
- 대농노는 손패에서 가장 좋은(숫자가 가장 낮은) 카드 2장을 대달무티에게 준다. 소농노는 가장 좋은 카드 1장을 소달무티에게 준다. 선택권이 없으므로 서버가 자동으로 옮긴다.
- `jesterTaxable = off`(기본)이면 광대는 "가장 좋은 카드" 계산에서 빼고 숫자 카드 중 가장 낮은 것을 준다. 같은 숫자가 여럿이면 어느 것을 줘도 같으므로 카드 ID 순으로 정한다.
- 받은 대달무티는 아무 카드 2장(방금 받은 카드 포함 가능), 소달무티는 아무 카드 1장을 골라 돌려준다. 두 사람은 동시에 고른다.
- 4인이면 소달무티 ↔ 소농노, 대달무티 ↔ 대농노만 있다.
6. **첫 트릭**은 대달무티가 시작한다(대혁명이면 새 대달무티).
## 5. 진행 규칙
### 5.1 트릭 진행
- **선(리드)**: 트릭을 시작하는 사람은 같은 숫자 카드를 원하는 장수(1장 이상)만큼 낸다. 광대를 섞어도 된다.
- **따라 내기**: 다음 사람부터 순서대로, 바로 앞에 나온 묶음과 같은 장수이면서 숫자가 더 낮은(더 좋은) 묶음을 내거나 패스한다.
- 예: 9 세 장이 나왔으면 1~8 중 같은 숫자 세 장만 낼 수 있다(1은 1장뿐이라 광대를 섞어야 3장이 된다).
- **광대**
- 광대만 내면 1장당 13이다. 광대 1장은 13 한 장, 광대 2장은 13 두 장이다.
- 숫자 카드와 함께 내면 그 숫자로 간주한다. 예: 4 + 광대 = 4 두 장, 1 + 광대 = 1 두 장(원작에 1과 광대를 같이 내는 것을 막는 문구가 없다).
- 한 묶음에 숫자 카드가 섞일 때 숫자 카드는 모두 같은 숫자여야 한다.
- **패스**: 낼 수 있어도 언제든 패스할 수 있다. 원작 기본(`passLocksOut = off`)에서는 패스해도 같은 트릭에서 차례가 다시 오면 낼 수 있다.
- **트릭 종료**: 마지막으로 낸 사람을 뺀, 아직 카드가 있는 나머지 모두가 연속으로 패스하면 트릭이 끝난다. 바닥 카드를 치우고 마지막으로 낸 사람이 새 트릭을 시작한다.
- 마지막으로 낸 사람이 그 묶음으로 손패를 다 털었으면, 턴 순서상 그 사람의 다음이면서 아직 카드가 있는 사람이 새 트릭을 시작한다.
- `passLocksOut = on`이면 패스한 사람은 그 트릭의 나머지 차례가 자동으로 건너뛰어진다. 트릭 종료 조건은 같다.
### 5.2 손패를 다 턴 경우와 라운드 종료
- 손패를 모두 낸 순서대로 다음 라운드 신분이 정해진다. 처음 턴 사람이 다음 대달무티, 두 번째가 소달무티, …
- 카드가 남은 사람이 1명이 되면 라운드가 끝나고 그 사람이 다음 대농노다. 그 사람의 남은 카드는 따로 내지 않는다.
- 다음 라운드는 새 신분으로 자리를 다시 잡고 4번 셋업 3단계(분배)부터 반복한다.
### 5.3 예시(5인)
- 대달무티 A가 7 두 장을 낸다. B는 5 두 장, C는 패스, D는 2 + 광대(2 두 장), E는 패스, A는 패스(1은 1장뿐이고 광대가 없음), B는 패스, C는 패스.
- D를 뺀 모두가 연속으로 패스했으므로 트릭이 끝나고 D가 새 트릭을 시작한다.
## 6. 승패와 점수 계산
- 원작에는 정해진 승리 조건이 없다(합의할 때까지 계속). 이 사이트에서는 `rounds`만큼 하고 끝낸다.
- `rank-points`(기본): 각 라운드에서 순위 r(1~N)인 사람이 N - r점을 받는다(1위 N-1점, 꼴찌 0점).
- 예(5인): 1위 4, 2위 3, 3위 2, 4위 1, 5위 0점.
- 최종 순위는 총점이 높은 순이고, 같으면 마지막 라운드 순위가 높은 사람이 앞선다.
- `last-round`: 마지막 라운드 순위가 최종 순위다.
- 점수는 방 안 결과 표시용이다. 칩이나 돈이 아니다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 혁명 끄기(`revolution`).
- 세금에 광대 포함(`jesterTaxable`). 일부 모임은 광대도 좋은 카드로 내야 한다고 한다.
- 한 번 패스하면 트릭에서 빠짐(`passLocksOut`). 대부호/대통령 계열에서 흔한 규칙이다.
- 9~12 빼기(`removeHighCards`). 인원이 적어 손패가 많을 때 쓴다.
- 지원하지 않는 변형(문서화만): 혁명 시 숫자 강약 반전(대부호식 "혁명"), 8 끊기, 같은 숫자 연속 금지(시바리), 낙오(도시락) 규칙.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
interface DCard { id: string; n: number } // 1..12, 광대는 13
type Play = { cardIds: string[]; count: number; rank: number }; // rank: 광대 섞이면 숫자 카드의 숫자, 광대만이면 13
interface DalmutiState {
options: DalmutiOptions;
rng: RngState;
players: PlayerId[]; // 참가자(불변)
order: PlayerId[]; // 이번 라운드 신분 순서 = 턴 순서. [0]=대달무티
round: number; // 1부터
hands: Record<PlayerId, DCard[]>; // 비공개
phase:
| { kind: 'firstRankDraw'; draws: Record<PlayerId, number[]> } // 연출용, 즉시 다음 단계
| { kind: 'revolutionCheck'; confirmed: Record<PlayerId, boolean> }
| { kind: 'taxReturn'; owed: Record<PlayerId, { to: PlayerId; count: number; done: boolean }> }
| { kind: 'playing' }
| { kind: 'roundOver' }
| { kind: 'finished' };
revolution: 'none' | 'revolution' | 'great' ;
taxLog: { from: PlayerId; to: PlayerId; cardIds: string[] }[]; // 당사자만 열람
trick: {
leader: PlayerId; // 이 트릭을 시작한 사람
top: (Play & { by: PlayerId }) | null; // 바닥 맨 위 묶음
passesSinceTop: PlayerId[]; // top 이후 패스한 사람
lockedOut: PlayerId[]; // passLocksOut용
pile: Play[]; // 이 트릭에 나온 묶음(공개)
};
current: PlayerId;
finishOrder: PlayerId[]; // 이번 라운드 나간 순서
roundResults: { order: PlayerId[]; revolution: string }[];
totals: Record<PlayerId, number>;
turnStartedAt: number;
seq: number;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `confirmHand` | `{ declareRevolution?: boolean }` | 모든 플레이어, phase=`revolutionCheck` | 한 번만. `declareRevolution=true`는 광대 2장을 모두 가진 사람만(아니면 "광대 2장이 있어야 혁명할 수 있어요") |
| `returnTax` | `{ cardIds: string[] }` | 대달무티(2장), 소달무티(1장), phase=`taxReturn` | 자기 손에 있는 카드이고 장수가 정확함 |
| `play` | `{ cardIds: string[] }` | `current`, phase=`playing` | 카드가 자기 손에 있음. 숫자 카드(광대 제외)는 모두 같은 숫자. 리드면 1장 이상. 따라 내기면 장수 = top.count이고 rank < top.rank. `passLocksOut` on이면 lockedOut이 아님 |
| `pass` | `{}` | `current`, phase=`playing` | 리드가 아닐 때만(리드는 반드시 내야 함) |
- `revolutionCheck`는 모두가 확인하면 끝난다.
- 선언자가 있으면 혁명 또는 대혁명을 적용하고 `playing`으로 간다.
- 없으면 세금을 자동으로 옮기고 `taxReturn`으로 간다. 두 사람이 모두 돌려주면 `playing`.
- `play` 처리: 묶음의 rank를 정하고 top을 바꾼 뒤 `passesSinceTop = []`. 손패가 0이 되면 `finishOrder`에 넣는다. 남은 사람이 1명이면 `roundOver`.
- 다음 차례 계산: 턴 순서에서 다음 사람 중 카드가 있는(그리고 lockedOut이 아닌) 사람.
- 트릭 종료 판정: (카드가 남은 사람 중 top.by가 아닌 모두)가 `passesSinceTop`에 있으면 종료한다. top.by가 이미 나간 경우에는 카드가 남은 모두가 패스하면 종료한다.
- `roundOver` 처리: 점수를 반영한다. 라운드가 남았으면 `order = finishOrder + [남은 1명]`으로 새 라운드 분배를 하고 `revolutionCheck`로 간다. 다 했으면 `finished`.
- `reason` 예: "앞사람과 같은 장수(3장)로 내야 해요", "더 낮은 숫자만 낼 수 있어요", "같은 숫자끼리만 낼 수 있어요", "새로 시작할 때는 패스할 수 없어요".
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함)
- 신분 순서, 각자 손패 장수, 현재 트릭의 모든 묶음(누가 무엇을 냈는지), 현재 차례, 패스 표시, 나간 순서.
- 라운드와 총점, 혁명 여부. 혁명이 선언되면 선언자와 "광대 2장"이 공개된다.
- 본인만: 자기 손패.
- 세금: 주고받은 카드 내용은 준 사람과 받은 사람에게만 보인다. 다른 사람과 관전자는 "대농노 → 대달무티 2장" 같은 장수만 받는다.
- `revolutionCheck`: 각자에게 `canDeclareRevolution`이 본인 view에만 있다. 다른 사람은 확인 완료 여부만 본다.
- 절대 보내지 않는 것: 남의 손패, 덱 섞인 순서, RNG 상태.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 첫 라운드 신분 뽑기(동점 재뽑기 포함).
- 매 라운드 덱 섞기.
- 그 외 없음(세금 자동 선택은 숫자 → 카드 ID 순으로 결정적).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 마감: `revolutionCheck` 20초, `taxReturn` 30초, 플레이 `turnSeconds`.
- `onTimeout`
- `revolutionCheck`: `confirmHand { declareRevolution: false }`. 자동으로 혁명을 선언하지 않는다.
- `taxReturn`: 숫자가 가장 큰(가장 나쁜) 숫자 카드부터 필요한 장수만큼 돌려준다(광대 제외).
- `playing` 리드: 광대를 빼고 가장 큰 숫자 카드를 그 숫자 전부 낸다. 광대만 남았으면 광대를 모두 낸다.
- `playing` 따라 내기: `pass`.
- 연결이 끊겨도 탈락시키지 않는다. 위 자동 행동으로 라운드가 끝까지 진행된다. 연속 3턴 타임아웃이면 서버가 마감을 5초로 줄인다.
### 8.6 종료 조건과 결과(GameResult)
- `round == rounds`의 라운드가 끝나면 종료한다.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; total: number; roundRanks: number[] }[];
winner: PlayerId[];
summary: string; // "3라운드 동안 지수 님이 가장 높은 신분을 지켰어요!"
}
```
## 9. UI/UX
- 모바일 세로 화면
- 위: 신분 순서대로 상대를 원형으로 배치한다. 각자 왕관/모자 아이콘(신분), 손패 장수, 패스 말풍선을 표시한다.
- 가운데: 현재 트릭 바닥 묶음(마지막 묶음을 크게, 이전 묶음은 겹쳐서 작게). "지금 기준: 5 × 2장"을 큰 글자로 보여 준다.
- 아래: 내 손패를 숫자별로 묶어서 보여 준다(같은 숫자 겹치기 + 장수 배지). 탭 영역은 48px 이상이다.
- 조작
- 숫자 묶음을 탭하면 필요한 장수만큼 자동으로 선택된다(따라 내기일 때 기준 장수만큼). +/-로 장수를 조절한다. 광대는 "광대 섞기" 토글로 추가한다.
- "내기"와 "패스" 큰 버튼을 둔다.
- 낼 수 있는 숫자 묶음만 밝게 표시하고, 나머지는 흐리게 한다. 광대를 섞어야만 낼 수 있는 묶음은 "광대 필요" 표시를 붙인다.
- 세금 화면: 받은 카드를 강조하고 "돌려줄 카드 2장을 고르세요"를 안내한다. 기본 추천은 가장 큰 숫자 카드다.
- 혁명 확인 화면: 모두에게 "손패 확인" 버튼이 있고, 광대 2장 보유자에게만 "혁명!" 버튼이 추가로 보인다.
- 애니메이션: 신분 바뀔 때 자리 이동, 대혁명 때 자리가 뒤집히는 연출, 세금 카드 이동.
- 초보 도움말: "숫자가 낮을수록 강해요", "앞사람과 같은 장수로만 낼 수 있어요"를 상단 고정 팁으로 둔다(끌 수 있음). "규칙 보기"에 카드 장수 표와 신분 그림을 넣는다.
## 10. 테스트 체크리스트
- [ ] 덱: 80장(숫자 n이 n장, 광대 2장). 6인 분배가 14, 14, 13, 13, 13, 13이고, 앞 신분이 더 받는다.
- [ ] 첫 신분 뽑기: 같은 시드면 같은 결과다. 동점자 재뽑기 후 순서가 확정된다.
- [ ] 세금: 대농노 손패 [광대, 광대, 3, 3, 7…]이고 `jesterTaxable=off`이면 3, 3이 대달무티에게 간다. `on`이면 광대 2장이 간다.
- [ ] 세금 돌려주기: 대달무티가 방금 받은 카드를 돌려줄 수 있다. 장수가 틀리면 거부된다.
- [ ] 혁명: 평민이 광대 2장으로 선언하면 세금 없이 신분 그대로 시작한다. 대농노가 선언하면 순서가 완전히 뒤집히고 새 대달무티가 첫 트릭을 시작한다.
- [ ] 혁명 정보 유출: 광대 2장 보유자가 있든 없든 `revolutionCheck` 단계가 똑같이 생긴다. 다른 사람 view에 `canDeclareRevolution`이 없다.
- [ ] 따라 내기: 9×3 위에 8×2는 거부(장수 다름), 9×3은 거부(같은 숫자), 4 + 4 + 광대는 허용, 광대×2 + 1은 허용(1×3).
- [ ] 광대 단독: 바닥 12×1 위에 광대 1장은 거부된다(13은 12보다 나쁨). 리드로 광대 1장은 허용(13×1)되고, 그 위에 아무 숫자 1장이 허용된다.
- [ ] 트릭 종료: 마지막으로 낸 사람을 뺀 전원이 패스하면 그 사람이 리드한다. 마지막 묶음으로 손패를 턴 경우 다음 사람이 리드한다.
- [ ] `passLocksOut=off`: 패스한 사람이 차례가 다시 오면 낼 수 있다. `on`이면 차례가 건너뛰어진다.
- [ ] 라운드 종료: 1명만 남으면 끝나고 그 사람이 대농노가 된다. 다음 라운드 순서 = 나간 순서.
- [ ] 점수: 5인 3라운드에서 순위 점수 합과 동점 처리(마지막 라운드 순위)가 맞다.
- [ ] 시간 초과: 리드 시간 초과 때 가장 큰 숫자 묶음을 전부 낸다. 따라 내기 시간 초과는 패스다. 세금 돌려주기 시간 초과는 가장 큰 숫자 카드다.
- [ ] 정보 유출: 관전자와 다른 플레이어 view에 남의 손패와 세금 카드 내용이 없다(장수만 있음).
- [ ] 차례가 아닌 `play`, 손에 없는 카드, 중복 패킷은 거부되고 상태가 바뀌지 않는다.
## 11. 참고 자료
- 나무위키 "달무티": https://namu.wiki/w/달무티 (80장 구성, 계급 이름, 신분 뽑기, 세금, 혁명·대혁명, 광대 13/와일드, 1+광대 허용, 9~12 빼기)
- Wikipedia "The Great Dalmuti": https://en.wikipedia.org/wiki/The_Great_Dalmuti (대농노가 덱 전체 분배, 세금, 혁명, 같은 장수 더 낮은 숫자, 승리 조건 없음)
## 12. 메모 (상표·법적 주의 등)
- "The Great Dalmuti"와 "달무티"는 원작사(Wizards of the Coast 등)와 국내 유통사의 상품명이다. 화면과 홍보에는 일반 이름을 쓴다. 후보: "신분 카드 게임", "왕과 농노", "계급 놀이"(사용자 선택).
- 카드 계급 이름(대주교, 남작부인 등)은 원작 고유 표현이다. 상표 우려를 줄이려면 숫자와 일반 이름(1 왕, 2 왕비… 등)으로 바꾸는 것도 고려한다. 일러스트는 직접 만든다.
- "농노" 같은 계급 표현은 게임 설정이므로 문제 없지만, 어린이 방에서는 "꼴찌" 대신 "다음엔 역전!" 같은 부드러운 문구를 쓴다.
- 돈, 칩을 쓰지 않는다. 점수는 방 안 결과 표시용이다.

209
docs/games/davinci-code.md Normal file
View File

@@ -0,0 +1,209 @@
# 다빈치 코드 (`davinci-code`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 10~15분 · 난이도: 쉬움~보통
## 1. 개요
- 검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, 상대 타일의 숫자를 추리해 맞히는 게임이다. 상대의 숨은 타일을 모두 드러내면 그 상대는 탈락하고, 마지막까지 숨은 타일이 남은 사람이 이긴다.
- 한국에서는 코리아보드게임즈 판으로 널리 알려졌고, 학교 수업과 방과후 교실의 추리 게임으로 많이 쓰인다. 예능 "더 지니어스"의 "흑과 백"으로도 유명하다.
- 인원 근거: 원작 박스와 나무위키 모두 2~4명이다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `jokers` | 조커(줄 표시 타일, 검정·흰색 각 1개) 사용 | on / off | off |
| `tieOrder` | 같은 숫자일 때 색 순서 | `black-left`(검정이 왼쪽) / `white-left` | `black-left` |
| `startTiles` | 시작 타일 수 | `auto`(2·3인 4개, 4인 3개) / 3 / 4 / 5 | `auto` |
| `chooseColors` | 타일을 가져올 때 색을 고르게 하기(실물 규칙, 뒷면 색이 보임) | on / off(무작위) | on |
| `emptyPoolPenalty` | 더미가 비었을 때 틀리면 | `reveal-own`(자기 숨은 타일 1개 공개) / `none`(벌칙 없음) | `reveal-own` |
| `turnSeconds` | 추리 제한 시간 | 20 / 30 / 45 / 60 | 30 |
| `deductionHelper` | 공개 정보로 가능한 숫자 후보 표시(초보 도움) | on / off | off |
## 3. 구성물
- 검은 타일 12개(0~11), 흰 타일 12개(0~11).
- 조커 2개(검정 1, 흰색 1). 숫자 대신 "-"가 그려져 있다. `jokers = on`일 때만 쓴다.
- 합계 24개(조커 사용 시 26개).
- 타일 뒷면은 그 타일의 색이다. 그래서 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨진다.
## 4. 준비(셋업)
1. 모든 타일을 뒤집어 가운데에 섞어 둔다(더미).
2. 선 플레이어를 RNG로 정하고, 진행은 시계 방향이다.
3. 시작 타일 가져오기
- 인원별 개수: 2·3인 4개, 4인 3개(`startTiles`).
- `chooseColors = on`: 각자 "검은색 몇 개, 흰색 몇 개"를 동시에 고른다(예: 검2 흰2). 고른 색의 타일 중에서 서버가 RNG로 뽑는다. 한 색이 모자라면 남은 수까지만 고를 수 있다. 선택 마감은 15초이고, 넘기면 가능한 한 반반으로 자동 선택한다.
- `chooseColors = off`: 서버가 무작위로 나눠 준다.
- 시작 타일에는 조커가 들어가지 않는다. 시작 분배는 조커를 뺀 타일에서만 하고, 조커는 분배가 끝난 뒤 더미에 섞는다.
4. 각자 받은 타일을 정렬 규칙(5.1)에 따라 자기 앞에 세운다. 서버가 자동으로 정렬한다.
## 5. 진행 규칙
### 5.1 정렬 규칙
- 자기 줄은 왼쪽에서 오른쪽으로 숫자가 커진다.
- 같은 숫자의 검정과 흰색이 함께 있으면 `tieOrder`에 따라 놓는다(기본: 검정이 왼쪽).
- 조커는 숫자가 없으므로 가져온 사람이 원하는 위치(아무 틈)에 끼운다. 한 번 놓은 뒤에는 옮길 수 없다.
- 줄의 타일 위치는 바뀌지 않고, 새 타일만 정렬 위치에 끼워진다. 상대 입장에서 위치가 추리의 중요한 단서다.
### 5.2 턴 순서
1. **가져오기**: 더미에서 타일 1개를 가져온다(`chooseColors = on`이면 색을 골라서). 이 타일을 "이번 턴 타일"이라 하고, 아직 줄에 끼우지 않고 따로 둔다. 숫자는 본인만 본다.
- 더미가 비었으면 이 단계는 건너뛴다.
2. **추리**: 아직 탈락하지 않은 상대 한 명의 숨은 타일 하나를 고르고 숫자를 말한다.
- 말할 수 있는 값: 0~11, 그리고 `jokers = on`이면 "조커".
- 그 타일의 색은 이미 보이므로 숫자만 말한다.
3. **결과**
- **맞힘**: 그 타일이 앞면으로 공개된다(자리는 그대로). 그 상대의 숨은 타일이 0개가 되면 그 상대는 즉시 탈락한다. 이어서 둘 중 하나를 고른다.
- **계속**: 2단계로 돌아가 다시 추리한다(대상과 타일은 자유).
- **멈춤**: 이번 턴 타일을 숨긴 채 정렬 위치에 끼우고 턴을 끝낸다.
- **틀림**: 이번 턴 타일을 앞면으로 공개한 채 정렬 위치에 끼우고 턴을 끝낸다.
- 더미가 비어 이번 턴 타일이 없으면 `emptyPoolPenalty = reveal-own`일 때 추리한 사람이 자기 숨은 타일 중 하나를 골라 공개한다. 그래서 자기 숨은 타일이 0개가 되면 본인이 탈락한다.
4. 마지막 상대를 탈락시키면 그 즉시 게임이 끝난다(멈춤/계속 선택 없음).
- 조커가 이번 턴 타일이면 끼울 때(멈춤 또는 틀림) 위치를 직접 고른다.
- 이미 공개된 타일은 추리 대상이 아니다. 자기 타일도 대상이 아니다.
- 이미 공개된 같은 색·같은 숫자 조합을 말하는 것처럼 논리적으로 불가능한 추리도 규칙상 허용한다(실물과 같음). UI에서만 경고한다.
### 5.3 예시
- A의 줄(왼→오): [검?][흰?][검?][흰?]. 공개된 것은 없다.
- B가 흰색 9를 가져온다. A의 두 번째 타일(흰)을 "3"이라고 말한다. 맞힘 → A의 흰3 공개.
- B가 계속을 고른다. A의 네 번째(흰)를 "10"이라고 말한다. 틀림 → B의 흰9가 앞면으로 B의 줄 정렬 위치에 들어간다. 턴 종료.
## 6. 승패와 점수 계산
- 숨은 타일이 1개라도 남은 플레이어가 1명만 남으면 그 사람이 승자다.
- 순위: 승자 1위, 그다음은 늦게 탈락한 순이다.
- 한 턴에 여러 명이 탈락할 수 있는데, 탈락 순서는 액션이 적용된 순서이므로 동시 탈락은 없다.
- 점수는 없다. 결과 화면에 각자 "맞힌 횟수 / 추리 횟수"를 통계로 보여 준다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 조커 사용(`jokers`). 원작 상급 규칙이고, 기본은 끔이다.
- 같은 숫자 색 순서(`tieOrder`). 나무위키에 따르면 보통 검정이 왼쪽이지만 흰색 왼쪽이나 자유로 하는 경우도 있다. "자유"는 상대가 순서를 알 수 없어 추리가 달라지므로 지원하지 않는다.
- 시작 타일 수 조정(`startTiles`). 어린이용 3개, 고수용 5개.
- 더미가 빈 뒤 틀렸을 때 벌칙(`emptyPoolPenalty`). 원작 영문 규칙은 "타일 없이 계속 진행"만 적고 있다. 국내에서는 자기 타일 1개 공개가 널리 쓰여 기본값으로 한다(오너 확인 필요).
- 2인용 변형(단서 타일을 공개로 하는 등)은 지원하지 않는다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type TColor = 'black' | 'white';
interface DTile { id: string; color: TColor; n: number | 'joker' } // n: 0..11
interface RowTile { tileId: string; revealed: boolean }
interface DavinciState {
options: DavinciOptions;
rng: RngState;
seats: PlayerId[];
tiles: Record<string, DTile>; // 정의(불변)
pool: { black: string[]; white: string[] }; // 비공개, 색별로 섞인 더미
rows: Record<PlayerId, RowTile[]>; // 왼→오 순서. 숫자는 비공개
phase:
| { kind: 'chooseStart'; picks: Record<PlayerId, { black: number; white: number } | null> }
| { kind: 'draw' } // current가 색 선택
| { kind: 'guess' } // current가 추리
| { kind: 'afterCorrect' } // 계속 / 멈춤
| { kind: 'placeJoker'; reveal: boolean } // 조커 위치 선택
| { kind: 'revealOwn' } // 더미 빈 뒤 틀림 → 자기 타일 공개
| { kind: 'finished' };
current: PlayerId;
pending: string | null; // 이번 턴 타일(본인만 앎)
alive: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
stats: Record<PlayerId, { guesses: number; correct: number }>;
history: { by: PlayerId; target: PlayerId; index: number; said: number | 'joker'; correct: boolean }[]; // 공개 기록
phaseStartedAt: number;
seq: number;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `chooseStart` | `{ black: number; white: number }` | 모든 플레이어, phase=`chooseStart` | 합 = 시작 타일 수. 각 색의 남은 수 이하(먼저 제출한 사람의 선택을 빼고 계산하므로 서버 도착 순서). 한 번만 제출 |
| `draw` | `{ color?: TColor }` | `current`, phase=`draw` | `chooseColors=on`이면 color 필수이고 그 색이 남아 있어야 함. 더미가 비었으면 이 phase 자체가 생략됨 |
| `guess` | `{ target: PlayerId; index: number; value: number \| 'joker' }` | `current`, phase=`guess` | target ≠ 본인, target 생존, `rows[target][index]`가 숨김 상태, value는 0~11 정수(또는 `jokers=on`이면 'joker') |
| `continue` | `{}` | `current`, phase=`afterCorrect` | 생존한 상대가 남아 있음 |
| `stop` | `{}` | `current`, phase=`afterCorrect` | 언제나. 이번 턴 타일이 조커면 `placeJoker`로 넘어감 |
| `placeJoker` | `{ index: number }` | `current`, phase=`placeJoker` | 0 ≤ index ≤ 줄 길이 |
| `revealOwn` | `{ index: number }` | `current`, phase=`revealOwn` | 자기 줄의 숨은 타일 |
- 일반 타일을 끼울 위치는 서버가 계산한다: 숫자 오름차순, 같은 숫자면 `tieOrder`. 조커는 정렬 계산에서 건너뛰고 자기 자리를 지킨다.
- 끼울 위치 = 새 타일보다 "앞서야 하는" 비조커 타일 중 가장 오른쪽 타일의 바로 다음 인덱스. 그런 타일이 없으면 인덱스 0.
- 비조커 타일은 항상 정렬되어 있으므로, 이 위치는 "뒤에 와야 하는" 타일들보다 반드시 앞이다. 조커가 그 사이에 있으면 새 타일은 조커의 왼쪽에 들어간다.
- 실물에서는 조커 주변 배치를 본인이 고를 수 있지만, 엔진은 위 규칙으로 고정해 결정적으로 만든다.
- `reason` 예: "이미 공개된 타일이에요", "자기 타일은 고를 수 없어요", "흰색 타일이 더 이상 없어요", "0부터 11 사이 숫자를 골라 주세요".
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함)
- 모든 줄의 타일 색과 위치, 공개 여부, 공개된 타일의 숫자.
- 더미 색별 남은 개수, 추리 기록(`history`), 생존 여부, 현재 phase, 마감 시각.
- 이번 턴 타일의 색(실물에서도 보임). 숫자는 보이지 않는다.
- 본인만: 자기 줄 숨은 타일의 숫자, 자기 이번 턴 타일의 숫자.
- 시작 선택 단계: 다른 사람의 색 선택은 제출 여부만 보인다. 결과 색 구성은 분배 후 줄에 보이므로 그때 공개된다.
- 절대 보내지 않는 것: 남의 숨은 숫자, 더미 순서, RNG 상태.
- 숨은 타일은 view에서 `{ color, revealed:false }`로만 내보낸다. 타일 ID도 보내지 않는다(ID에 숫자가 들어가면 유출). 위치 인덱스로만 가리킨다.
- `deductionHelper = on`이면 후보 계산은 클라이언트에서 view 정보(공개 숫자 + 자기 숫자 + 위치·색)만으로 한다. 서버 비공개 정보는 쓰지 않는다.
- 후보 계산: 각 숨은 타일에 대해 "같은 색의 미공개 숫자 ∖ 내 손의 같은 색 숫자" 중 좌우 공개 타일 사이 범위를 만족하는 값. 조커가 있을 수 있으면 범위 제한을 완화한다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `setup`: 선 플레이어, 색별 더미 섞기. 조커는 시작 분배가 끝난 뒤 해당 색 더미의 무작위 위치에 끼운다.
- `chooseColors = off`일 때 무작위 분배.
- 그 외에는 RNG를 쓰지 않는다(가져오기는 색별 더미의 맨 앞).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 마감: `chooseStart` 15초, `draw` 10초, `guess` `turnSeconds`, `afterCorrect` 10초, `placeJoker`/`revealOwn` 10초.
- `onTimeout`
- `chooseStart`: 가능한 한 반반(홀수면 검정 하나 더).
- `draw`: 더 많이 남은 색(같으면 검정).
- `guess`: 숨은 타일이 가장 적은 생존 상대의 가장 왼쪽 숨은 타일을 고르고, 그 타일의 가능한 후보(8.3 후보 계산을 서버에서 공개 정보와 본인 정보만으로 수행) 중 가장 작은 값을 말한다. 결정적이라 재현할 수 있다.
- `afterCorrect`: `stop`.
- `placeJoker`: 맨 오른쪽.
- `revealOwn`: 가장 왼쪽 숨은 타일.
- 연결이 끊긴 플레이어도 탈락시키지 않고 위 자동 행동으로 진행한다. 연속 3턴 타임아웃이면 서버가 마감을 5초로 줄인다.
### 8.6 종료 조건과 결과(GameResult)
- 생존자가 1명이면 `finished`.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; hiddenLeft: number; guesses: number; correct: number }[];
winner: PlayerId[];
finalRows: Record<PlayerId, { color: TColor; n: number | 'joker' }[]>; // 종료 후 전원 공개
summary: string; // "철수 님의 암호가 끝까지 풀리지 않았어요!"
}
```
- 게임이 끝난 뒤에는 모든 타일을 공개해도 된다(`finalRows`).
## 9. UI/UX
- 모바일 세로 화면
- 위: 상대 줄(2~3명). 타일은 검정·흰색 직사각형이고, 공개된 타일에는 숫자를 크게 쓴다. 각 타일 아래에 위치 번호(1, 2, 3…)를 작게 쓴다.
- 가운데: 더미(검정 n개, 흰 n개 두 무더기). 이번 턴 타일 미리보기.
- 아래: 내 줄. 내 숨은 타일 숫자는 나만 보이고, 테두리 점선으로 "상대에게 숨김"을 표시한다.
- 조작
1. 내 차례에 검정/흰 무더기 중 하나를 탭해서 가져온다.
2. 상대 타일을 탭하면 그 타일 위에 0~11 숫자 패드(3×4, 버튼 56px)와 "조커" 버튼이 뜬다.
3. 숫자를 누르고 "이 숫자!" 확인 버튼을 누른다.
4. 결과는 뒤집기 애니메이션으로 보여 준다(맞힘: 초록 반짝임 / 틀림: 내 타일이 뒤집혀 공개되는 애니메이션).
5. 맞혔으면 "한 번 더 맞히기"와 "여기서 멈추기" 큰 버튼 2개를 보여 준다. 멈출 때 위험도 안내("멈추면 가져온 타일이 숨겨진 채로 들어가요")를 띄운다.
- 합법 수 하이라이트: 고를 수 있는 상대 타일(숨김 상태)만 테두리를 강조하고, 공개된 타일은 비활성이다.
- 초보 도움말: "규칙 보기"에 정렬 규칙 그림(작은 수 왼쪽, 같은 숫자면 검정 왼쪽)을 넣는다. `deductionHelper`를 켜면 숫자 패드에서 불가능한 숫자를 흐리게 표시한다(누를 수는 있음).
- 추리 기록 패널: "민수 → 영희 3번째 타일 = 7 (틀림)" 목록을 둔다. 노년층의 기억 부담을 줄인다.
## 10. 테스트 체크리스트
- [ ] 셋업: 2·3인은 각 4개, 4인은 각 3개. 시작 타일에 조커가 없다(`jokers=on`이어도). 같은 시드면 같은 분배가 나온다.
- [ ] `chooseStart`: 3인이 모두 "흰4"를 고르면 12개로 충분해 성공한다. 4인이 모두 "검3"을 고르는데 검정 12개면 성공하고, 이후 사람은 검정 0개만 남는다. 남은 수를 넘는 선택은 거부된다.
- [ ] 정렬: [흰5, 검5, 검2]를 받으면 [검2, 검5, 흰5]가 된다(`black-left`). `white-left`이면 [검2, 흰5, 검5].
- [ ] 조커: 조커를 가져와 틀렸을 때 `placeJoker` 단계에서 고른 위치에 앞면으로 들어간다. 이후 새 숫자 타일 정렬은 조커 위치를 바꾸지 않는다.
- [ ] 맞힘 → 계속 → 틀림: 처음 맞힌 타일은 공개된 채로 남고, 이번 턴 타일은 앞면으로 내 줄에 들어간다.
- [ ] 맞힘 → 멈춤: 이번 턴 타일이 숨긴 채로 들어간다. 다른 플레이어 view에는 그 타일의 색만 있다.
- [ ] 마지막 숨은 타일을 맞히면 그 상대가 즉시 탈락한다. 2인이면 바로 게임이 끝나고 `afterCorrect` 단계가 생기지 않는다.
- [ ] 더미가 빈 뒤: `draw` 단계가 생략된다. 틀리면 `revealOwn` 단계가 되고, 자기 마지막 숨은 타일을 공개하면 본인이 탈락한다.
- [ ] 이미 공개된 타일이나 탈락자 타일, 자기 타일을 추리하려 하면 거부된다. value 12, -1, 3.5는 거부된다. `jokers=off`에서 'joker'도 거부된다.
- [ ] 시간 초과: `guess` 시간 초과 때 자동 추리가 공개 정보만으로 결정적으로 정해진다(같은 상태 → 같은 추리). 상대 숨은 숫자를 참조하지 않는지 코드 리뷰로 확인한다.
- [ ] 연결 끊김: 끊긴 플레이어 차례가 자동으로 진행되고, 게임이 멈추지 않는다.
- [ ] 정보 유출: 관전자와 상대 view JSON에 남의 숨은 숫자, 숨은 타일 ID, 이번 턴 타일 숫자가 없다. 이벤트(`tileDrawn`)도 본인 외에는 색만 있다.
- [ ] 정보 유출: 종료 후에는 `finalRows`로 전원 공개되는 것이 정상이다.
## 11. 참고 자료
- 나무위키 "다빈치 코드(보드게임)": https://namu.wiki/w/다빈치%20코드(보드게임) (2~4명, 시작 타일 수, 정렬 규칙, 시작 패에 조커 금지, 조커 위치 자유)
- UltraBoardGames "How to play Davinci Code": https://www.ultraboardgames.com/davinci-code/game-rules.php (턴 흐름, 맞힘 후 계속/멈춤, 틀림 시 공개, 조커는 상급 규칙)
- 더미 소진 후 처리: 영문 규칙 요약에는 "타일 없이 계속"만 있고, 자기 타일 공개 벌칙은 국내 통용 규칙을 따른 것이다.
## 12. 메모 (상표·법적 주의 등)
- "다빈치 코드"라는 이름은 국내 유통사가 쓰는 상품명이다(원작은 일본 게임 "Algo"/"Coda" 계열). 소설·영화 제목과도 겹친다. 상표 문제를 피하려면 표시 이름을 바꾸는 것을 권한다. 후보: "숫자 암호", "흑백 암호 추리", "비밀 숫자 맞히기"(사용자 선택). 코드 ID `davinci-code`는 내부용이다.
- 타일 디자인은 단순한 검정·흰색 직사각형으로 직접 그린다.
- 돈, 칩을 쓰지 않는다.

206
docs/games/dobble.md Normal file
View File

@@ -0,0 +1,206 @@
# 도블 (`dobble`)
> 마일스톤: M8 · 인원: 최소 2 ~ 최대 8명 · 예상 시간: 약 3~10분 · 난이도: 쉬움
## 1. 개요
- 그림 8개가 그려진 원형 카드 두 장에는 **항상 정확히 하나**의 같은 그림이 있다. 그 그림을 누구보다 빨리 찾아 누르는 실시간 순발력 게임. 여러 개의 짧은 미니게임으로 즐긴다.
- 한국에서는 "도블"(영문판 Spot it!)이라는 이름으로 가족·어린이 보드게임으로 매우 유명하다. 글을 몰라도 할 수 있어 어린이·어르신에게 적합.
- 인원 근거: 원작(Play Factory / Asmodee, 2009) 박스 표기 2~8인.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `miniGame` | 미니게임 | `tower`(탑 쌓기) / `well`(우물) / `hotPotato`(뜨거운 감자) / `poisonedGift`(독이 든 선물) | `tower` |
| `deckSize` | 덱 장수 | 57(수학적 완전 덱) / 55(원작과 같은 장수) | 57 |
| `hotPotatoRounds` | 뜨거운 감자 라운드 수 | 3 / 5 / 7 | 5 |
| `wrongLockoutMs` | 틀린 그림을 눌렀을 때 잠금 시간 | 0 / 1000 / 2000 | 1000 |
| `latencyComp` | 지연 보정 | `off` / `light` | `off` |
| `symbolStyle` | 그림 크기·회전 | `easy`(크기 균일, 회전 없음) / `classic`(크기·회전 무작위) | `classic` |
| `showRtt` | 각자 핑(ms) 표시 | on / off | on |
| `wellEndAtFirst` | 우물: 첫 완주자가 나오면 종료 | on / off | off |
## 3. 구성물
### 3.1 덱 생성: 7차 사영평면
- 그림(기호) 57종, 카드 57장, 카드당 그림 8개. 어떤 두 카드도 정확히 1개의 그림을 공유하고, 각 그림은 정확히 8장에 나온다.
- n = 7(소수)일 때 다음 알고리즘으로 생성(서버 코드에서 실제 실행해 57장·57종·모든 쌍 공유 1개·그림당 8회 등장을 확인함):
```ts
function buildDeck(n = 7): number[][] {
const cards: number[][] = [];
// 1) 무한원 직선: 그림 0..n
cards.push(Array.from({ length: n + 1 }, (_, i) => i));
// 2) 그림 0을 지나는 n장
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);
}
// 3) 나머지 n*n장: 기울기 i, 절편 j
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; // 1 + n + n*n = 57장, 그림 번호 0..56
}
```
- `deckSize=55`이면 생성 후 RNG로 2장을 제외한다(원작도 55장). 두 카드 공유 1개 성질은 유지된다.
- 이 알고리즘은 n이 소수일 때만 성립(n=7 고정).
- 그림 번호는 고정이 아니라, 게임마다 RNG로 57개 그림 이미지에 섞어 배정한다(같은 카드 조합이 매번 같은 모양으로 반복되지 않게).
### 3.2 그림 57종 (자체 제작 아이콘, 한글 이름)
사과, 해, 달, 별, 하트, 물방울, 불꽃, 눈송이, 번개, 구름, 나무, 꽃, 나뭇잎, 선인장, 버섯, 당근, 레몬, 체리, 고양이, 강아지, 물고기, 새, 거북이, 무당벌레, 거미, 공룡, 토끼, 펭귄, 열쇠, 자물쇠, 시계, 전구, 연필, 가위, 안경, 우산, 모자, 왕관, 종, 닻, 자전거, 자동차, 로켓, 배, 공, 주사위, 음표, 카메라, 집, 성, 선물상자, 촛불, 망치, 느낌표, 물음표, 눈사람, 무지개 (57개).
- 색약 배려: 모양만으로 구분 가능해야 하며 색은 보조. 비슷한 실루엣(해/별, 달/바나나 등) 피하기.
### 3.3 카드 배치(레이아웃)
- 카드는 원형. 8개 그림의 위치는 미리 만든 원 패킹 템플릿 6종 중 하나를 RNG로 고르고, `classic`이면 그림마다 크기(0.6~1.2배)와 회전(0~359도)을 RNG로 정한다. `easy`는 크기 1.0, 회전 0.
- 레이아웃은 셋업 때 카드별로 확정해 상태에 저장(모든 클라이언트가 같은 모양을 봄).
## 4. 준비(셋업)
공통: 덱 생성 → RNG 셔플 → 레이아웃 확정. 좌석은 RNG.
- **탑 쌓기(tower)**: 각자 1장(자기 탑의 첫 장, 공개). 나머지는 가운데 뽑기 더미, 맨 위 1장 공개.
- **우물(well)**: 가운데에 1장 공개. 남은 카드를 같은 장수로 나눔 `floor((덱-1)/N)`장씩, 나머지는 사용하지 않음(비공개 보관). 각자 자기 더미의 맨 위가 공개.
- **뜨거운 감자(hotPotato)**: 라운드마다 각자 1장. 이미 쓴 카드는 다시 쓰지 않음(8인 × 7라운드 = 56장 ≤ 57).
- **독이 든 선물(poisonedGift)**: 각자 1장(공개), 나머지는 가운데 뽑기 더미, 맨 위 공개.
- 모든 미니게임은 3초 카운트다운 후 동시에 카드가 공개되며 시작.
## 5. 진행 규칙
### 5.1 공통: "찾기(claim)"
- 플레이어는 두 카드(아래 미니게임별 조합)의 공통 그림을 눌러 "찾기"를 보낸다. 서버가 도착 순서로 판정.
- 찾기에는 기준 카드 버전(가운데 카드 id 또는 대상 플레이어의 맨 위 카드 id)이 들어간다. 그 사이 카드가 바뀌었으면 "늦었어요"로 거부하고 벌칙 없음.
- 공통 그림이 아니면 "틀렸어요" + `wrongLockoutMs` 동안 그 플레이어의 찾기 잠금(서버가 판정).
- 원작의 "동시에 외치면 카드를 먼저 놓은 사람"은 온라인에서 서버 도착 순서로 대체한다.
### 5.2 미니게임별 규칙
| 미니게임 | 비교하는 두 카드 | 성공 시 | 종료 | 승리 |
|---|---|---|---|---|
| 탑 쌓기 | 내 탑 맨 위 ↔ 가운데 맨 위 | 가운데 카드를 내 탑 위로 가져옴(내 새 맨 위) | 뽑기 더미 소진 | 카드 가장 많은 사람 |
| 우물 | 내 더미 맨 위 ↔ 가운데 맨 위 | 내 카드를 가운데에 올림(새 가운데) | 1명 빼고 모두 비움(`wellEndAtFirst`면 첫 완주자) | 먼저 비운 순서대로 순위, 마지막 사람 꼴찌 |
| 뜨거운 감자 | 내 카드(맨 위) ↔ 다른 사람 카드 맨 위 | 내 카드 묶음 전체를 그 사람 위에 올림. 나는 이번 라운드 탈출 | 1명이 모든 카드를 가짐 → 그 사람이 그 카드를 벌점으로 보관 | 모든 라운드 후 벌점 카드 가장 적은 사람 |
| 독이 든 선물 | 다른 사람 카드 맨 위 ↔ 가운데 맨 위 | 가운데 카드를 그 사람 위에 올림 | 뽑기 더미 소진 | 카드 가장 적은 사람 |
- 뜨거운 감자의 "묶음 전체를 올림": 원작은 받은 사람이 새 맨 위 카드로 계속 경기한다. 탈출한 사람(카드 0장)은 그 라운드 동안 대상이 될 수 없다.
- 독이 든 선물에서 자기 카드에는 올릴 수 없다(대상은 다른 사람만).
- 탑 쌓기 예(3인): 가운데 카드 {사과, 해, 고양이, ...}, 내 맨 위 {고양이, 열쇠, ...} → "고양이" 누름 → 서버 도착 1등이면 가운데 카드가 내 탑으로, 다음 가운데 카드 공개. 다른 사람이 같은 가운데 카드 기준으로 0.05초 늦게 보낸 찾기는 "늦었어요".
### 5.3 실시간 판정과 공정성
- 서버(방 단위 단일 스레드 큐)가 메시지 수신 순서대로 `serverTs`(서버 수신 시각, ms)를 찍어 액션에 붙이고 그 순서대로 validate/apply한다. 같은 ms라도 큐 순서가 곧 판정 순서이며 로그에 남아 재생 가능.
- 클라이언트가 보내는 시각은 신뢰하지 않는다.
- **RTT 측정**: 2초마다 ping/pong, 지수이동평균(α=0.2)으로 플레이어별 RTT 저장. `showRtt`면 이름 옆에 표시(초록 < 80ms, 노랑 < 200ms, 빨강 ≥ 200ms).
- **지연 보정 `light`**(선택):
- 첫 유효 찾기가 도착하면 즉시 확정하지 않고 60ms 창을 연다.
- 창 안에 도착한 같은 기준 카드의 유효 찾기들의 보정 시각 = `serverTs - min(RTT/2, 80)`. 가장 작은 사람이 승리.
- 확정은 창이 닫힐 때 서버 내부 `resolve` 액션으로(재생 가능). 화면 반응이 60ms 늦어지는 대신 지연이 큰 사람의 불리함을 최대 80ms까지 줄인다. RTT를 일부러 부풀리는 악용을 막기 위해 보정 상한 80ms 고정.
- **잠금**: 틀린 찾기 후 `wrongLockoutMs` 동안 거부(무작위 연타 방지). 잠금 중 거부 사유 "잠깐! 1초 뒤에 다시 눌러요".
- **봇 의심**: 공개된 카드에서 공통 그림은 프로그램으로 즉시 계산 가능하므로 막을 수 없다. 카드 공개 후 200ms 미만 정답이 연속 5회면 방장에게 "의심" 표시만 한다(자동 처벌 없음).
## 6. 승패와 점수 계산
- 미니게임별 순위는 5.2 표. 동점은 공동 순위.
- 우물: 비운 순서가 순위. 우물에서 남은 2명 중 1명이 비우면 나머지가 꼴찌.
- 뜨거운 감자: 라운드마다 패자가 받은 카드 수를 벌점 누적. 최종 벌점 적은 순.
- 방 기록용 점수(여러 판 연속 플레이 시 누적, 원작 토너먼트 방식 단순화): 1등 3점, 2등 2점, 3등 1점, 그 외 0점. 공동 순위는 같은 점수.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `wrongLockoutMs=0` | 틀려도 벌칙 없음(원작은 벌칙 규정 없음) | 1000 |
| `wrongGivesCard` | 탑 쌓기에서 틀리면 내 탑 맨 위 1장을 가운데 더미 맨 아래로 | off |
| `symbolStyle=easy` | 어린이 모드: 크기 균일·회전 없음 | `classic` |
| `triplet` | 트리플렛(9장 중 같은 그림을 가진 3장 찾기) 미니게임 | 향후 추가 |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
interface CardLayout { template: number; items: { symbol: number; x: number; y: number; scale: number; rot: number }[] }
interface DobbleState {
options: DobbleOptions;
rng: RngState;
cards: number[][]; // 카드 id → 그림 번호 8개(셋업에서 생성)
layouts: CardLayout[]; // 카드 id → 레이아웃
symbolArt: number[]; // 그림 번호 → 아이콘 id(셔플)
order: PlayerId[];
piles: Record<PlayerId, number[]>; // 플레이어 더미, 맨 위 = 끝. well은 뒷면 더미 포함
center: number[]; // 가운데 공개 더미(맨 위 = 끝)
drawPile: number[]; // 비공개 뽑기 더미
unused: number[]; // 우물 나머지·55장 제외분
lockedUntil: Record<PlayerId, number>;
pendingWindow: null | { base: number; openedAt: number; claims: { player: PlayerId; target: PlayerId | 'center'; symbol: number; adjTs: number }[] };
round: number; // 뜨거운 감자
penalty: Record<PlayerId, number>; // 뜨거운 감자 벌점
outThisRound: PlayerId[]; // 뜨거운 감자 탈출자
finishedOrder: PlayerId[]; // 우물 완주 순서
phase: 'countdown' | 'playing' | 'roundEnd' | 'ended';
phaseEndsAt: number;
stats: Record<PlayerId, { correct: number; wrong: number; fastHits: number }>;
}
```
### 8.2 액션
액션에는 서버가 `serverTs`를 붙인다(클라이언트 값 무시).
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `claim` | `{ base: number; target?: PlayerId; symbol: number }` | 참가자, playing | 잠금 해제됨(`serverTs >= lockedUntil`), `base`가 현재 기준 카드 id와 일치(아니면 "늦었어요"), 미니게임별 대상 규칙(우물·탑은 target 없음, 감자·선물은 다른 생존 대상), 그림이 두 카드에 모두 있음(아니면 잠금 부여) |
| `resolve` | `{}` | 서버 내부, `latencyComp=light` 창 마감 | 창이 열려 있음 |
| `tick` | `{}` | 서버 타이머 | 카운트다운·라운드 전환 |
"틀렸어요"는 `validate` 실패가 아니라 `apply`가 잠금만 기록하는 정상 액션으로 처리한다(잠금 상태를 남겨야 하므로). 늦은 찾기는 `validate` 실패.
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함): 모든 공개 카드(가운데 맨 위, 각자 더미 맨 위, 뜨거운 감자의 각자 맨 위)의 그림과 레이아웃, 더미 장수, 순위, 잠금 상태, RTT.
- 비공개: 뽑기 더미 순서, 우물의 각자 뒷면 카드, 사용하지 않는 카드, 다음에 나올 카드, RNG 상태.
- 레이아웃과 그림 번호는 공개 카드에 대해서만 보낸다(나중에 나올 카드의 레이아웃 선전송 금지).
- 정답(공통 그림)은 보내지 않는다(클라이언트가 계산 가능하다는 점은 5.3 참고).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
그림-아이콘 배정, 덱 셔플, 55장 제외 카드, 레이아웃 템플릿·크기·회전, 좌석.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 차례가 없는 동시 게임이므로 `activePlayers` = 남아 있는 참가자 전원, 턴 타이머 없음. `deadline` = 카운트다운/라운드 전환/`pendingWindow` 마감 시각.
- `onTimeout`: 개인 시간 초과 개념이 없으므로 아무 행동도 반환하지 않는 `noop`.
- 정체 방지: 45초 동안 아무도 맞히지 못하면(거의 없음) 서버가 공통 그림을 2초간 깜빡여 보여 주고 계속(점수 없음).
- 연결 끊김: 탑 쌓기·독이 든 선물은 그대로 진행(끊긴 사람은 못 맞힐 뿐). 우물·뜨거운 감자는 30초 이상 끊기면 "기권" 처리 — 우물은 꼴찌 그룹, 뜨거운 감자는 대상에서 제외되고 현재 카드는 가운데로 반환.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface DobbleResult {
miniGame: 'tower' | 'well' | 'hotPotato' | 'poisonedGift';
ranking: { rank: number; id: PlayerId; cards?: number; penalty?: number; points: number }[];
stats: Record<PlayerId, { correct: number; wrong: number }>;
summary: string; // 예: "탑 쌓기 1등 하린님 21장!"
}
```
## 9. UI/UX
- 모바일 세로: 위쪽에 가운데 카드(화면 폭 80% 원), 아래쪽에 내 카드(같은 크기). 다른 사람은 상단 작은 아바타 + 장수(뜨거운 감자·독이 든 선물에서는 대상 카드를 탭해 크게 보기).
- 조작: 내 카드 또는 가운데 카드의 그림을 탭하면 찾기. 그림 터치 영역은 최소 48px(카드 지름 300px 기준으로 템플릿 설계). 뜨거운 감자/독이 든 선물은 먼저 대상 카드를 선택하고 그림 탭(두 번 탭), 대상이 2명이면 자동 선택.
- 피드백: 정답 시 그림 확대+초록 테두리, 늦음 시 회색 "늦었어요", 틀림 시 화면 흔들림 + 잠금 원형 타이머.
- 접근성: 그림 이름 음성 읽기(선택), `easy` 모드, 큰 카드 모드. 색만으로 정보 전달 금지.
- 연습 모드: 혼자서 공통 그림 찾기(엔진 재사용, 기록 없음).
- PC: 카드 좌우 배치, 마우스 클릭.
## 10. 테스트 체크리스트
- [ ] 덱 생성: 57장, 그림 57종, 카드당 서로 다른 그림 8개, 모든 카드 쌍(1596쌍) 공유 그림 정확히 1개, 각 그림 8회 등장. 55장 모드에서도 쌍 성질 유지.
- [ ] 탑 쌓기: 두 사람이 같은 가운데 카드에 정답 → 먼저 도착한 사람만 획득, 나중 사람은 "늦었어요"(잠금 없음).
- [ ] 틀린 그림 → `wrongLockoutMs` 동안 정답도 거부, 잠금 해제 후 허용. `serverTs` 기준.
- [ ] 우물: 장수 균등(`floor((덱-1)/N)`), 완주 순서 순위, 마지막 1명 꼴찌로 종료. `wellEndAtFirst` on이면 첫 완주 즉시 종료.
- [ ] 뜨거운 감자: 탈출자는 대상 불가, 한 명이 모두 가지면 라운드 종료·벌점 누적, 설정 라운드 후 벌점 최소 승리.
- [ ] 독이 든 선물: 자기 자신 대상 거부, 더미 소진 시 카드 최소 승리, 동점 공동 순위.
- [ ] 지연 보정 light: 상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms 상한)일 때 내가 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리, 100ms 늦게 도착하면 60ms 창 밖이라 패배.
- [ ] 연결 끊김: 우물에서 30초 끊김 → 기권 처리 후 남은 사람으로 계속.
- [ ] 비공개 누출 검사: 모든 view/이벤트에 뽑기 더미·우물 뒷면·미사용 카드의 그림·레이아웃·id가 없는지 자동 검사.
- [ ] 같은 시드 + 액션 로그(serverTs 포함) 재생 시 동일 결과.
- [ ] 봇 의심 표시: 200ms 미만 정답 5연속 시 방장 view에만 표시.
## 11. 참고 자료
- Wikipedia "Dobble" (55장, 카드당 8개, 7차 사영평면) — https://en.wikipedia.org/wiki/Dobble
- UltraBoardGames "Spot it! 규칙"(동시 외침 처리, 미니게임 점수) — https://www.ultraboardgames.com/spot-it/game-rules.php
- BoardGameGeek "Spot it!" — https://boardgamegeek.com/boardgame/63268/spot-it
- Wikipedia "Projective plane"(유한 사영평면 구성) — https://en.wikipedia.org/wiki/Projective_plane
- 나무위키 "도블" — https://namu.wiki/w/도블
## 12. 메모 (상표·법적 주의 등)
- "Dobble"과 "Spot it!"은 Asmodee 그룹의 상표이고 원작 그림 일러스트는 저작물이다. 사영평면 구조(수학)는 자유롭게 쓸 수 있지만 이름·그림은 자체 제작해야 한다. 표시 이름 후보: "같은 그림 찾기", "짝꿍 그림", "번쩍 찾기".
- 도박 요소 없음.

424
docs/games/gostop.md Normal file
View File

@@ -0,0 +1,424 @@
# 고스톱 / 맞고 (`gostop`)
> 마일스톤: M5 · 인원: 최소 2 ~ 최대 5명 (2인 맞고, 3인 고스톱, 4~5인은 3명만 치고 나머지는 광팔기) · 예상 시간: 1판 약 3분, 기본 10판 약 30분 · 난이도: 보통
## 1. 개요
- 화투 48장으로 바닥 패와 같은 월(月)을 맞춰 가져오고, 가져온 패로 점수 족보(광·열끗·띠·피)를 만들어 먼저 기준 점수에 이르면 "고"(계속) 또는 "스톱"(끝내기)을 고르는 한국의 대표 화투 게임이다. 명절·가족 모임에서 남녀노소 모두 알고, 2인용인 맞고는 온라인에서 가장 많이 하는 형태다.
- 인원 근거: 고스톱은 원래 3인 게임이다. 2인은 맞고로 정착한 변형이다. 4~5인은 전통적으로 3명만 치고 나머지가 "광을 파는" 방식이다. 그래서 최소 2명, 최대 5명이다.
- 2명 → 맞고 모드, 3명 → 고스톱 모드, 4~5명 → 고스톱 + 광팔기.
- **점수는 방 안에서만 쓰는 가상 점수**다. 판이 끝나면 방 점수판에 더하고 빼기만 하며, 돈·재화·환전·저장 포인트와 전혀 연결하지 않는다(12장 참조).
- 지역마다 규칙이 크게 다르다. 이 문서는 나무위키·주요 온라인 맞고에서 흔히 쓰는 규칙을 기본값으로 정하고, 나머지는 옵션으로 둔다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `rounds` | 세트 판 수(나가리 판도 1판으로 셈) | 1 / 3 / 5 / 10 / 20 | 10 |
| `bonusCards` | 보너스패 구성 | `none` / `2+1`(2피 2장 + 3피 1장) / `3+1`(2피 3장 + 3피 1장) | 맞고 `2+1` / 고스톱 `none` |
| `winScore` | 고·스톱 기준 점수 | 정수 | 맞고 7 / 고스톱 3 |
| `piBakLimit` | 피박이 되는 패자의 피 장수(피 값) 상한 | 정수 | 맞고 7 / 고스톱 5 |
| `piBakZeroExempt` | 패자 피 0장이면 피박 면제 | true / false | true |
| `gwangBak` | 광박 | true / false | true |
| `meongTta` | 멍따: 승자 열끗 7장 이상이면 x2 | true / false | true |
| `meongBak` | 멍박: 멍따 승리 시 패자 열끗 0장이면 x2 | true / false | false |
| `goBak` | 고박(맞고 x2 / 3인 독박) | true / false | true |
| `biGwangThree` | 비광 포함 3광 점수 | `2`(2점) / `0`(불인정) | `2` |
| `ogwangPoints` | 5광 점수 | 15 / 30 | 15 |
| `gukjinMode` | 9월 열끗(국진) 처리 | `auto`(점수에 유리하게 자동) / `ask`(판 끝에 본인 선택) / `yeolOnly`(열끗 고정) | `auto` |
| `shake` | 흔들기(x2) | true / false | true |
| `bomb` | 폭탄(x2, 피 1장씩) | true / false | true |
| `chongtong` | 총통 처리 | `win`(즉시 승리) / `redeal`(다시 돌림) / `off`(그냥 진행) | `win` |
| `chongtongPoints` | 총통 승리 점수 | 정수 | 맞고 10 / 고스톱 5 |
| `ppeokBonus` | 첫뻑·연뻑 즉시 보너스, 삼뻑 즉시 승리 | true / false | true |
| `jaPpeokSteal` | 자뻑 때 상대마다 뺏는 피 장수 | 1 / 2 | 2 |
| `lastTurnSweepBonus` | 마지막 턴의 쓸·쪽 피 뺏기 인정 | true / false | false |
| `naturalThreeSteal` | 처음 바닥에 깔린 같은 월 3장을 먹을 때 피 뺏기 | true / false | true |
| `nagariMultiplier` | 나가리 다음 판 배수(연속이면 누적) | `x2` / `none` | `x2` |
| `nagariMaxMultiplier` | 연속 나가리 배수 상한 | 2 / 4 / 8 | 8 |
| `gwangPalgi` | 4~5인 광팔기 허용 | true / false | true |
| `gwangPrice` | 광팔기 1장당 값 | 1 / 2 / 3 | 1 |
| `gwangPayer` | 광값 내는 사람 | `nonDealerPlayers`(선 제외 참가자 각자) / `allPlayers`(참가자 3명 각자) | `nonDealerPlayers` |
| `gwangSellCards` | 팔 수 있는 패 | `gwang`(광만) / `gwangPlus`(광 + 쌍피·국진·보너스) | `gwang` |
| `turnSeconds` | 1턴 제한 시간 | 5~60 | 15 |
| `decisionSeconds` | 고/스톱·선택 제한 시간 | 5~30 | 10 |
| `hints` | 먹을 수 있는 패·족보 진행 힌트 | true / false | true |
모드(맞고/고스톱)는 `setup`의 인원 수로 정한다. 2명이면 맞고 기본값, 3~5명이면 고스톱 기본값을 쓴다.
## 3. 구성물
### 3.1 화투 48장 (12개월 x 4장)
| 월 | 이름 | 카드 ID | 구성 |
|---|---|---|---|
| 1 | 송학(소나무) | 0 / 1 / 2 / 3 | 광(학) / 홍단 / 피 / 피 |
| 2 | 매조(매화) | 4 / 5 / 6 / 7 | 열끗(꾀꼬리, 고도리) / 홍단 / 피 / 피 |
| 3 | 벚꽃 | 8 / 9 / 10 / 11 | 광(벚꽃) / 홍단 / 피 / 피 |
| 4 | 흑싸리 | 12 / 13 / 14 / 15 | 열끗(두견새, 고도리) / 초단 / 피 / 피 |
| 5 | 난초 | 16 / 17 / 18 / 19 | 열끗(다리) / 초단 / 피 / 피 |
| 6 | 모란 | 20 / 21 / 22 / 23 | 열끗(나비) / 청단 / 피 / 피 |
| 7 | 홍싸리 | 24 / 25 / 26 / 27 | 열끗(멧돼지) / 초단 / 피 / 피 |
| 8 | 공산(억새) | 28 / 29 / 30 / 31 | 광(보름달) / 열끗(기러기, 고도리) / 피 / 피 |
| 9 | 국진(국화) | 32 / 33 / 34 / 35 | 열끗(술잔, 국진: 쌍피로도 쓸 수 있음) / 청단 / 피 / 피 |
| 10 | 단풍 | 36 / 37 / 38 / 39 | 열끗(사슴) / 청단 / 피 / 피 |
| 11 | 오동(똥) | 40 / 41 / 42 / 43 | 광(봉황) / 쌍피 / 피 / 피 |
| 12 | 비 | 44 / 45 / 46 / 47 | 광(비광) / 열끗(제비) / 띠(비띠, 단 아님) / 쌍피 |
- 합계: 광 5장, 열끗 9장, 띠 10장, 피 24장(그중 쌍피 2장: 41, 47).
- 피 값: 일반 피 1, 쌍피 2. 국진을 쌍피로 쓰면 2.
- 보너스패(옵션): ID 48·49(·50) = 2피(피 값 2), 마지막 ID = 3피(피 값 3). 월이 없다.
### 3.2 용어
- 바닥: 가운데 앞면으로 깔린 패. 더미: 뒤집어 쌓은 남은 패. 먹은 패: 각자 앞에 종류별로 모아 둔 패(모두 공개).
- 선: 그 판에 먼저 시작하는 사람. 진행 방향은 반시계(선 → 선의 오른쪽 사람 → …).
## 4. 준비(셋업)
1. 첫 판의 선: RNG로 정한다(연출로 "패 뽑기"를 보여 줄 수 있음). 다음 판부터는 직전 판 승자가 선이다. 나가리면 선을 유지한다.
2. 패 섞기: 48장(+보너스)을 RNG로 섞는다.
3. 패 돌리기(서버는 배열에서 순서대로 나눠 주고, 화면에서만 전통 방식으로 연출):
- 맞고(2인): 손패 각 10장, 바닥 8장, 더미 나머지(보너스 없으면 20장). 연출: 바닥 4 → 상대 5 → 선 5 → 바닥 4 → 상대 5 → 선 5.
- 고스톱(3인): 손패 각 7장, 바닥 6장, 더미 21장. 연출: 바닥 3 → 각 4 → 바닥 3 → 각 3.
- 4~5인(광팔기): 모두에게 7장씩, 바닥 6장. 그 뒤 광팔기 단계(5.8)를 거쳐 판 사람들의 손패를 더미와 함께 RNG로 다시 섞는다. 더미는 항상 21장(+보너스)이 된다.
4. 바닥 정리:
- 바닥에 보너스패가 있으면 선이 먹은 패로 가져가고, 더미 맨 위에서 1장을 바닥에 채운다(또 보너스면 반복).
- 바닥에 같은 월 4장이 깔리면 **다시 섞어서 돌린다**(선 유지, 나가리 아님, 판 수 증가 없음).
- 바닥에 같은 월 3장이면 한 무더기로 쌓아 둔다(자연 뻑). 나머지 1장을 가진 사람이 4장을 모두 가져간다.
5. 총통 확인(5.7).
6. 선부터 첫 턴을 시작한다.
## 5. 진행 규칙
### 5.1 한 턴의 흐름
1. **손패 1장 내기**(또는 폭탄·폭탄 빈패·보너스패, 5.4~5.6)
2. **더미 맨 위 1장 뒤집기**
3. **먹기 판정**(5.2) → 피 뺏기 처리(5.3)
4. **점수 확인**: 지금 점수가 `winScore` 이상이고, 이번 판에 고를 한 적이 없거나 마지막 고를 부를 때보다 점수가 높으면 **고/스톱 선택**(5.9)을 한다.
5. 다음 사람 턴. 모두의 손패가 0장이고 더미가 비면 판이 끝난다(5.10).
### 5.2 먹기 판정 (정확한 알고리즘)
손에서 낸 패를 H(월 m1), 뒤집은 패를 F(월 m2)라 하고, H를 내기 직전 바닥에 있던 m1월 패 수를 k라 한다.
**H 처리**
| k | 결과 |
|---|---|
| 0 | H를 바닥에 놓는다. |
| 1 | H와 그 패를 짝으로 잡아 둔다(임시). |
| 2 | 둘 중 하나를 골라 짝으로 잡는다. 두 장의 종류·피 값이 같으면 자동 선택. 고르는 것은 `play` 액션의 `target`으로 함께 보낸다. |
| 3 | 쌓인 3장(뻑 또는 자연 뻑)과 H, 4장을 모두 가져온다. 뻑 먹기 피 뺏기(5.3). |
**F 처리** (F가 보너스패면 내 먹은 패에 넣고 1장 더 뒤집어 그 패를 F로 다시 처리)
- m2 = m1인 경우(같은 월이 연달아 나옴):
- k = 0 → **쪽**: H와 F를 가져온다. 상대마다 피 1장.
- k = 1 → **뻑**: H, 바닥 1장, F 3장이 바닥에 한 무더기로 남는다. 아무것도 못 먹는다. 무더기에 "뻑 낸 사람 = 나"를 기록한다.
- k = 2 → **따닥**: 바닥 2장 + H + F, 4장을 모두 가져온다. 상대마다 피 1장.
- m2 ≠ m1인 경우: H 처리 결과를 확정하고(k=1·2면 짝을 가져옴), F를 바닥의 m2월 패 수 j로 처리한다.
- j = 0 → F를 바닥에 놓는다.
- j = 1 → F와 그 패를 가져온다.
- j = 2 → 둘 중 하나를 고른다(종류가 같으면 자동). 이때만 별도 선택 단계(`chooseFlip`)가 생긴다.
- j = 3 → 4장을 모두 가져온다. 뻑 먹기 피 뺏기.
- **쓸(싹쓸이)**: 이번 턴 처리가 끝난 뒤 바닥이 비면 상대마다 피 1장.
- `lastTurnSweepBonus=false`면 그 판의 마지막 턴(더미 마지막 장을 뒤집는 턴)에서 쓸·쪽의 피 뺏기를 하지 않는다. 따닥·뻑 먹기는 그대로 인정한다.
### 5.3 피 뺏기
- 쪽, 따닥, 쓸, 남의 뻑 먹기, 자연 뻑 먹기(`naturalThreeSteal`), 폭탄: 상대마다 피 1장.
- 자뻑(내가 만든 뻑 무더기를 내가 먹음): 상대마다 `jaPpeokSteal`장(기본 2).
- 한 턴에 여러 개가 겹치면 모두 더한다. 예: 따닥과 쓸이 함께 나오면 상대마다 2장.
- 어떤 피를 주나: 상대가 가진 피 중 **값이 가장 낮은 것부터** 준다(일반 피 → 쌍피·2피 → 3피). 같은 값이면 ID가 작은 것부터. 피가 없으면 주지 않는다(빚 없음). 거스름은 없다.
- 국진이 열끗 자리에 있는 동안은 피로 보지 않으므로 뺏기지 않는다(`gukjinMode=auto/ask`일 때 판 중에는 열끗으로 둔다).
### 5.4 흔들기 (`shake`)
- 손에 같은 월 3장이 있고 바닥에 그 월이 0장일 때, 그중 1장을 내면서 "흔들기"를 선언할 수 있다.
- 3장을 모두에게 공개한다. 남은 2장은 손에 있지만 공개 상태로 표시한다.
- 이 판을 내가 이기면 흔들기 1번마다 x2. 2번 흔들면 x4.
- 선언하지 않고 그냥 내도 된다. 한 월에 1번만 할 수 있다.
### 5.5 폭탄 (`bomb`)
- 손에 같은 월 3장이 있고 바닥에 그 월이 1장 있을 때, 3장을 한꺼번에 내서 바닥 1장과 함께 4장을 모두 가져온다. 상대마다 피 1장. 흔들기와 같은 x2 배수를 1번 받는다.
- 그 뒤 손에 **폭탄 빈패 2장**을 받는다. 다음 턴부터 빈패를 내면 손패를 쓰지 않고 더미만 뒤집는다. 빈패는 아무 때나 쓸 수 있고 판이 끝날 때 남아 있으면 그냥 사라진다.
- 폭탄 턴에도 더미를 뒤집어 F 처리를 한다(F는 m1월일 수 없음).
- 바닥 1장이 뻑 무더기일 수는 없다(4장 한계).
### 5.6 보너스패 (`bonusCards`)
- 손에 있는 보너스패: 내 턴에 언제든 낼 수 있다. 바로 내 먹은 패(피 영역)로 가고, 더미 맨 위 1장을 손에 가져온 뒤 **같은 턴에 다시 손패 1장을 낸다**(턴 소모 없음).
- 더미에서 뒤집힌 보너스패: 내가 먹고 1장을 더 뒤집는다.
- 바닥에 깔린 보너스패: 셋업 단계에서 선이 가져간다.
- 보너스패 1장은 정확히 더미 1장을 더 쓰게 하므로 손패와 더미 장수는 항상 맞아떨어진다.
### 5.7 총통 (`chongtong`)
- 패를 돌린 직후(광팔기 뒤) 손에 같은 월 4장을 가진 사람은 총통을 선언할 수 있다.
- `win`: 선언하면 즉시 판이 끝나고 `chongtongPoints`점(기본 맞고 10, 고스톱 5)을 상대마다 받는다. 박·흔들기 배수는 없고 나가리 배수만 적용한다. 선언하지 않고 진행해도 된다. 진행하면 그 월로 흔들기·폭탄을 할 수 있다.
- 여러 명이 총통이면 선부터 진행 순서로 먼저인 사람만 선언할 수 있다.
- `redeal`: 다시 섞어 돌린다(선 유지). `off`: 아무 일도 없다.
### 5.8 광팔기 (4~5인, `gwangPalgi`)
- 모두 7장씩 받은 뒤 **선의 다음 사람부터 진행 순서대로** "치기" 또는 "죽기(광 팔기)"를 고른다. 선은 반드시 친다.
- 치기 3명(선 포함)이 정해지면 나머지는 자동으로 죽는다. 죽는 사람이 (인원 - 3)명이 되면 나머지는 자동으로 친다.
- 죽는 사람은 손패 중 팔 수 있는 패(`gwangSellCards`, 기본 광만)를 공개하고 그 장수 x `gwangPrice`점을 광값으로 받는다. 내는 사람은 `gwangPayer`에 따른다(기본: 선을 뺀 참가자 2명이 각자). 광이 없으면 그냥 빠진다.
- 죽은 사람의 손패는 공개한 패까지 포함해 모두 더미와 함께 RNG로 섞는다.
- 광값은 판 결과와 상관없이(나가리여도) 그 시점에 바로 정산한다.
### 5.9 고 / 스톱
- 내 턴 처리가 끝났을 때 내 점수(6.1)가 `winScore` 이상이면 선택한다. 이미 고를 한 적이 있으면 **마지막 고 때 점수보다 커야** 다시 선택할 수 있다(같거나 작으면 그냥 다음 턴).
- **스톱**: 판이 끝나고 내가 이긴다(6장 정산).
- **고**: 고 횟수 +1, 판을 계속한다. 이때 점수를 "마지막 고 점수"로 기록한다.
- 내 손패가 0장이 되는 마지막 턴(빈패만 남은 경우 포함)에 조건이 되면 고를 할 수 없고 **자동 스톱**이다.
- 3인에서 다른 사람이 고를 부른 뒤에도 각자 자기 턴에 조건이 되면 스톱할 수 있다(먼저 스톱한 사람이 승자).
### 5.10 판 끝
- 누군가 스톱 → 그 사람 승리.
- 총통 선언, 삼뻑 → 즉시 승리.
- 손패와 더미를 모두 쓴 마지막 턴까지 아무도 스톱하지 않음(아무도 기준 점수를 못 넘었거나, 고를 부른 사람이 그 뒤 점수를 더 내지 못함) → **나가리**. 점수 이동 없음, 선 유지, 다음 판 배수 x2(연속이면 누적, 상한 `nagariMaxMultiplier`).
### 5.11 첫뻑·연뻑·삼뻑 (`ppeokBonus`)
- 첫뻑: 자기 첫 턴에 뻑을 내면 즉시 상대마다 3점을 받는다.
- 연뻑: 자기 첫 턴과 둘째 턴에 연달아 뻑을 내면 둘째 턴에 상대마다 6점을 더 받는다(누적 9점).
- 삼뻑: 한 판에서 같은 사람이 뻑을 3번 내면 판이 즉시 끝나고 그 사람이 이긴다. 점수 = `chongtongPoints`와 같은 값(박·흔들기 배수 없음, 나가리 배수 적용). 첫뻑·연뻑으로 받은 점수는 돌려주지 않는다.
- 첫뻑·연뻑 보너스는 판 승패와 상관없이 바로 방 점수에 반영한다. 나가리가 되어도 돌려주지 않는다.
## 6. 승패와 점수 계산
### 6.1 기본 점수(족보 합)
| 족보 | 조건 | 점수 |
|---|---|---|
| 오광 | 광 5장 | 15 (`ogwangPoints`) |
| 사광 | 광 4장(비광 포함 여부 무관) | 4 |
| 삼광 | 비광 없이 광 3장 | 3 |
| 비삼광 | 비광 포함 광 3장 | 2 (`biGwangThree`) |
| 열끗 | 5장 1점, 이후 1장마다 +1 | 장수 - 4 |
| 고도리 | 2·4·8월 열끗(새 3마리) | +5 |
| 띠 | 5장 1점, 이후 1장마다 +1(비띠 포함) | 장수 - 4 |
| 홍단 | 1·2·3월 띠 | +3 |
| 청단 | 6·9·10월 띠 | +3 |
| 초단 | 4·5·7월 띠 | +3 |
| 피 | 피 값 합 10에 1점, 이후 1마다 +1 | 피 값 - 9 |
- 고도리 카드는 열끗 장수에도 들어가고, 단 카드는 띠 장수에도 들어간다(중복 인정).
- 피 값: 일반 피 1, 쌍피 2, 2피 보너스 2, 3피 보너스 3, 국진(쌍피로 쓸 때) 2.
- 국진(`gukjinMode=auto`): 점수를 계산할 때마다 열끗 1장으로 셀 때와 피 값 2로 셀 때를 모두 계산해 **주인에게 유리한 쪽**을 쓴다. 승자는 최종 받을 점수가 큰 쪽, 패자는 낼 점수가 작은 쪽(피박·멍박 회피 포함)이다. 기준 점수 도달 판정에도 큰 쪽을 쓴다. `ask`면 판 끝 정산 직전에 본인이 고르고, 시간이 지나면 `auto` 규칙으로 정한다.
### 6.2 최종 점수 공식
```
T = (기본 점수 + 고 횟수) x 고 배수 x 2^(흔들기 횟수 + 폭탄 횟수) x 멍따 배수 x 나가리 배수
고 배수 = 1 (고 0~2회), 2^(고 횟수 - 2) (고 3회 이상: 3고 x2, 4고 x4, 5고 x8 …)
멍따 배수 = 2 (meongTta이고 승자 열끗 7장 이상), 아니면 1
나가리 배수 = 2^(직전 연속 나가리 수), 상한 nagariMaxMultiplier
패자 L이 낼 점수 = T x 피박(2) x 광박(2) x 멍박(2) (해당하는 것만 곱함)
```
- **피박**: 승자의 피 점수가 1점 이상(피 값 10 이상)이고, 패자의 피 값이 1 ~ `piBakLimit`(맞고 7, 고스톱 5). 피 0이면 `piBakZeroExempt`에 따라 면제(기본 면제).
- **광박**: 승자가 광 족보 점수(삼광·비삼광·사광·오광)를 받았고 패자 광이 0장.
- **멍박**(옵션): 승자가 멍따이고 패자 열끗이 0장.
- **고박**(`goBak`): 이번 판에 고를 불렀다가 다른 사람이 이긴 패자.
- 맞고: 그 패자의 낼 점수 x2.
- 3인: **독박** — 고를 부른 패자가 자기 몫과 다른 패자 몫을 모두 낸다. 다른 패자는 0점. 고를 부른 패자가 2명이면 마지막에 고를 부른 사람이 독박이다.
- 박이 여러 개면 모두 곱한다. 3인에서는 패자마다 박 여부를 따로 판정한다.
- 판 정산: 승자 방 점수 += 패자들이 낸 합, 각 패자 -= 자기가 낸 점수. 총통·삼뻑 승리는 `점수 x 나가리 배수`를 패자마다 받는다.
### 6.3 계산 예시
1. 맞고 기본 스톱: 광 1·3·8월(삼광 3점) + 피 값 12(3점) + 띠 5장(1점) = 7점, 고 없이 스톱. 상대 광 0장, 피 6장 → 피박·광박. 7 x 2 x 2 = **28점**.
2. 비삼광: 광 1·3·12월 = 2점(비삼광). 광 4장(1·3·8·12) = 4점.
3. 고 배수: 맞고, 기본 9점에서 3고 후 스톱, 흔들기 1회. (9 + 3) x 2 x 2 = **48점**. 상대 피박이면 96점.
4. 고 점수 비교: 맞고 7점에서 1고. 다음 턴 피를 뺏겨 6점이 됐다가 다시 7점 → 7은 마지막 고 점수(7)보다 크지 않으므로 선택 없음. 8점이 되면 선택. 그때 스톱하면 (8 + 1) = **9점**.
5. 3인 독박: A가 3점에서 1고. B가 홍단 3 + 띠 5장 1 + 피 값 11(2) = 6점으로 스톱(고 없음). C 피 4장(피박), A 피 8장. C 몫 6 x 2 = 12, A 몫 6. A 독박 → A가 18점, C 0점, B +18.
6. 국진 자동: 승자 열끗 5장(국진 포함)·피 값 9 → 국진을 열끗으로 보면 열끗 1 + 피 0 = 1점, 쌍피로 보면 열끗 0(4장) + 피 값 11 → 2점. 쌍피로 계산.
7. 나가리 다음 판: 직전 판 나가리, 이번 판 7점 스톱, 박 없음 → 7 x 2 = **14점**. 2판 연속 나가리 뒤면 x4.
8. 멍따: 열끗 7장(고도리 포함) → 열끗 3점 + 고도리 5점 = 8점, 멍따 x2 → 16점.
### 6.4 세트 결과와 동점
- `rounds` 판이 끝나면 방 점수 합계(판 점수 + 광값 + 첫뻑·연뻑 보너스)로 순위를 매긴다.
- 동점은 같은 순위로 처리한다(공동 순위). 다음 순위는 건너뛴다(1, 1, 3).
- 판 점수는 제로섬이다(모든 플레이어 점수 합 = 0).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 피박 기준 장수, 피 0장 면제: `piBakLimit`, `piBakZeroExempt`
- 비광 3광 2점/불인정, 오광 15/30: `biGwangThree`, `ogwangPoints`
- 멍따·멍박·고박·광박: 각 옵션
- 국진 처리 방식: `gukjinMode`
- 총통 즉시 승리 / 다시 돌림 / 무시, 총통 점수
- 첫뻑·연뻑·삼뻑 보너스: `ppeokBonus`
- 자뻑 피 1장/2장: `jaPpeokSteal`
- 마지막 턴 쓸·쪽 인정: `lastTurnSweepBonus`
- 나가리 배수: `nagariMultiplier`, `nagariMaxMultiplier`
- 보너스패 구성: `bonusCards`
- 광팔기 가격·지불자·팔 수 있는 패: `gwangPrice`, `gwangPayer`, `gwangSellCards`
- 지원하지 않는 변형(향후 후보): 2장 폭탄, 흔들기 후 폭탄 추가 배수, 9월 열끗 판 중 이동, "고 선언 후 스톱 시 상대 독박 면제" 같은 동네 규칙, 섯다식 땡값. 규칙 충돌이 커서 1차 범위에서 뺀다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type CardId = number; // 0..47 화투, 48.. 보너스
type Month = 1|2|3|4|5|6|7|8|9|10|11|12;
interface CardInfo { // 상수 테이블(3.1)
id: CardId; month: Month | null;
kind: 'gwang' | 'yeol' | 'tti' | 'pi';
piValue: 0 | 1 | 2 | 3;
bi?: true; godori?: true; dan?: 'hong' | 'cheong' | 'cho'; gukjin?: true; bonus?: true;
}
interface GostopOptions { /* 2장 표의 키 전부 */ }
interface FloorGroup { cards: CardId[]; ppeokBy: number | null; natural: boolean } // cards 0..3장
interface RoundState {
participants: number[]; // 이번 판 치는 player index, 진행 순서(선부터)
sellers: Array<{ idx: number; shown: CardId[]; paid: number }>;
hands: CardId[][]; // player index별(판 밖 사람은 [])
bombDummies: number[];
revealed: CardId[][]; // 흔들어서 공개된 손패
deck: CardId[]; // 0번이 맨 위
floor: Partial<Record<Month, FloorGroup>>;
captured: CardId[][];
gukjinChoice: (null | 'yeol' | 'pi')[];
goCount: number[]; lastGoScore: number[]; goOrder: number[]; // 고 부른 순서(독박 판정)
shakes: number[]; bombs: number[];
ppeokCount: number[]; ppeokOnTurn: boolean[][]; // [player][턴번호]
turnsTaken: number[];
turn: number; // player index
step: 'gwangSelling' | 'chongtong' | 'play' | 'chooseFlip' | 'goStop' | 'gukjin' | 'done';
pendingTurn: null | { // chooseFlip 대기 중 보관
played: CardId[]; flipped: CardId; heldPair: CardId[]; stealCount: number;
};
sellOrder: number[]; sellCursor: number;
multiplierNagari: number; // 이 판에 적용되는 나가리 배수
lastTurn: boolean;
outcome: null | RoundOutcome;
}
interface RoundOutcome {
type: 'stop' | 'chongtong' | 'samppeok' | 'nagari';
winner: number | null;
baseScore: number; // 족보 합
breakdown: Array<{ name: string; points: number }>;
multipliers: Array<{ name: string; factor: number }>;
payments: Array<{ from: number; amount: number; baks: Array<'pi' | 'gwang' | 'meong' | 'go'> }>;
}
interface GostopState {
options: GostopOptions;
mode: 'matgo' | 'gostop';
players: PlayerId[]; // 착석 순서 = 진행 순서
rng: RngState;
totals: number[]; // 방 점수(세트 누적)
roundNo: number; // 1부터
dealer: number;
nagariStreak: number;
round: RoundState | null;
phase: 'playing' | 'roundResult' | 'finished';
ready: number[];
autoPilot: boolean[];
timeoutStreak: number[];
deadline: number | null;
log: RoundOutcome[];
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `sell` | `{ sell: boolean }` | 광팔기 단계, `sellOrder[sellCursor]` | 결정 차례인 사람만. 선은 sell=true 불가. 치기 3명 또는 죽기 (인원-3)명이 이미 채워진 뒤의 사람은 자동 결정되므로 액션을 받지 않음 |
| `chongtong` | `{ declare: boolean }` | `step='chongtong'`, 총통 보유자 중 우선순위 1명 | 손에 같은 월 4장 |
| `play` | `{ card: CardId \| 'dummy'; target?: CardId; shake?: boolean; bomb?: boolean }` | `step='play'`, 차례인 사람 | card가 손에 있음(dummy면 빈패 ≥1). 바닥 같은 월 2장이고 종류가 다르면 target 필수이며 그 2장 중 하나. `shake`: 같은 월 3장 보유 + 바닥 0장 + 그 월 미흔듦. `bomb`: 같은 월 3장 보유 + 바닥 1장(이때 card는 그중 아무 1장, 3장 모두 냄). 보너스패면 target/shake/bomb 없음 |
| `chooseFlip` | `{ target: CardId }` | `step='chooseFlip'`, 차례인 사람 | target이 바닥의 F 월 2장 중 하나 |
| `goStop` | `{ decision: 'go' \| 'stop' }` | `step='goStop'`, 차례인 사람 | 마지막 턴이면 go 불가 |
| `gukjin` | `{ as: 'yeol' \| 'pi' }` | `gukjinMode='ask'`, 판 끝 정산 직전, 국진 보유자 | 국진 보유 |
| `ready` | `{}` | `phase='roundResult'`, 모든 플레이어 | 아직 ready 안 함 |
- `validate` 실패 사유 예: "지금은 당신 차례가 아닙니다", "손에 없는 패입니다", "가져올 패를 골라 주세요", "흔들기는 같은 월 3장을 가지고 있고 바닥에 그 월이 없을 때만 할 수 있습니다", "폭탄은 같은 월 3장과 바닥 1장이 있어야 합니다", "선은 광을 팔 수 없습니다", "마지막 턴에는 고를 할 수 없습니다".
- `apply` 이벤트: `dealt`, `bonusTaken`, `played`, `flipped`, `captured`, `ppeok`, `jjok`, `ttadak`, `sseul`, `bomb`, `shake`(공개 패 포함), `piStolen`, `scoreUpdate`, `goStopPrompt`(본인), `go`, `stop`, `nagari`, `chongtong`, `gwangSold`, `roundEnded`, `setEnded`.
- 보너스패를 내면 `step`은 그대로 `play`이고 같은 사람이 다시 낸다.
- `activePlayers`: 광팔기면 결정할 1명, `chongtong`/`play`/`chooseFlip`/`goStop`이면 차례인 1명, `gukjin`이면 국진 보유자, `roundResult`면 ready 안 한 사람들.
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함): 바닥 전체(뻑 무더기 표시), 더미 장수, 각자 먹은 패(종류별 분류), 각자 현재 족보 점수와 진행도(예: 홍단 2/3), 고 횟수, 흔들기·폭탄 횟수, 뻑 횟수, 손패 장수, 빈패 수, 흔들기로 공개된 패, 광팔이가 공개한 광, 방 점수, 판 번호, 선, 나가리 배수, 마감 시각.
- 본인만: 내 손패, 내 합법 행동(먹을 수 있는 패 하이라이트용 매칭 정보, 흔들기·폭탄 가능 여부), 고/스톱 선택지, 내 국진 선택지.
- 절대 보내지 않음: 더미 순서(다음 뒤집을 패 포함), 남의 손패(흔든 패 제외), 광팔이의 공개하지 않은 패, 섞기 전 패 배열, RNG 상태.
- 판 끝: 남은 손패는 공개하지 않는다(정산에 필요 없음). 정산 내역(족보·배수·박)은 공개한다.
- 관전자는 `viewer=null`로 공개 정보만 받는다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. 첫 판 선 정하기.
2. 매 판 패 섞기(Fisher–Yates).
3. 바닥 같은 월 4장, 총통 `redeal` 때 다시 섞기.
4. 광팔기 뒤 죽은 사람 손패 + 더미 다시 섞기.
- 그 밖에는 RNG를 쓰지 않는다. 자동 행동은 결정적이다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 턴(`play`) = `turnSeconds`, 선택 단계(`chooseFlip`, `goStop`, `chongtong`, `sell`, `gukjin`) = `decisionSeconds`, 결과 화면 = 10초.
- `onTimeout(state, player)` (모두 결정적):
- `play`: 빈패가 있으면 빈패를 낸다. 없으면 보너스패가 있으면 그것부터. 그 밖에는 바닥과 맞는 패 중 가져올 바닥 패 가치가 가장 큰 것(광 > 열끗 > 띠 > 쌍피 > 피, 같으면 낮은 ID), 맞는 패가 없으면 손패 중 가치가 가장 낮은 패(같으면 낮은 ID). target은 가치 큰 쪽. 흔들기·폭탄은 선언하지 않는다.
- `chooseFlip`: 가치 큰 쪽.
- `goStop`: **스톱**.
- `chongtong`: `declare: true`.
- `sell`: 죽기(sell=true)가 가능하면 죽기, 아니면 치기.
- `gukjin`: `auto` 규칙.
- `roundResult`: `ready`.
- 연속 2회 시간 초과나 연결 끊김이면 `autoPilot=true`가 되고 마감을 2초로 줄인다. 액션 1회로 해제된다.
- 서버는 도착 순서대로 처리하고, 마감 이후 도착한 액션은 버린다.
### 8.6 종료 조건과 결과(GameResult)
- `rounds`판을 마치면(나가리 포함) `finished`. 플레이어가 나가면 그 자리는 autoPilot으로 남은 판을 진행한다.
```ts
interface GostopResult {
ranking: Array<{ player: PlayerId; rank: number; total: number }>; // 동점 = 같은 rank
summary: {
rounds: number; nagari: number;
bestRound: { player: PlayerId; amount: number; roundNo: number; breakdown: string[] } | null;
perPlayer: Array<{ player: PlayerId; wins: number; goCalls: number; piBakGiven: number; gwangSold: number }>;
};
}
```
## 9. UI/UX
- 모바일 세로 배치(위 → 아래)
- 상대 영역: 이름, 방 점수, 손패 장수(뒷면), 먹은 패를 종류별로 겹친 묶음(광/열끗/띠/피, 탭하면 펼침), 현재 점수 배지, 고 횟수 배지.
- 바닥: 월별로 묶어 3x4 격자에 배치(같은 월은 겹침). 뻑 무더기는 빨간 테두리. 더미는 가운데 옆에 남은 장수와 함께.
- 내 먹은 패: 종류별 묶음 + 족보 진행도("홍단 2/3", "피 8/10").
- 내 손패: 10장(맞고)이 2줄로 배치되어 카드 1장 너비가 60px 이상이 되게 한다. 카드 높이 84px 이상.
- 조작: 손패를 탭하면 바로 낸다(화투는 실수 부담이 마작보다 작다). 설정에서 "두 번 탭해서 내기"를 고를 수 있다. 바닥 2장 중 고르기는 두 카드를 크게 띄우고 탭.
- 합법 수 하이라이트: 바닥과 맞는 손패에 초록 테두리, 낼 경우 가져올 바닥 패를 함께 깜빡임. 흔들기·폭탄 가능 패에는 배지.
- 고/스톱 화면: 화면 가운데 큰 버튼 2개(각 높이 72px 이상). 지금 점수, 고 하면 바뀌는 배수, 상대가 박 대상인지 미리 보여 준다(예: "지금 스톱하면 28점: 피박 x2, 광박 x2").
- 애니메이션: 패 날아가기 200ms, 쪽·따닥·뻑·쓸·폭탄은 큰 글자와 효과음, 피 뺏기는 카드가 상대에게서 날아옴. "애니메이션 줄이기" 설정 제공.
- 카드 그림: 전통 화투 도안을 새로 그린 자체 일러스트. 모서리에 월 숫자와 종류 글자(광/열/띠/피, 쌍피는 "피2")를 크게 표시해 어르신·아이도 구분하기 쉽게.
- 초보자 도움말: [규칙 보기]에 월별 카드표, 족보표, 박 설명, 그림 예시. `hints` on이면 "이 패를 내면 홍단 완성" 같은 한 줄 힌트(본인 손패와 공개 정보만 사용).
- 판 결과 화면: 족보별 점수 → 고 가산 → 배수 → 박 순으로 한 줄씩 계산 과정을 보여 준다.
## 10. 테스트 체크리스트
- [ ] 셋업: 같은 시드면 같은 배분. 맞고 손 10/바닥 8/더미 20(+보너스 수), 3인 7/6/21, 4·5인 광팔기 뒤 더미 21.
- [ ] 바닥 4장 같은 월 → 다시 섞기, 선·판 번호 유지. 바닥 3장 같은 월 → 한 무더기, 4번째로 먹으면 피 1장씩.
- [ ] 바닥 보너스패 → 선이 가져가고 더미에서 보충, 보충패도 보너스면 반복.
- [ ] 쪽: 바닥에 없는 월을 냈는데 같은 월이 뒤집힘 → 2장 먹고 상대마다 피 1장.
- [ ] 뻑: 바닥 1장과 짝 + 같은 월 뒤집힘 → 3장 무더기, 아무것도 못 먹음, ppeokBy 기록. 다른 사람이 4번째로 먹으면 피 1장, 본인이 먹으면(자뻑) 피 2장씩.
- [ ] 따닥: 바닥 2장 + 손 1장 + 같은 월 뒤집힘 → 4장 먹고 피 1장씩.
- [ ] 쓸: 턴 끝에 바닥이 비면 피 1장씩. 마지막 턴 쓸은 `lastTurnSweepBonus=false`일 때 피 뺏기 없음.
- [ ] 두 장 중 고르기: 손패가 맞는 바닥 2장이 광/피면 target 없이 내면 거부. 뒤집은 패가 바닥 2장과 맞으면 `chooseFlip` 단계. 종류가 같으면 자동.
- [ ] 폭탄: 3장 + 바닥 1장 → 4장 먹고 피 1장씩, 빈패 2장 지급, 빈패 내면 더미만 뒤집음, 판 끝 x2.
- [ ] 흔들기: 공개된 패가 상대 view에 보이고 나머지 손패는 안 보임. 2번 흔들고 이기면 x4. 지면 배수 없음.
- [ ] 총통: 손에 같은 월 4장 → 선언 시 맞고 10점(나가리 배수만 적용). 미선언 시 진행 가능.
- [ ] 피 뺏기 순서: 상대가 일반 피 0장·쌍피 1장이면 쌍피를 준다. 피가 0장이면 아무것도 안 줌.
- [ ] 점수: 비삼광 2점, 사광 4점(비광 포함), 오광 15점, 고도리 5 + 열끗 장수 점수 중복, 띠 5장 1점 + 홍단 3점 중복, 피 값 10에 1점(쌍피·보너스 반영).
- [ ] 국진 auto: 6.3 예시 6. 패자 국진이 피박 회피에 쓰임(피 값 5 → 7, 맞고 기준 7이면 여전히 피박, 3인 기준 5→7이면 피박 면제).
- [ ] 고 규칙: 마지막 고 점수와 같으면 선택 프롬프트 없음, 커지면 프롬프트. 마지막 턴 조건 충족 → 자동 스톱. 3고 x2, 4고 x4, 1·2고 가산만.
- [ ] 최종 공식: 6.3 예시 1(28), 3(48), 5(독박 18), 7(나가리 14), 8(멍따 16)과 같다.
- [ ] 박: 피박 경계값(맞고 7장 피박 / 8장 아님, 3인 5/6), 피 0장 면제, 광박은 승자가 광 점수가 있을 때만, 고박 맞고 x2.
- [ ] 나가리: 고를 부른 사람이 끝까지 점수를 더 못 냄 → 나가리, 선 유지, 다음 판 x2, 연속 나가리 x4, 상한 x8.
- [ ] 첫뻑 3점 즉시, 연뻑 추가 6점, 삼뻑 즉시 승리. 나가리여도 첫뻑 점수 유지.
- [ ] 광팔기(4인): 선 다음 사람부터 결정, 선은 sell 불가, 치기 3명 확정 시 나머지 자동 죽기, 광 2장 공개 → 선 제외 참가자 2명이 각 2점(가격 1). 죽은 사람 손패가 더미에 섞여 더미 21장.
- [ ] 보너스패: 손에서 내면 먹고 1장 보충 후 같은 턴 계속. 판 끝까지 손패·더미 장수가 맞는다(더미 부족 없음).
- [ ] 시간 초과: play 자동 행동이 결정적(같은 상태 → 같은 카드). goStop 시간 초과 → 스톱. 2회 연속 → autoPilot, 마감 2초.
- [ ] 연결 끊김: 끊긴 사람 턴이 2초 안에 자동 처리되어 판이 멈추지 않는다. 재접속 후 액션으로 autoPilot 해제.
- [ ] 정보 누출: 상대·관전자 view JSON에 내 손패 ID, 더미 순서, 광팔이 미공개 패가 없다. 흔든 패만 보인다. 이벤트 `flipped`는 뒤집힌 뒤에만 카드 ID를 담는다.
- [ ] 제로섬: 각 판 정산 후 방 점수 합 = 0(광값·첫뻑 포함).
- [ ] 재현성: 같은 시드 + 같은 액션 로그 → 같은 결과.
## 11. 참고 자료
- 나무위키 "고스톱"(패 돌리기, 뻑·쪽·따닥·쓸·폭탄·흔들기·총통, 점수, 박, 고 배수, 나가리, 국진): https://namu.wiki/w/고스톱
- 나무위키 "맞고"(2인 배분 10/8/20, 7점, 피박 7장, 총통·보너스패 플랫폼별 차이): https://namu.wiki/w/맞고
- Pagat.com "Go-Stop"(월별 카드 구성, 광팔기 절차, 첫뻑·삼뻑, 폭탄 후 뒤집기 턴, 고박): https://www.pagat.com/fishing/gostop.html
- Wikipedia "Go-Stop"(배분, 쪽·따닥·뻑, 점수, 고 배수, 피박·광박): https://en.wikipedia.org/wiki/Go-Stop
## 12. 메모 (상표·법적 주의 등)
- 상표: "고스톱", "맞고", "화투"는 일반 명칭이다. 다만 "한게임 신맞고", "피망 뉴맞고", "넷마블 맞고" 등 서비스명은 상표이므로 쓰지 않는다. 표시 이름 후보: "고스톱", "맞고(2인 고스톱)".
- 그림: 시판 화투나 기존 온라인 게임 카드 그림을 그대로 쓰지 않는다. 전통 도안(월별 꽃·동물 모티프)을 바탕으로 새로 그린 자체 일러스트를 쓴다.
- **법적 주의(중요)**:
- 점수는 방 안 가상 점수로만 쓰고, 유료 구매·충전·선물·환전·순위 보상과 연결하지 않는다. 연결하면 「게임산업진흥에 관한 법률」 제28조(사행성 조장 금지)·제32조(환전·환전 알선 금지)와 형법 제246조(도박) 문제가 생긴다.
- 고스톱·포커류 같은 웹보드 게임은 게임물관리위원회 등급분류에서 대체로 **청소년이용불가**로 분류된다. 유료 재화가 있으면 웹보드게임 규제(게임산업법 시행령 별표 2: 월 결제 한도, 1회 베팅 한도, 본인인증 등)도 적용된다. 이 사이트는 아이도 대상이므로 출시 전에 (a) 고스톱 방에 연령 확인·성인 전용 표시를 할지, (b) 등급분류를 받을지와 면제 대상인지를 확인해야 한다.
- 화면 문구에 "돈", "판돈", "점당 얼마" 같은 표현을 쓰지 않는다. "점수"만 쓴다.

203
docs/games/halli-galli.md Normal file
View File

@@ -0,0 +1,203 @@
# 할리갈리 (`halli-galli`)
> 마일스톤: M6 · 인원: 최소 2 ~ 최대 6명 · 예상 시간: 약 5~15분 · 난이도: 쉬움
## 1. 개요
- 차례대로 과일 카드를 한 장씩 뒤집다가, 펼쳐진 카드에서 **한 종류 과일이 정확히 5개**가 되면 가운데 종을 가장 먼저 친 사람이 펼쳐진 카드를 모두 가져가는 순발력 게임.
- 한국에서 가장 유명한 보드게임 중 하나로(코리아보드게임즈 유통), 어린이부터 어르신까지 규칙을 1분이면 이해한다.
- 인원 근거: 원작(Amigo, 1990, 하임 샤피르) 박스 표기 2~6인.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `endMode` | 종료 방식 | `lastStanding`(한 명이 모든 카드를 가질 때까지, 국내 통상 규칙) / `official`(2명 남으면 다음 종 판정으로 종료, 원작 규칙) | `lastStanding` |
| `timeLimitMin` | 전체 제한 시간(분). 만료 시 카드 많은 사람 승 | 5 / 10 / 15 / 없음 | 10 |
| `flipSeconds` | 자기 차례에 뒤집기 제한 시간(초과 시 자동 뒤집기) | 2 / 3 / 5 / 8 | 3 |
| `autoFlip` | 모든 뒤집기를 서버가 일정 간격으로 자동 진행 | on / off | off |
| `flipGuardMs` | 뒤집기 직후 다음 뒤집기를 받지 않는 시간(지연 공정성) | 400 / 600 / 1000 | 600 |
| `wrongRingLockoutMs` | 틀린 종 후 그 사람의 종 잠금 시간 | 0 / 1000 / 2000 | 1000 |
| `leftoverCards` | 나누고 남은 카드 처리 | `pot`(종 밑, 다음 정답자가 가져감) / `remove`(게임에서 제외) | `pot` |
| `latencyComp` | 지연 보정 | `off` / `light` | `off` |
| `showRtt` | 각자 핑 표시 | on / off | on |
| `kidMode` | 어린이 모드: `flipSeconds` 8, 과일 개수 합계 보조 표시 | on / off | off |
## 3. 구성물
- 카드 56장: 과일 4종(딸기, 바나나, 라임, 자두) × 14장.
- 과일 1종당 구성:
| 그려진 과일 수 | 장수 |
|---|---|
| 1개 | 5장 |
| 2개 | 3장 |
| 3개 | 3장 |
| 4개 | 2장 |
| 5개 | 1장 |
과일 1종 합계 14장, 과일 개수 총합 5×1 + 3×2 + 3×3 + 2×4 + 1×5 = 33개.
- 종 1개(가상, 화면 가운데).
## 4. 준비(셋업)
1. 56장을 RNG로 섞는다. 좌석과 첫 플레이어도 RNG.
2. 같은 장수로 나눈다: `floor(56 / N)`장씩. 남은 카드는 `leftoverCards`에 따라 종 밑(공개되지 않은 더미, 장수만 공개) 또는 제외.
| 인원 | 1인당 | 남는 카드 |
|---|---|---|
| 2 | 28 | 0 |
| 3 | 18 | 2 |
| 4 | 14 | 0 |
| 5 | 11 | 1 |
| 6 | 9 | 2 |
3. 각자 받은 카드는 뒷면 더미(덱). 그 앞에 공개 더미(펼친 카드) 자리가 빈 채로 시작.
4. 3초 카운트다운 후 첫 플레이어 차례.
## 5. 진행 규칙
### 5.1 뒤집기
- 차례인 플레이어는 자기 덱 맨 위 카드를 뒤집어 자기 공개 더미 위에 놓는다. 그 다음 시계 방향으로 다음 "참가 중" 플레이어 차례.
- 각 플레이어의 공개 더미 **맨 위 카드만** 셈에 들어간다.
- `flipSeconds` 안에 뒤집지 않으면 서버가 자동으로 뒤집는다.
- 뒤집은 직후 `flipGuardMs` 동안은 다음 사람의 뒤집기를 받지 않는다(예약해 두었다가 시간이 지나면 처리). 지연이 큰 사람도 같은 화면을 보고 종을 칠 시간을 보장하기 위함.
### 5.2 종 치기
- 누구든(차례와 무관하게, 참가 중이면) 언제든 종을 칠 수 있다.
- 판정 기준: 종을 친 순간의 테이블 상태. 테이블의 모든 공개 더미 맨 위 카드(탈락자의 공개 더미 포함)에서 과일별 개수를 합산해 **어떤 과일이든 정확히 5**면 정답. 6 이상, 4 이하는 정답이 아니다.
- 예: 딸기 2 + 딸기 3 + 바나나 4 → 딸기 5 → 정답. 딸기 2 + 딸기 3 + 딸기 1 → 딸기 6 → 오답.
- **정답**: 가장 먼저 도착한 사람이 모든 공개 더미(+ 종 밑 카드)를 가져와 자기 덱 맨 아래에 넣는다(순서: 좌석 순으로 각 공개 더미를 아래부터, 마지막에 종 밑 카드). 그 사람이 다음 뒤집기 차례.
- **오답**: 종을 친 사람은 자기 덱에서 다른 참가 중인 모든 플레이어에게 1장씩 준다(받는 사람 덱 맨 아래, 지급 순서는 시계 방향). 덱이 모자라 다 주지 못하면 시계 방향 순으로 줄 수 있는 만큼만 주고 즉시 탈락. 정확히 다 줘서 0장이 된 경우는 5.3과 같이 자기 차례가 올 때까지 참가 상태. 진행 차례는 바뀌지 않는다.
- **늦은 종**: 종에는 클라이언트가 보고 있던 테이블 버전(`tableVersion`, 뒤집기·수거마다 +1)이 담긴다. 서버 버전과 다르면 "늦었어요"로 무시(벌칙 없음). 같은 버전에서 이미 누가 정답을 가져갔다면 버전이 바뀌었으므로 자연히 무시된다.
- 2명이 거의 동시에 친 경우: 서버 도착 순서가 우선(5.4).
### 5.3 탈락
- 자기 차례가 왔는데 덱이 0장이면 탈락(공개 더미는 테이블에 남아 계속 셈에 들어가며, 다음 정답자가 가져간다).
- 덱이 0장이어도 자기 차례가 오기 전에는 종을 칠 수 있다(정답이면 카드를 얻어 복귀). 단 오답이면 줄 카드가 없으므로 즉시 탈락.
- 탈락자는 관전 화면으로 전환.
### 5.4 실시간 판정과 공정성
- 방 단위 단일 큐에서 서버 수신 순서대로 `serverTs`를 붙여 처리한다. 같은 ms라도 큐 순서가 판정 순서이고 로그로 재생 가능.
- 클라이언트 시각은 신뢰하지 않는다.
- RTT: 2초마다 ping, 지수이동평균(α=0.2). `showRtt`면 이름 옆에 표시(초록 < 80ms, 노랑 < 200ms, 빨강 ≥ 200ms). 200ms 이상인 사람에게 "연결이 느려요" 안내.
- `flipGuardMs`: 테이블이 바뀐 뒤 최소 이 시간 동안은 다시 바뀌지 않으므로, 지연 RTT/2가 이 값보다 작은 사람은 정답 상태를 놓치지 않는다.
- `latencyComp=light`(선택): 첫 정답 종이 도착하면 50ms 창을 열고, 창 안의 같은 버전 정답 종들을 `serverTs - min(RTT/2, 80)` 기준으로 비교해 가장 이른 사람 승리. 창 마감 시 서버 내부 `resolve` 액션으로 확정(재생 가능). 오답 종은 창과 무관하게 즉시 벌칙.
- `wrongRingLockoutMs`: 오답 후 잠금(연타 방지). 잠금 중 종은 거부(벌칙 없음).
### 5.5 예시(3인 A·B·C)
1. A 뒤집기: 바나나 3. B 뒤집기: 딸기 2. C 뒤집기: 바나나 2 → 바나나 5.
2. B가 먼저 종(버전 3) → 정답. B가 세 공개 더미 + 종 밑 2장을 덱 아래로. B 차례로 다음 뒤집기.
3. 0.1초 뒤 A의 종(버전 3) 도착 → 버전이 4로 바뀌었으므로 "늦었어요", 벌칙 없음.
4. 이후 A가 바나나 4 상태에서 착각해 종 → 오답 → A가 B와 C에게 1장씩.
## 6. 승패와 점수 계산
- `lastStanding`: 마지막까지 남은 1명(모든 카드 보유) 승리. 순위는 탈락 역순.
- `official`: 참가자가 2명 남으면, 그다음 정답 종이 나올 때까지 진행하고 종료. 그 2명 상태에서 오답 종이 나오면 상대가 펼쳐진 카드 전부를 가져가고 즉시 종료. 종료 시 보유 카드가 많은 순. 처음부터 2인 게임이면 `official`도 `lastStanding`으로 진행한다(원작 문구대로면 첫 종에서 끝나 버리므로).
- `timeLimitMin` 만료: 즉시 종료, 보유 카드 수 많은 순.
- 보유 카드 수 = 덱 장수 + 자기 공개 더미 장수(아직 아무도 가져가지 않은 자기 카드). 종 밑 카드는 누구 것도 아님.
- 동점: 정답 종 횟수 많은 순 → 그래도 같으면 공동 순위.
- 방 기록용 점수: 1등 3점, 2등 2점, 3등 1점.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `duelWrongLoses` | 1대1 상황에서 오답 종을 친 사람이 즉시 패배(국내 하우스 룰) | off |
| `strikeOut` | 오답 3회 누적 시 몰수패 | off |
| `penaltyEscalation` | 오답마다 줄 장수가 1, 2, 3장...으로 증가 | off |
| `wrongToPot` | 오답 벌칙 카드를 다른 사람에게 주지 않고 종 밑으로 | off |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Fruit = 'strawberry' | 'banana' | 'lime' | 'plum';
interface Card { id: number; fruit: Fruit; count: 1 | 2 | 3 | 4 | 5 } // id 0..55
interface HalliGalliState {
options: HalliGalliOptions;
rng: RngState;
order: PlayerId[];
decks: Record<PlayerId, number[]>; // 뒷면 덱, 맨 위 = 끝
faceUp: Record<PlayerId, number[]>; // 공개 더미, 맨 위 = 끝
pot: number[]; // 종 밑
active: PlayerId[]; // 참가 중(탈락 안 함)
eliminated: PlayerId[]; // 탈락 순서
turn: PlayerId;
turnDeadline: number;
flipAllowedAt: number; // flipGuard
pendingFlip: PlayerId | null; // 가드 중 들어온 뒤집기 예약
tableVersion: number;
lockedUntil: Record<PlayerId, number>;
wrongCount: Record<PlayerId, number>;
correctCount: Record<PlayerId, number>;
ringWindow: null | { version: number; openedAt: number; rings: { player: PlayerId; adjTs: number }[] };
phase: 'countdown' | 'playing' | 'ended';
startedAt: number;
endsAt: number | null; // timeLimit
lastBell?: { player: PlayerId; correct: boolean; fruit?: Fruit; at: number };
}
```
### 8.2 액션
액션에는 서버가 `serverTs`를 붙인다.
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `flip` | `{}` | 차례인 플레이어, playing | `turn == actor`, 덱 1장 이상. `serverTs < flipAllowedAt`이면 거부하지 않고 `pendingFlip`에 예약 |
| `ring` | `{ version: number }` | 참가 중 플레이어(덱 0장 포함), playing | `serverTs >= lockedUntil[actor]`, `version == tableVersion`(다르면 "늦었어요"). 정답/오답 판정은 apply에서 |
| `tick` | `{}` | 서버 타이머 | 자동 뒤집기, 예약 뒤집기 실행, 시간 제한, 카운트다운 |
| `resolve` | `{}` | 서버 내부, `latencyComp=light` | 창 열림 |
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함): 각 공개 더미 맨 위 카드(과일·개수), 공개 더미 장수, 각자 덱 장수, 종 밑 장수, 차례, 테이블 버전, 잠금 상태, RTT, 남은 시간, 탈락자.
- 비공개: 모든 덱의 카드 내용과 순서(본인 덱 포함 — 실제 게임에서도 자기 덱을 보지 않음), 종 밑 카드 내용, 공개 더미의 맨 위 아래 카드(가려져 있으므로 보낼 필요 없음).
- 자기 덱 내용을 본인에게도 보내지 않는 이유: 다음 카드를 미리 알면 뒤집기 타이밍을 조작할 수 있다.
- "지금 5개다"라는 판정 결과는 view에 넣지 않는다(`kidMode`의 과일 합계 보조 표시는 클라이언트가 공개 정보로 계산).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
덱 셔플, 좌석, 첫 플레이어. 이후 카드 이동은 모두 결정적(수거 순서 고정).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `activePlayers`: 참가 중 전원(종은 누구나), 뒤집기는 `turn`.
- `deadline`: `min(turnDeadline, flipAllowedAt(예약 있을 때), ringWindow 마감, endsAt)`.
- `onTimeout(player)`: 차례 플레이어의 `flip`. 종은 자동으로 치지 않는다.
- 연결 끊김: 그 사람 차례는 `flipSeconds` 후 자동 뒤집기로 계속 진행(카드를 모두 잃을 때까지 남아 있음). 60초 이상 끊기면 `flipSeconds`를 1초로 줄여 진행 속도 유지.
- `autoFlip` on이면 모든 뒤집기를 서버가 `flipSeconds` 간격으로 실행(`flip` 액션 거부).
### 8.6 종료 조건과 결과(GameResult)
```ts
interface HalliGalliResult {
endReason: 'lastStanding' | 'official' | 'timeLimit';
ranking: { rank: number; id: PlayerId; cards: number; correct: number; wrong: number }[];
summary: string; // 예: "도윤님이 56장을 모두 모아 우승!"
}
```
## 9. UI/UX
- 모바일 세로: 화면 가운데 큰 종 버튼(지름 화면 폭 40% 이상, 최소 120px) — 화면 어디를 쳐도 되게 하지 않고 종만 반응(오조작 방지). 종 주위에 플레이어별 공개 카드(최대 6장)를 원형 배치. 하단 내 덱(장수 표시)과 "뒤집기" 버튼(내 차례에만 활성, 큰 버튼). 상단 남은 시간, 순위.
- 차례 표시: 차례인 사람 카드 자리 테두리 깜빡임 + 남은 뒤집기 시간 원형 게이지.
- 종 피드백: 정답 시 "땡!" 효과음, 카드가 정답자에게 날아가는 애니메이션. 오답 시 "삐!" + 벌칙 카드 이동 애니메이션 + 잠금 원형 타이머. 늦음 시 작은 "늦었어요".
- 키보드(PC): 스페이스 = 종, 엔터 = 뒤집기.
- 어린이 모드: 느린 뒤집기, 과일별 합계 막대(공개 정보로 계산), "5개가 되면 종을 쳐요" 상시 안내.
- 효과음은 끄기 가능, 진동(지원 기기) 옵션.
## 10. 테스트 체크리스트
- [ ] 덱: 56장, 과일별 14장, 개수별 5/3/3/2/1장.
- [ ] 분배: 인원별 장수와 남는 카드가 4장 표와 일치, `pot`/`remove` 처리.
- [ ] 판정: 딸기 2+3 → 정답, 딸기 2+3+1 → 오답, 다른 과일 섞여도 한 과일이 정확히 5면 정답, 탈락자 공개 더미 맨 위도 합산.
- [ ] 정답 수거 순서 결정적(좌석 순, 종 밑 마지막), 정답자가 다음 차례.
- [ ] 동시 종: 같은 버전 정답 종 2개 → 먼저 도착한 사람만 수거, 두 번째는 버전 불일치로 "늦었어요" 무벌칙.
- [ ] 오답 벌칙: 4인에서 덱 2장인 사람 오답 → 시계 방향 2명에게만 1장씩 주고 0장 → 즉시 탈락.
- [ ] 덱 0장인 사람이 차례 전에 정답 종 → 카드 획득 후 계속 참가. 차례가 왔을 때 0장 → 탈락.
- [ ] `flipGuardMs`: 가드 중 들어온 flip은 예약되었다가 가드 종료 시 실행, 테이블 버전 1회만 증가.
- [ ] 뒤집기 시간 초과 → 자동 뒤집기. 연결 끊긴 사람도 계속 자동 진행.
- [ ] `official` 종료: 3인 → 2인 남은 뒤 첫 정답 종에서 종료, 2인 상태 오답 → 상대 수거 후 종료. 처음부터 2인이면 lastStanding처럼 진행.
- [ ] 시간 제한 만료: 보유 카드(덱 + 자기 공개 더미) 기준 순위, 동점은 정답 횟수.
- [ ] 비공개 누출 검사: 모든 view/이벤트에 덱 카드 내용(본인 포함), 종 밑 카드 내용, 다음 카드가 없는지 자동 검사.
- [ ] 같은 시드 + 액션 로그(serverTs 포함) 재생 시 동일 결과.
## 11. 참고 자료
- UltraBoardGames "How to play Halli Galli"(원작 규칙: 오답 벌칙, 탈락, 2인 종료 규칙) — https://www.ultraboardgames.com/halli-galli/game-rules.php
- 나무위키 "할리갈리"(인원별 분배와 남는 카드, 국내 하우스 룰) — https://namu.wiki/w/할리갈리
- BoardGameGeek "Halli Galli" — https://boardgamegeek.com/boardgame/2944/halli-galli
- Board Game Capital "How To Play Halli Galli"(과일별 카드 구성) — https://www.boardgamecapital.com/halli-galli-rules.htm
## 12. 메모 (상표·법적 주의 등)
- "Halli Galli / 할리갈리"는 Amigo Spiele의 상표이며 국내 유통은 코리아보드게임즈. 카드 그림도 저작물이므로 자체 제작. 표시 이름 후보: "과일 종치기", "다섯 개 땡!", "후르츠 벨".
- 도박 요소 없음.
- 종 버튼 연타는 터치스크린 기기 성능 차이(입력 지연)가 있으므로 경쟁 기록(랭킹)을 만들 경우 공정성 한계를 안내한다.

258
docs/games/janggi.md Normal file
View File

@@ -0,0 +1,258 @@
# 장기 (`janggi`)
> 마일스톤: M2 · 인원: 최소 2 ~ 최대 2명 · 예상 시간: 약 20~40분 · 난이도: 보통
## 1. 개요
- 초(楚)와 한(漢)이 각 16개의 기물로 상대 궁(장)을 외통수로 몰면 이기는 한국 전통 2인 전략 게임. 공원·경로당·가정에서 어르신부터 어린이까지 두는 친숙한 게임이며 장기판 앞의 "장군!" "멍군!"이 상징적이다.
- 인원 근거: 전통 규칙상 초·한 2인. 최소 2, 최대 2.
- 규칙 기준: (사)대한장기연맹(KJF) 규정을 기본으로 하고, 온라인에 맞게 구현 방식을 정했다. 단체마다 다른 부분(빅장 조건, 반복수 처리, 상차림 순서)은 옵션으로 둔다.
- 완전 정보 게임. 단, `pairing=simultaneous` 상차림 옵션을 쓰면 상대 상차림이 확정 전까지 비공개(8.3).
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `scoring` | 점수제(판정이 필요한 종료를 기물 점수로 가림) | `true` / `false`(무승부 처리) | `true` |
| `bikjang` | 빅장 규칙 | `off`(궁이 마주봐도 아무 일 없음) / `kjf`(기물이 하나라도 잡힌 다음 수부터 빅장 가능) / `kja30`(양쪽 기물 점수가 모두 30점 미만일 때만 가능) | `kjf` |
| `repetition` | 반복수 처리 (같은 국면 4번째) | `forbid`(4번째를 만드는 수를 둘 수 없음) / `lose`(KJF: 4회 반복한 쪽 실격패) / `scoreEnd`(4번째 국면 발생 시 점수 판정 종료) | `forbid` |
| `allowPass` | 한수쉼 허용 | `true` / `false` | `true` |
| `setupOrder` | 상차림 순서 | `hanFirst`(KJF: 후수 한이 먼저 공개적으로 차리고 초가 나중) / `simultaneous`(동시에 비공개로 고른 뒤 공개) | `hanFirst` |
| `sideAssignment` | 초·한 결정 | `random` / `hostCho` / `hostHan` / `alternate` | `random` |
| `moveLimit` | 수 제한(도달 시 점수 판정) | `0`(없음) / 150 / 200 / 250 (양쪽 합산 수) | `0` |
| `timeControl.kind` | 시간 방식 | `none` / `byoyomi` | `byoyomi` |
| `timeControl.mainSec` / `periodSec` / `periods` | 기본 시간 / 초읽기 1회 / 횟수 | 5분·10분·20분 / 20·30·60초 / 1~5회 | 10분 / 30초 / 3회 |
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `hints` | 이동 가능 칸 표시, 장군 위험 경고 | `off` / `moves` / `movesAndThreats` | `moves` |
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 |
## 3. 구성물
- 판: 가로 9줄 × 세로 10줄, 교차점 90개. 기물은 교차점에 놓는다. 강(river) 없음.
- 궁성: 양쪽 끝 가운데 3×3 영역. 궁성 안에는 X자 대각선이 그려져 있다.
- 기물(각 진영 16개):
| 기물 | 초 / 한 | 수 | 점수 |
|---|---|---|---|
| 궁(장) | 楚 / 漢 | 1 | — |
| 차 | 車 | 2 | 13 |
| 포 | 包 | 2 | 7 |
| 마 | 馬 | 2 | 5 |
| 상 | 象 | 2 | 3 |
| 사 | 士 | 2 | 3 |
| 졸 / 병 | 卒 / 兵 | 5 | 2 |
기물 점수 합 72점. 후수(한)에게 덤 1.5점 → 초 72, 한 73.5로 시작.
- 내부 좌표: `(f, r)`, `f` 0~8(초 기준 왼→오), `r` 0~9(초의 뒷줄 0, 한의 뒷줄 9).
- 초 궁성: `f` 3~5, `r` 0~2, 중심 `(4,1)`. 대각선: `(3,0)–(4,1)–(5,2)`, `(5,0)–(4,1)–(3,2)`.
- 한 궁성: `f` 3~5, `r` 7~9, 중심 `(4,8)`. 대각선: `(3,7)–(4,8)–(5,9)`, `(5,7)–(4,8)–(3,9)`.
## 4. 준비(셋업)
1. `sideAssignment`로 초(선수)·한(후수) 결정. 초가 먼저 둔다.
2. 고정 배치(초 기준, 한은 `r → 9 - r`로 대칭):
- 궁 `(4,1)` (궁성 중심), 사 `(3,0)`, `(5,0)`, 차 `(0,0)`, `(8,0)`, 포 `(1,2)`, `(7,2)`, 졸 `(0,3) (2,3) (4,3) (6,3) (8,3)`.
- 한: 궁 `(4,8)`, 사 `(3,9)` `(5,9)`, 차 `(0,9)` `(8,9)`, 포 `(1,7)` `(7,7)`, 병 `(0,6) (2,6) (4,6) (6,6) (8,6)`.
3. 상차림: 마·상 4개를 뒷줄의 차와 사 사이 두 칸씩(왼쪽 2칸, 오른쪽 2칸)에 놓는다. 각 플레이어가 자기 시점에서 왼쪽→오른쪽으로 4가지 중 선택:
| 선택 | 자기 시점 왼→오 | 별칭 |
|---|---|---|
| `MSSM` | 마 상 · 상 마 | 안상차림, 원앙마 |
| `SMMS` | 상 마 · 마 상 | 바깥상차림, 양귀마 |
| `SMSM` | 상 마 · 상 마 | 왼상차림(귀마) |
| `MSMS` | 마 상 · 마 상 | 오른상차림(귀마) |
- 초의 왼쪽은 `f` 작은 쪽 → 칸 `(1,0) (2,0) (6,0) (7,0)`. 한은 판 반대편에 앉아 있으므로 한의 "왼쪽"은 `f` 큰 쪽 → 칸 `(7,9) (6,9) (2,9) (1,9)` 순서로 대응.
- `setupOrder=hanFirst`: 한이 먼저 선택(즉시 공개) → 초가 보고 선택. `simultaneous`: 둘 다 비공개로 선택, 둘 다 확정되면 동시에 공개.
4. 시계 초기화, 반복 국면 카운트에 초기 국면(초 차례) 1회 기록.
## 5. 진행 규칙
### 5.1 턴
- 초부터 번갈아 한 수씩: 기물 하나 이동(이동 칸에 상대 기물이 있으면 잡음) 또는 한수쉼(`allowPass`, 장군 상태가 아닐 때만).
- 자기 기물이 있는 칸으로는 이동 불가. 판 밖 이동 불가.
- 이동 후 자기 궁이 상대 기물의 공격을 받게 되는 수는 불법(장군을 피하지 않는 수, 스스로 장군에 들어가는 수 모두 포함).
### 5.2 기물별 행마
- 궁, 사: 궁성 안에서만, 궁성의 선을 따라 1칸. 가로·세로 1칸, 그리고 대각선이 그려진 곳(모서리↔중심)에서만 대각 1칸. 즉 모서리 4곳·중심은 대각 가능, 변의 가운데 4곳(`(4,0) (3,1) (5,1) (4,2)` 등)에서는 대각 불가.
- 차: 가로·세로로 막히지 않는 한 몇 칸이든. 궁성 안(양쪽 궁성 모두)에서는 대각선을 따라서도 이동: 모서리→중심→반대 모서리까지(중심이 비어 있어야 반대 모서리로 감), 중심→모서리 1칸.
- 포: 차와 같은 선(가로·세로, 궁성 대각선)을 따라 움직이되, 반드시 정확히 1개의 기물(포다리)을 뛰어넘어야 한다.
- 포다리는 피아 구분 없이 아무 기물이나 되지만 포(어느 편이든)는 포다리가 될 수 없다.
- 포다리 너머 첫 기물 앞까지의 빈칸 어디든 이동 가능, 또는 포다리 너머 첫 기물이 상대 기물이면 잡을 수 있다. 단 포는 포를 잡을 수 없다.
- 포다리 없이는 한 칸도 움직일 수 없다.
- 궁성 대각선: 모서리에 있는 포는 중심에 포가 아닌 기물이 있으면 그것을 넘어 반대 모서리로 갈 수 있다(빈칸이면 이동, 상대 비-포 기물이면 잡기).
- 마: 가로 또는 세로 1칸 간 뒤 그 방향으로 대각선 1칸(목적지 8곳: `(±1,±2) (±2,±1)`). 첫 1칸(멱)에 기물이 있으면 그 방향으로는 갈 수 없다. 궁성 대각선과 무관.
- 상: 가로 또는 세로 1칸 간 뒤 그 방향으로 대각선 2칸(목적지 8곳: `(±2,±3) (±3,±2)`). 경로의 첫 칸(직선 1칸)과 둘째 칸(대각 1칸째)이 모두 비어 있어야 한다. 예: `(f,r)`에서 위로 가는 상 → 멱 `(f,r+1)`, `(f±1,r+2)`, 목적지 `(f±2,r+3)`.
- 졸(초)/병(한): 앞 또는 옆으로 1칸. 뒤로는 못 간다. 상대 궁성 안에서는 앞쪽 대각선을 따라 1칸 추가 가능: 초 졸은 `(3,7)→(4,8)`, `(5,7)→(4,8)`, `(4,8)→(3,9)`, `(4,8)→(5,9)`; 한 병은 `(3,2)→(4,1)`, `(5,2)→(4,1)`, `(4,1)→(3,0)`, `(4,1)→(5,0)`. 마지막 줄에서는 옆으로만 이동.
- 모든 기물은 승급 없음.
### 5.3 장군·멍군·외통
- 장군: 다음 수에 상대 궁을 잡을 수 있는 상태(포다리를 이용한 포의 장군 포함). 장군을 받은 쪽은 반드시 피해야 하며 한수쉼 불가.
- 멍군: 장군을 막아낸 응수(가리기, 공격 기물 잡기, 궁 피하기). UI 표시용 용어이며 규칙상 별도 효과 없음.
- 외통(체크메이트): 장군 상태에서 합법 수가 없음 → 패배. 궁을 실제로 잡지는 않는다.
- 장군 상태가 아닌데 합법 이동이 없으면 한수쉼(옵션이 꺼져 있어도 이 경우 자동 한수쉼 처리).
### 5.4 빅장
- 정의: 두 궁이 같은 세로줄(`f` 같음)에 있고 그 사이에 기물이 하나도 없는 상태. 장군이 아니다.
- 빅장 성립(부르기): 어떤 수의 결과로 빅장 상태가 되고, 그 시점에 `bikjang` 조건이 충족되면 그 수를 둔 쪽이 빅장을 부른 것이다.
- `kjf`: 이전 수들 중 잡기가 한 번 이상 있었다(첫 잡기가 일어난 그 수 자체로는 부를 수 없고, 그 다음 수부터).
- `kja30`: 그 수를 둔 직후 양쪽 기물 점수(덤 제외)가 모두 30점 미만.
- 조건이 충족되지 않으면 궁이 마주봐도 아무 일도 없다(합법이며 빅장 아님).
- 응수: 빅장을 받은 쪽은 한 수를 둔다. 그 수 후에도 두 궁이 여전히 마주보고 있으면(한수쉼, 무관한 수, 잡기만 하고 막지 않은 수 포함) 빅 → 즉시 종료: `scoring=true`면 남은 기물 점수로 판정, `false`면 무승부. 마주보지 않게 만들면(사이에 기물을 넣거나 궁을 옆으로) 대국 계속.
- 빅장을 부르는 수가 동시에 장군이 될 수는 없다(궁끼리는 장군 관계가 아님). 다른 기물로 장군을 건 상태라면 상대는 장군을 먼저 해결해야 하며, 그 응수 후에도 마주보면 빅.
- `bikjang=off`: 마주보기는 아무 효과 없음(카카오 장기 등 온라인 일부 방식).
### 5.5 반복수
- 국면 = 판 배치 + 둘 차례. 같은 국면이 3번까지는 허용, 4번째로 나타나는 경우:
- `forbid`(기본): 4번째 국면을 만드는 수는 둘 수 없다("같은 수를 너무 많이 반복해서 둘 수 없어요"). 예외: 장군을 받았는데 그 수 말고 합법 수가 없으면 허용하고 즉시 점수 판정(`scoring=false`면 무승부).
- `lose`: 4번째 국면을 만든 쪽 실격패(KJF 규정 "4회 반복 시 실격패").
- `scoreEnd`: 4번째 국면이 나오면 점수 판정 종료.
- 한수쉼으로 생긴 국면도 같은 방식으로 센다(차례만 바뀌므로 별도 국면).
### 5.6 한수쉼 연속
- 양쪽이 연속으로 한수쉼 → 종료: `scoring=true`면 점수 판정, 아니면 무승부.
## 6. 승패와 점수 계산
- 완승: 외통, 상대 기권, 상대 시간패, 상대 실격(`repetition=lose`).
- 점수 판정이 일어나는 경우(`scoring=true`): 빅장 성립, 양쪽 연속 한수쉼, 수 제한 도달, `repetition=scoreEnd`, `forbid`의 예외 상황.
- 점수 = 판에 남은 자기 기물 점수 합(차 13, 포 7, 마 5, 상 3, 사 3, 졸·병 2, 궁 0) + 한에게 덤 1.5.
- 덤이 0.5 단위라 동점은 나오지 않는다. 높은 쪽 승.
- 예시: 빅 성립 시 초에 차2·포1·마1·사2·졸3 = 26+7+5+6+6 = 50, 한에 차1·포2·마2·상1·사2·병4 = 13+14+10+3+6+8 = 54 + 1.5 = 55.5 → 한 5.5점 승("한 판정승 55.5 : 50").
- `scoring=false`면 위 판정 상황은 모두 무승부.
- 무승부 합의도 가능.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 점수제/비점수제 × 빅장 있음/없음의 4가지 대국 방식이 실제로 공존한다. 기본은 정식 대회에서 주로 쓰는 "점수제 + 빅장".
- 빅장 조건: KJF(기물 1개 이상 잡힌 뒤) / 대한장기협회(KJA, 양쪽 30점 미만) / 없음.
- 반복수: 기본은 "막기"(어린이에게 실격패보다 친절), 공식처럼 실격패도 선택 가능.
- 상차림 순서: KJF는 후수(한)가 먼저 차린다. 친선에서는 각자 동시에 정하는 경우가 많아 옵션 제공.
- 접장기(실력 차 보정으로 차·포 등을 떼고 두기), 기물 점수 변형은 v1 미지원(추후 `handicapRemove` 옵션 후보).
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Side = 'cho' | 'han';
type PieceKind = 'king' | 'cha' | 'po' | 'ma' | 'sang' | 'sa' | 'jol';
type Piece = { side: Side; kind: PieceKind };
type Sq = { f: number; r: number };
type Arrangement = 'MSSM' | 'SMMS' | 'SMSM' | 'MSMS';
interface JanggiState {
options: JanggiOptions;
players: Record<Side, PlayerId>;
phase: 'setup' | 'playing' | 'finished';
setup: { choice: Record<Side, Arrangement | null> }; // simultaneous면 상대 값은 view에서 숨김
board: (Piece | null)[]; // index = r*9 + f
turn: Side;
moves: ({ kind: 'move'; side: Side; from: Sq; to: Sq; captured: PieceKind | null }
| { kind: 'pass'; side: Side })[];
firstCapturePly: number | null; // 첫 잡기가 일어난 수 번호
positionCounts: Record<string, number>; // 배치+차례 -> 횟수
bikjang: { calledBy: Side; ply: number } | null; // 응수 대기 중인 빅장
consecutivePasses: number;
inCheck: Side | null;
clock: { mainMs: Record<Side, number>; periodsLeft: Record<Side, number>; turnStartedAt: number };
undo: { usedBy: Record<Side, number>; pending: { by: Side; plies: 1 | 2 } | null };
draw: { pendingBy: Side | null; lastOfferPly: Record<Side, number> };
result: JanggiResult | null;
rng: RngState;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `chooseSetup` | `{ arrangement }` | `setup` 단계. `hanFirst`: 한이 먼저, 그다음 초 / `simultaneous`: 둘 다 | 아직 선택 안 함, 순서 맞음. 둘 다 정해지면 배치 확정, `playing` 진입, 초 차례 |
| `move` | `{ from: Sq, to: Sq }` | 차례인 플레이어, `playing` | `from`에 자기 기물, `to`가 5.2 행마로 도달 가능, 자기 기물 없음, 포는 포다리 조건(포 넘기·포 잡기 금지), 이동 후 자기 궁이 공격받지 않음, 반복수 `forbid` 위반 아님. 사유 예: "포는 포를 넘을 수 없어요", "마의 길(멱)이 막혀 있어요", "장군을 피해야 해요", "궁은 궁성 밖으로 나갈 수 없어요" |
| `pass` | `{}` | 차례인 플레이어 | `allowPass` 또는 합법 이동 없음, 장군 상태 아님("장군을 받은 상태에서는 쉴 수 없어요"), 반복수 위반 아님 |
| `requestUndo` / `respondUndo` | `{}` / `{ accept }` | 플레이어 / 상대 | 횟수 남음, 대기 없음. 수락 시 `moves` 재생으로 판·`positionCounts`·`firstCapturePly`·`bikjang` 재구성, 시계 유지 |
| `offerDraw` / `respondDraw` / `cancelOffer` | | 플레이어 | 거절 후 본인 5수 이후 재제안 |
| `resign` | `{}` | 플레이어 | `setup` 중이면 결과 없이 취소 |
| `timeout` | `{}` | 시스템 | 8.5 |
- `move`/`pass` 적용 후 처리 순서:
1. 시계 차감(초읽기 규칙은 바둑과 동일: 기본 시간 → 초읽기 회차, 회차 초과 시 소진, 0이면 시간패로 액션 무효).
2. 판 갱신, 잡기면 `firstCapturePly` 설정(처음일 때).
3. 응수 대기 중인 빅장이 있고 지금 둔 사람이 응수자라면: 여전히 마주봄 → 빅(종료). 아니면 `bikjang = null`.
4. 상대 외통 판정 → 승리.
5. 새로 빅장 상태가 되었고 조건 충족 → `bikjang = { calledBy: 둔 사람 }`, 이벤트 "빅장".
6. 반복 카운트 증가, `lose`/`scoreEnd` 처리, 연속 한수쉼 처리, `moveLimit` 처리.
7. 장군 여부 이벤트(`janggun`), 직전 장군 해소면 `meonggun` 이벤트.
- 공격 판정 함수 `attacks(board, side, sq)`: 차(직선·궁성 대각), 포(포다리 1개, 포 제외), 마·상(멱 확인), 졸(앞·옆·궁성 대각 앞), 사·궁(궁성 안 인접 선) 모두 포함. 궁끼리 마주봄은 공격이 아니다.
### 8.3 공개/비공개 정보 (view)
- 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과.
- `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개.
- 차례인 플레이어에게만: `legalMoves`(하이라이트용), 힌트 옵션 시 `threatened`(잡힐 위험 기물).
- 제외: `rng`, 상대의 미공개 상차림.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `sideAssignment=random`의 초·한 결정.
- 상차림 시간 초과 시 자동 선택(4가지 중 균등 무작위).
- 대국 중에는 사용하지 않는다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`:
- `setup`: 단계 시작 + 60초(선택해야 할 사람 기준).
- `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn]*periodSec*1000`.
- `playing` + `none`: `turnStartedAt + idleLimitSec*1000`.
- 종료: `null`.
- `activePlayers`: `setup`에서 `hanFirst`면 순서에 맞는 1명, `simultaneous`면 아직 선택 안 한 전원. `playing`이면 차례인 1명.
- `onTimeout(state, player)`:
- `setup`: `{ type: 'timeout' }` → `apply`가 시드 RNG로 4가지 중 하나를 골라 `chooseSetup`과 같은 처리(`onTimeout`은 RNG를 쓰지 않는 순수 함수로 유지).
- `playing`: `{ type: 'timeout' }` → 시간패. 장기는 자동 수를 두지 않는다.
- 연결 끊김: 끊긴 플레이어가 행동해야 할 때 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout`.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface JanggiResult {
ranking: { playerId: PlayerId; rank: 1 | 2 }[]; // 무승부면 둘 다 1, 셋업 중 취소면 빈 배열
winner: Side | null;
reason: 'checkmate' | 'resign' | 'timeLoss' | 'repetitionLoss'
| 'scoreBikjang' | 'scorePasses' | 'scoreMoveLimit' | 'scoreRepetition'
| 'drawBikjang' | 'drawPasses' | 'drawRepetition' | 'drawMoveLimit' | 'drawAgreed' | 'aborted';
scores: { cho: number; han: number }; // 항상 기록(한은 덤 포함)
moveCount: number;
summaryKo: string; // "초 외통승 (63수)", "한 판정승 55.5 : 50 (빅장)"
}
```
## 9. UI/UX
- 판 방향: 내 진영이 아래. 초는 파랑(또는 초록), 한은 빨강. 기물은 팔각형(차·포·마·상 큰 것, 사·졸 작은 것) 한자 + 작은 한글 라벨 옵션("車 차")으로 한자에 익숙하지 않은 어린이 배려.
- 모바일 세로: 판(9×10)이 세로로 길어 세로 화면에 잘 맞는다. 폰 360px 폭에서 교차점 간격 약 38px이므로 기물 터치 영역을 교차점 간격 전체(정사각형)로 잡고, 선택 시 확대 강조.
- 조작: 기물 탭 → 이동 가능 점 표시(잡기 가능 칸은 빨간 링) → 탭. 포는 포다리 기물을 살짝 강조해 이유가 보이게.
- 상차림 화면: 4개 큰 카드(그림 + 이름 "안상차림 / 원앙마"), 한 화면에 미리보기.
- "장군!" "멍군!" "빅장!" 큰 말풍선과 효과음(목소리 옵션). 외통 시 결과 모달.
- 상단에 양측 점수 바(초 72 : 한 73.5) 상시 표시 → 판정 상황 이해 도움.
- 한수쉼 버튼은 확인 모달. 반복 경고 배너.
- 규칙 보기: 기물별 행마 애니메이션, 멱(막힘) 예시, 포다리 예시, 빅장 설명.
## 10. 테스트 체크리스트
- [ ] 초기 배치 4가지 상차림 × 초/한 시점 매핑이 정확(비대칭인 `SMSM` 기준: 초 → `(1,0)` 상, `(2,0)` 마, `(6,0)` 상, `(7,0)` 마 / 한 → `(7,9)` 상, `(6,9)` 마, `(2,9)` 상, `(1,9)` 마).
- [ ] 마·상 멱: 첫 칸이 막히면 해당 방향 2곳 불가, 상은 둘째 칸 막힘도 확인.
- [ ] 포: 포다리 없이 이동 불가, 포를 포다리로 사용 불가, 포를 잡기 불가, 포다리 너머 여러 빈칸 이동 가능, 궁성 대각선 넘기.
- [ ] 차의 궁성 대각 이동: 모서리→반대 모서리(중심 비었을 때), 중심 막혔을 때 불가.
- [ ] 궁·사가 변 가운데 점에서 대각 이동 불가, 궁성 밖 이동 불가.
- [ ] 졸: 뒤로 불가, 상대 궁성 대각 앞 이동 가능, 자기 궁성에서 대각 불가.
- [ ] 스스로 장군에 들어가는 수 거부, 포다리 역할 기물을 치워 포 장군에 노출되는 수 거부(핀).
- [ ] 장군 중 한수쉼 거부, 외통 판정.
- [ ] 빅장(`kjf`): 잡기 전엔 마주봐도 무효, 첫 잡기 수 자체로는 불가, 그 이후 성립. 응수로 막으면 계속, 못 막으면 점수 판정(덤으로 동점 없음).
- [ ] 빅장(`kja30`): 한쪽이라도 30점 이상이면 무효.
- [ ] 반복수 `forbid`: 4번째 국면을 만드는 수 거부, 장군 중 유일한 수면 허용 후 판정 종료. `lose`: 실격패.
- [ ] 연속 한수쉼 → 점수 판정 / 무승부.
- [ ] 초읽기 시간패, 상차림 시간 초과 시 RNG 자동 선택이 같은 시드에서 동일.
- [ ] `simultaneous` 상차림 중 상대·관전자 view에 선택값 누출 없음(선택 완료 여부만).
- [ ] 무르기 수락 시 `firstCapturePly`, `bikjang`, 반복 카운트 복원.
- [ ] view에 `rng` 없음, 관전자에게 `legalMoves` 없음.
## 11. 참고 자료
- (사)대한장기연맹 장기규정/규칙: https://kojf.net/theme/sample30/html/content03.php (점수·덤 1.5, 판정, 빅장은 기물을 하나라도 취한 후 다음 수부터, 반복수 3회 허용·4회 실격패, 후수가 먼저 상차림, 한수쉼)
- 대한장기연맹 장기규칙 개정 공지(2019.12.31): http://kojf.net/post/478
- 대한장기협회(KJA): http://www.kja.or.kr/business/business5.php (점수제 빅장은 양측 30점 미만일 때)
- 위키백과 「장기」: https://ko.wikipedia.org/wiki/%EC%9E%A5%EA%B8%B0
- 나무위키 「장기/기물 및 행마법」: https://namu.wiki/w/%EC%9E%A5%EA%B8%B0/%EA%B8%B0%EB%AC%BC%20%EB%B0%8F%20%ED%96%89%EB%A7%88%EB%B2%95 , 「장기/용어」: https://namu.wiki/w/%EC%9E%A5%EA%B8%B0/%EC%9A%A9%EC%96%B4
- 상차림 명칭(원앙마·양귀마·귀마): https://brunch.co.kr/@1305b8c2c18141c/529 , https://ko.wikibooks.org/wiki/%EC%9E%A5%EA%B8%B0/%EC%B4%88%EB%B0%98_%ED%8F%AC%EC%A7%84%EB%B2%95
- 점수제/빅장 대국 방식 4종 정리: https://gall.dcinside.com/mgallery/board/view/?id=janggi&no=10449
- Janggi 영문 규칙 요약: https://janggionline.com/how-to-play/ , https://en.wikipedia.org/wiki/Janggi
## 12. 메모 (상표·법적 주의 등)
- "장기"는 전통 게임 명칭으로 상표 문제 없음. 기물 그림·폰트는 자체 제작 또는 무료 라이선스 폰트 사용.
- 단체마다 세부 규칙이 달라 "대한장기연맹 규정 기반, 일부 온라인 방식 적용"이라고 규칙 보기에 명시.
- 금전·베팅 요소 없음.

174
docs/games/las-vegas.md Normal file
View File

@@ -0,0 +1,174 @@
# 라스베가스 주사위 (`las-vegas`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 5명 · 예상 시간: 약 20~30분 · 난이도: 쉬움
## 1. 개요
- 각자 주사위 8개를 굴려, 나온 눈 하나를 골라 그 눈의 주사위를 모두 같은 번호(1~6) 카지노에 놓는다. 라운드가 끝나면 카지노마다 주사위가 가장 많은 사람부터 높은 지폐를 가져가는데, 개수가 같은 사람끼리는 모두 무효가 된다. 4라운드 뒤 돈이 가장 많은 사람이 이긴다.
- Rüdiger Dorn의 보드게임(2012년, alea/Ravensburger)으로, 한국 보드게임 카페에서 "라스베가스"라는 이름으로 잘 알려진 가족용 주사위 게임이다. 규칙이 짧고 동점 무효 규칙 덕분에 역전이 잦아 어린이·어르신이 함께 하기 좋다.
- 인원 근거: 원작 규칙서 기준 2~5명(색깔별 주사위 5세트). 2~4명은 중립(흰색) 주사위 변형 규칙이 공식 규칙서에 포함돼 있다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `rounds` | 라운드 수 | 3, 4, 5 | 4 |
| `neutralDice` | 중립(흰) 주사위 변형 | `auto`(2명일 때만 사용, 1인당 4개), `on`(2명 1인당 4개, 3~4명 1인당 2개), `off` | `auto` |
| `hideMoney` | 다른 사람이 번 돈 금액을 숨김(원작: 지폐를 뒤집어 둠) | `true`(장 수만 공개), `false`(금액 공개) | `true` |
| `turnSeconds` | 한 차례(굴리기+놓기) 제한 시간 | 20, 30, 60, `null` | 30 |
옵션 검증: 5명일 때 `neutralDice = on`은 허용하지 않는다(흰 주사위가 5번째 색이라 남지 않음). `auto`는 5명에서 자동으로 꺼진다.
## 3. 구성물
- 주사위 40개: 5색 × 8개. 2~4명이면 쓰지 않는 색 1세트(8개)를 "중립(흰색)" 주사위로 쓴다.
- 카지노 6곳: 1~6번(원작은 실제 카지노 이름이 붙어 있으나 우리는 "1번 카지노"~"6번 카지노" 또는 자체 이름을 쓴다).
- 지폐 54장(단위: 만 달러, 게임 안 가상 돈):
| 금액 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 장 수 | 6 | 8 | 8 | 6 | 6 | 5 | 5 | 5 | 5 |
합계 54장, 총 250만 달러.
## 4. 준비(셋업)
1. 지폐 54장을 섞어 비공개 더미(`pile`)를 만든다.
2. 카지노 1번부터 6번까지 순서대로, 더미 위에서 한 장씩 꺼내 그 카지노에 놓되 그 카지노의 지폐 합이 5(=5만 달러) 이상이 될 때까지 계속 놓는다. 카지노의 지폐는 금액 내림차순으로 정렬해 모두 공개한다.
3. 각 플레이어에게 자기 색 주사위 8개.
4. 중립 주사위(옵션 적용 시): 2명이면 각자 4개, 3~4명이면 각자 2개를 자기 주사위와 별도로 받는다. 3명이면 남는 2개는 매 라운드 시작 시 그 라운드 선 플레이어가 굴려, 각 주사위를 나온 눈의 카지노에 바로 놓는다(선택 없음).
5. 1라운드 선 플레이어는 RNG로 정한다(원작: 가장 나이 많은 사람). 이후 라운드마다 선은 왼쪽(다음 좌석)으로 넘어간다.
## 5. 진행 규칙
- 차례는 선 플레이어부터 좌석 순(시계 방향).
- 내 차례:
1. `ROLL`: 남은 내 주사위와 남은 내 중립 주사위를 모두 함께 굴린다.
2. `PLACE(value)`: 나온 눈 중 하나(`value`)를 고르고, 그 눈이 나온 주사위를 색에 상관없이 "모두" `value`번 카지노에 놓는다. 일부만 놓을 수 없다. 중립 주사위에만 나온 눈을 골라도 된다(그러면 중립 주사위만 놓임).
3. 이미 다른 사람 주사위가 있는 카지노에도 놓을 수 있다.
- 주사위가 하나도 남지 않은 사람은 이후 차례를 건너뛴다(라운드 끝 무렵 한 사람이 연속으로 차례를 가질 수 있음).
- 모든 사람이 주사위를 다 놓으면 라운드 정산:
1. 카지노마다 "주인"별 주사위 수를 센다(주인 = 각 플레이어, 그리고 중립 주사위 전체를 하나의 가상 플레이어 "하우스"로 취급).
2. 동점 제거: 주사위 수가 같은 주인이 2명 이상이면 그 주인들의 주사위를 모두 제거(그 카지노에서 아무것도 못 받음). 하우스도 동점 판정에 포함된다.
3. 남은 주인을 주사위 수 내림차순으로 줄 세우고, 지폐를 금액 내림차순으로 1:1로 준다. 지폐가 모자라면 뒤쪽 사람은 못 받는다.
4. 하우스가 받은 지폐, 그리고 아무도 받지 않고 남은 지폐는 더미 맨 아래에 넣는다(카지노 1→6 순, 각 카지노 안에서는 높은 금액부터).
5. 받은 지폐는 그 플레이어 앞에 놓는다(`hideMoney`면 금액 비공개).
- 정산 후 다음 라운드: 주사위를 모두 돌려받고, 4장 2번 방식으로 카지노에 다시 지폐를 채우고, 선을 다음 사람에게 넘긴다.
- 더미가 바닥나면(사실상 발생하지 않음) 그 카지노는 있는 만큼만 받은 채로 진행한다. 지폐가 0장인 카지노에 주사위를 놓는 것도 허용된다.
### 5.1 정산 예시(원작 규칙서 예시 재구성)
- 카지노 지폐 [8, 3, 1]. 주사위: A 5개, B 3개, C 3개, D 1개 → B·C 동점 제거. A가 8, D가 3을 받고 1은 더미 맨 아래로.
- 카지노 지폐 [4, 4]. A 2개, C 2개, B 1개, D 1개 → A·C 제거, B·D 제거. 아무도 못 받고 두 장 모두 더미 아래로.
- 카지노 지폐 [7, 2]. C 4개, B 2개, D 1개 → C 7, B 2, D 없음.
- 중립 예시(2명): 카지노 지폐 [9]. 하우스 3개, A 2개, B 1개 → 하우스가 9를 가져가 더미 아래로. A·B 모두 0.
## 6. 승패와 점수 계산
- `rounds` 라운드 후 각자 받은 지폐 금액 합계가 점수.
- 동점이면 지폐 장 수가 많은 사람이 이긴다. 그래도 같으면 공동 순위.
- 결과 화면에서 모두의 지폐를 공개한다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 중립 주사위 변형(`neutralDice`): 원작 규칙서의 "2~4인 변형". 2명 게임은 중립 주사위 없이는 동점 무효가 거의 일어나지 않아 단조로우므로 기본으로 켠다(`auto`).
- 라운드 수(`rounds`): 짧은 판을 원하면 3.
- 돈 공개(`hideMoney=false`): 어린이 판이나 처음 하는 사람을 위해 금액을 공개.
- 지원하지 않는 변형(문서화만): 확장 "라스베가스 불러바드"의 큰 주사위·7번째 카지노·보너스 카드, "돈이 가장 적은 사람이 이김" 같은 반대 규칙.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Owner = PlayerId | 'house';
type Note = number; // 1..9 (만 달러)
interface LasVegasState {
options: LasVegasOptions;
rng: RngState;
seats: PlayerId[];
round: number; // 1..rounds
startSeat: number; // 이번 라운드 선
current: number; // 현재 차례 좌석
phase: 'roll' | 'place' | 'finished';
pile: Note[]; // 비공개 더미(0번 = 맨 위)
casinos: { value: 1|2|3|4|5|6; notes: Note[] /* 내림차순 */; dice: Partial<Record<Owner, number>> }[];
hand: Record<PlayerId, { own: number; neutral: number }>; // 남은 주사위 수
lastRoll: { own: number[]; neutral: number[] } | null; // 현재 차례 굴림 결과
money: Record<PlayerId, Note[]>; // 받은 지폐
roundResults: { round: number; payouts: { casino: number; removedTies: Owner[]; awards: { owner: Owner; note: Note }[]; returned: Note[] }[] }[];
turnSeq: number;
deadlineAt: number | null;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `ROLL` | 없음 | 현재 차례 사람, `phase='roll'` | 남은 주사위(own+neutral) ≥ 1. 위반: "지금은 굴릴 차례가 아니에요." |
| `PLACE` | `{ value: 1..6 }` | 현재 차례 사람, `phase='place'` | `lastRoll`(own 또는 neutral)에 `value`가 1개 이상. 위반: "그 숫자는 나오지 않았어요." |
| `CONCEDE` | 없음 | 참가자 | 남은 주사위를 버리고(놓지 않음) 이후 차례 제외. 이미 받은 돈은 결과에 유지. 남은 참가자가 1명이면 즉시 종료 |
- `PLACE` 처리 후 다음 차례: 좌석 순으로 남은 주사위가 있는 다음 사람. 아무도 없으면 정산 → 다음 라운드 셋업 또는 종료.
- 3명 + 중립 사용 시 라운드 시작(셋업 직후 포함)에 선 플레이어 이름으로 남는 중립 주사위 2개를 자동 굴려 놓는다(이벤트 `houseDicePlaced`).
- 이벤트: `rolled{player,own,neutral}`, `placed{player,value,ownCount,neutralCount}`, `tiesRemoved{casino,owners}`, `paid{casino,owner,note}`, `notesReturned`, `casinosRefilled`, `roundEnd`, `gameOver`.
### 8.3 공개/비공개 정보 (view)
- 모두에게 공개: 카지노별 지폐(금액)와 주인별 주사위 수, 각자 남은 주사위 수(own/neutral), 현재 굴림 결과, 라운드·선·차례, 정산 내역(누가 몇 번 카지노에서 받았는지).
- 단 `hideMoney=true`이면 정산 내역의 지폐 금액은 받은 본인에게만 보이고, 다른 사람에게는 "1장 받음"으로만 보인다. 하우스가 받은 지폐와 남은 지폐 금액은 공개(카지노에 공개돼 있던 것이므로).
- `hideMoney=true`: 다른 플레이어의 `money`는 장 수만, 본인은 금액 전체. 관전자는 장 수만.
- 참고: 카지노 지폐는 공개 상태에서 분배되므로 주의 깊게 보면 추적이 가능하다(원작도 같음). 이 옵션은 "기억력 요소"를 살리는 장치이며 UI는 기록 보조를 주지 않는다.
- 비공개: `pile` 순서와 장 수 외 내용(남은 장 수만 공개), `rng`.
- 게임 종료 후 결과에서는 모두 공개.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 셋업: 지폐 더미 섞기(Fisher–Yates), 1라운드 선 결정.
- `ROLL`: 자기 주사위 → 중립 주사위 순서로 각각 `1 + floor(rng()*6)`.
- 3명 중립 사용 시 라운드 시작 하우스 주사위 2개 굴림.
- 라운드 간 더미는 다시 섞지 않는다(원작대로 돌려받은 지폐는 맨 아래).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: `phase` 진입 시 `now + turnSeconds`(굴리기와 놓기 각각).
- `onTimeout`:
- `roll` → `ROLL`.
- `place` → 결정적 선택: 나온 눈 중 (1) 해당 눈 주사위(own+neutral) 수가 가장 많은 것, (2) 동률이면 그 카지노 최고 지폐가 큰 것, (3) 그래도 같으면 작은 숫자.
- 끊긴 사람은 5초 유예 후 자동 행동. 3번 연속 자동이면 "자리 비움" 표시.
### 8.6 종료 조건과 결과(GameResult)
- 마지막 라운드 정산 후 종료.
```ts
interface LasVegasResult {
ranking: { rank: number; player: PlayerId; total: number /* 만 달러 */; notes: Note[] }[];
winners: PlayerId[];
roundTotals: Record<PlayerId, number[]>; // 라운드별 획득액
summary: string; // 예: "지수 승리! 47만 달러 (8장)"
}
```
## 9. UI/UX
- 모바일 세로: 위쪽에 카지노 6곳을 2열 × 3행 카드로(각 카드에 큰 숫자 1~6, 지폐 금액, 색별 주사위 개수 막대), 아래에 내 굴림 결과 영역과 "굴리기" 버튼. PC: 카지노 6곳을 가로 한 줄.
- 조작: 굴린 뒤 같은 눈끼리 자동으로 묶여 "3 × 3개", "5 × 2개(흰 1 포함)"처럼 큰 버튼으로 표시. 버튼을 누르면 해당 카지노가 강조되고 "놓은 뒤 예상 순위"(현재 1등/동점 위험)가 미리 보인 뒤 확정.
- 하이라이트: 놓으면 동점이 되는 카지노는 노란 "동점 주의", 단독 1등이 되면 초록 "1등!".
- 애니메이션: 주사위 굴림, 주사위가 카지노로 날아감, 정산 때 동점 주사위가 "펑" 사라지고 지폐가 승자에게 날아감.
- 초보자 도움말: "같은 숫자는 한꺼번에", "개수가 같으면 둘 다 꽝" 두 문장을 첫 판에 크게 안내.
- 지폐는 실제 화폐와 다른 디자인(알록달록한 놀이용 지폐)으로, "만 달러" 대신 "포인트" 표기 옵션(UI 설정).
## 10. 테스트 체크리스트
- [ ] 셋업: 지폐 54장 구성(6·8·8·6·6·5·5·5·5), 각 카지노 합 ≥ 5, 그리고 마지막 한 장을 빼면 5 미만(필요 이상으로 놓지 않음).
- [ ] `PLACE`는 그 눈의 주사위를 모두 옮긴다(일부만 불가). 나오지 않은 눈 거부.
- [ ] 중립 주사위에만 나온 눈 선택 시 중립 주사위만 놓인다.
- [ ] 정산 예시 4개(5.1)를 그대로 재현해 결과 일치.
- [ ] 하우스와 플레이어 동점 → 둘 다 제거.
- [ ] 하우스가 받은 지폐와 남은 지폐가 정해진 순서로 더미 맨 아래에 들어간다.
- [ ] 주사위가 없는 사람은 차례 건너뜀, 한 사람만 남으면 연속 차례.
- [ ] 라운드마다 선이 다음 좌석으로 이동, 4라운드 후 종료.
- [ ] 3명 + `neutralDice=on`: 라운드 시작 시 하우스 주사위 2개 자동 배치.
- [ ] 5명 + `neutralDice=on` 옵션 검증 거부.
- [ ] 최종 동점: 금액 같으면 장 수 많은 쪽 승, 그것도 같으면 공동 우승.
- [ ] 시간 초과: 굴리기 자동, 놓기는 개수 최다 눈 선택.
- [ ] 연결 끊김 사용자가 있어도 라운드가 끝까지 진행.
- [ ] 정보 유출: `hideMoney=true`에서 다른 플레이어·관전자 View에 금액이 없음(장 수만). View에 `pile` 내용·`rng` 없음.
- [ ] 리플레이 결정성: 같은 시드·액션 열 → 같은 최종 금액.
## 11. 참고 자료
- 원작 규칙서(영문, Ravensburger/alea, Rüdiger Dorn): https://cdn.1j1ju.com/medias/d1/8e/c6-las-vegas-rulebook.pdf (구성물·지폐 분포·동점 제거·2~4인 중립 주사위 변형·동점 시 지폐 장 수)
- Yucata 규칙(중립 주사위 세부: 중립만 나온 눈 선택 가능, 하우스 획득 지폐는 더미 아래): https://www.yucata.de/en/Rules/LasVegas
- Wikipedia "Las Vegas (board game)": https://en.wikipedia.org/wiki/Las_Vegas_(board_game)
- 나무위키 "라스베가스(보드게임)": https://namu.wiki/w/라스베가스(보드게임)
- BoardGameGeek "Las Vegas": https://boardgamegeek.com/boardgame/117959/las-vegas
## 12. 메모 (상표·법적 주의 등)
- "Las Vegas"는 게임 제목으로 상표 등록돼 있을 수 있고(alea/Ravensburger), 원작 카지노 이름(실제 카지노 상호)은 상표다. 화면 표시 이름 후보: "주사위 카지노", "럭키 6", "주사위 놀이공원"(카지노 대신 놀이기구 6곳으로 테마 변경 시). 규칙 자체는 저작권 보호 대상이 아니다.
- 사행성 주의: 돈은 방 안에서만 쓰는 가상 지폐이며 저장·이월·교환이 없다. 실제 돈·상품을 걸 수 없음을 규칙 보기에 명시한다. 아이들 대상이면 카지노 테마 대신 놀이공원·시장 테마 스킨을 권장한다(게임물관리위원회 등급 분류에서 "사행성 모사" 요소로 볼 여지를 줄임).

254
docs/games/mafia.md Normal file
View File

@@ -0,0 +1,254 @@
# 마피아 (`mafia`)
> 마일스톤: M8 · 인원: 최소 4 ~ 최대 12명 · 예상 시간: 약 10~25분 · 난이도: 보통
## 1. 개요
- 비밀 역할을 받은 시민 팀과 마피아 팀이 낮(토론·투표)과 밤(비밀 행동)을 반복하며 상대 팀을 제거하는 대화형 추리 게임. 이 게임에서는 **채팅이 곧 게임판**이다.
- 한국에서는 수련회·MT의 오프라인 놀이와 "마피아42" 같은 온라인 게임으로 매우 유명하다. 이 문서는 한국 온라인 마피아 진행 방식(토론 → 투표 → 최후의 반론 → 찬반 투표 → 밤)을 기준으로 한다.
- 인원 근거: 원작(디미트리 다비도프, 1986)은 인원 제한이 고정되어 있지 않은 진행자 게임이다. 사용자 요청과 국내 온라인 관행(한 방 최대 8~12명)에 따라 4~12명으로 정한다. 4명 미만은 낮 투표가 성립하지 않는다(1대2에서 바로 결판).
- 진행자는 서버가 맡는다(사람 사회자 불필요).
## 2. 모드와 옵션
방장이 대기실에서 조정. 모든 시간은 초 단위.
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mafiaCount` | 마피아 수 | `auto` / 1~4 | `auto`(4장 표) |
| `extraRoles` | 추가 직업(시민 자리를 대체) | `soldier`, `politician`, `medium`, `lovers` 다중 선택 | 없음 |
| `firstPhase` | 시작 페이즈 | `day`(투표 없는 인사 낮) / `night` | `day` |
| `discussionPerAlive` | 토론 시간 = 생존자 수 × 이 값 | 10 / 15 / 20 | 15 |
| `discussionMin` / `discussionMax` | 토론 시간 하한/상한 | 30~300 | 60 / 180 |
| `timeAdjust` | 낮마다 생존자 1인 1회 시간 연장/단축(±15초) | on / off | on |
| `voteSeconds` | 지목 투표 시간 | 10~60 | 20 |
| `defenseSeconds` | 최후의 반론 시간 | 10~60 | 20 |
| `confirmSeconds` | 찬반 투표 시간 | 5~30 | 10 |
| `nightSeconds` | 밤 시간 | 15~90 | 30 |
| `deathReveal` | 사망 시 공개 정보 | `none` / `team`(마피아 여부) / `role`(직업) | `team` |
| `openVote` | 지목 투표에서 누가 누구를 찍었는지 공개 | on / off | on |
| `doctorSelfHeal` | 의사 자기 치료 허용 | on / off | on |
| `doctorRepeat` | 의사가 같은 사람을 연속 두 밤 치료 허용 | on / off | on |
| `mafiaMustKill` | 마피아가 대상 미선택 시 처리 | `noKill` / `random` | `noKill` |
| `deadSeeAll` | 죽은 사람이 모든 직업·마피아 채팅을 열람 | on / off | off |
| `kidSafeChat` | 비속어 필터 + 링크 차단 | on / off | on |
## 3. 구성물
- 직업(역할) 카드 — 가상. 화면에서 "내 직업" 카드로 표시.
- 기본 직업: 마피아, 경찰, 의사, 시민.
- 추가 직업(옵션): 군인, 정치인, 영매, 연인(2명 1쌍).
| 직업 | 팀 | 능력 |
|---|---|---|
| 마피아 | 마피아 | 서로를 안다. 밤에 마피아 전용 채팅. 밤마다 1명을 처치 대상으로 지정(팀 공용 1표, 마지막 선택이 유효). |
| 경찰 | 시민 | 밤마다 살아 있는 다른 1명을 조사해 "마피아입니다 / 마피아가 아닙니다"를 즉시 받는다. |
| 의사 | 시민 | 밤마다 살아 있는 1명을 치료. 그 밤 마피아의 공격 대상이면 살린다. |
| 시민 | 시민 | 능력 없음. 토론과 투표로 마피아를 찾는다. |
| 군인 | 시민 | 마피아 공격을 1회 버틴다(그때 군인임이 전원에게 공개). 두 번째 공격에는 사망. |
| 정치인 | 시민 | 지목 투표와 찬반 투표에서 표가 2표로 계산. 투표로 처형되지 않는다(찬반 통과 시 "정치인이라 처형되지 않았다"로 공개 후 생존). |
| 영매 | 시민 | 밤에 죽은 사람 채팅방을 읽고 쓸 수 있다. 밤마다 죽은 사람 1명을 골라 그 직업을 알 수 있다. |
| 연인 | 시민 | 2명이 서로를 안다. 밤에 둘만의 채팅. |
## 4. 준비(셋업)
1. `mafiaCount=auto`일 때 기본 배분:
| 인원 | 마피아 | 경찰 | 의사 | 시민(추가 직업 포함) |
|---|---|---|---|---|
| 4 | 1 | 1 | 1 | 1 |
| 5 | 1 | 1 | 1 | 2 |
| 6 | 2 | 1 | 1 | 2 |
| 7 | 2 | 1 | 1 | 3 |
| 8 | 2 | 1 | 1 | 4 |
| 9 | 3 | 1 | 1 | 4 |
| 10 | 3 | 1 | 1 | 5 |
| 11 | 3 | 1 | 1 | 6 |
| 12 | 3 | 1 | 1 | 7 |
2. 추가 직업은 시민 자리를 대체. 시민 자리가 부족하면 대기실에서 선택 불가(사유 표시: "인원이 부족해 연인을 넣을 수 없습니다(시민 2자리 필요)"). 수동 `mafiaCount`는 `마피아 < 전체/2`여야 한다.
3. RNG로 직업 셔플 후 배정. 마피아는 서로의 이름을 받는다. 연인은 서로를 안다.
4. 역할 확인 화면 8초 후 `firstPhase`로 시작. 일차(day number)는 1부터.
## 5. 진행 규칙
### 5.1 페이즈 순서
```
[낮 토론] → [지목 투표] → (최다 득표자 1명일 때) [최후의 반론] → [찬반 투표] → [처형 결과] → [밤] → [아침 발표] → 다음 날 [낮 토론] ...
```
- `firstPhase=day`이면 첫날은 "인사 토론"만 하고(60초 고정, 투표 없음) 바로 밤으로.
- 각 단계 끝나고 승리 조건 확인(6장).
### 5.2 낮 토론
- 토론 시간 = `clamp(생존자 수 × discussionPerAlive, discussionMin, discussionMax)`.
- `timeAdjust` on: 생존자는 하루 1회 "+15초" 또는 "-15초"를 누를 수 있다(누가 눌렀는지 공개). 남은 시간은 최소 5초 아래로 내려가지 않는다.
- 살아 있는 사람 모두 낮 채팅 가능.
### 5.3 지목 투표
- 생존자는 다른 생존자 1명 또는 "기권"을 선택. 자기 자신은 지목 불가. 시간 내 변경 가능, 마감 시점 선택이 유효. 미투표 = 기권.
- 정치인 표는 2표.
- 최다 득표자가 정확히 1명이고 득표가 1표 이상이면 그 사람이 "피고". 동률 또는 전원 기권이면 처형 없이 밤으로.
- `openVote` on이면 마감 후 누가 누구를 찍었는지 공개, off면 득표수만.
### 5.4 최후의 반론과 찬반 투표
- 최후의 반론: 피고만 낮 채팅에 쓸 수 있다(다른 생존자 채팅 잠금).
- 찬반 투표: 피고를 제외한 생존자가 "찬성/반대" 선택. 미투표는 집계 제외. 정치인 2표.
- `찬성 > 반대`면 처형, 같거나 적으면 생존. 찬반 결과는 수만 공개(익명).
- 처형 시 `deathReveal`에 따라 공개. 피고가 정치인이면 처형 무효, 정치인임이 공개되고 생존.
### 5.5 밤
- 시간 `nightSeconds`. 밤에는 낮 채팅 잠금.
- 동시 행동(모두 마감 전까지 변경 가능, 마감 시점 값으로 처리):
- 마피아: 살아 있는 비마피아 1명을 공용 대상으로 지정(마피아끼리 서로 보임, 마지막 변경이 유효). 마피아 채팅 가능.
- 경찰: 살아 있는 다른 1명 조사. 결과는 선택 즉시가 아니라 **밤 마감 시** 경찰에게만 전달(대상 변경으로 여러 명 조사하는 것을 막기 위해).
- 의사: 살아 있는 1명(옵션에 따라 자신 포함, 연속 불가) 치료.
- 영매: 죽은 사람 1명의 직업 확인(밤 마감 시 전달), 죽은 사람 채팅 참여.
- 연인: 연인 채팅.
- 처리 순서(밤 마감 시):
1. 마피아 대상 확정(없으면 `mafiaMustKill`에 따라 미처치 또는 RNG 무작위).
2. 대상 == 의사 치료 대상 → 생존, 아침에 "의사의 치료로 누군가 살아났습니다" 발표(이름은 공개하지 않음).
3. 대상이 군인이고 첫 공격 → 생존, "군인 ○○이 공격을 버텼습니다" 공개.
4. 아니면 사망, `deathReveal`에 따라 공개.
5. 경찰·영매 결과 개인 전달.
- 경찰/의사가 그 밤에 죽어도 그 밤의 행동은 유효(동시 처리).
### 5.6 채팅 채널(서버가 강제)
| 채널 | 쓰기 | 읽기 | 열리는 시간 |
|---|---|---|---|
| `day` | 생존자(최후의 반론 중엔 피고만) | 전원(관전자·죽은 사람 포함) | 낮 토론·투표·반론·찬반 |
| `mafia` | 살아 있는 마피아 | 마피아(죽은 마피아 포함), `deadSeeAll`이면 죽은 사람 | 밤(쓰기), 기록은 마피아가 언제든 열람 |
| `dead` | 죽은 사람, 밤의 영매 | 죽은 사람, 영매(밤에만 표시) | 항상(영매는 밤) |
| `lovers` | 살아 있는 연인 | 연인 | 밤 |
| `system` | 서버 | 전원 | 항상 |
- 메시지 제한: 200자, 0.7초당 1개(서버 레이트 리밋), `kidSafeChat` 필터. 위반 시 메시지 거부 사유 표시.
- 죽은 사람의 메시지가 살아 있는 사람에게 보이는 경로는 절대 없어야 한다(영매 제외).
- 외부 음성(디스코드)으로 정보를 흘리는 것은 막을 수 없으므로 규칙 보기에 "죽은 사람은 말하지 않기" 에티켓을 안내한다.
## 6. 승패와 점수 계산
- 시민 팀 승리: 살아 있는 마피아 0명.
- 마피아 팀 승리: `살아 있는 마피아 수 >= 살아 있는 비마피아 수`.
- 판정 시점: 처형 결과 직후, 아침 발표 직후, 연결 끊김 강제 탈락 직후.
- 예: 생존 마피아 2, 시민 3 상태에서 낮에 시민 1명이 처형 → 2 대 2 → 즉시 마피아 승리.
- 예: 4인(마피아1)에서 첫 밤 시민 사망 → 1 대 2 → 계속. 다음 낮 오처형 → 1 대 1 → 마피아 승리.
- 무승부 없음. 동시에 두 조건이 될 수 없다(마피아 0이면 시민 승).
- 점수(방 내 기록): 승리 팀 전원 1점(죽은 팀원 포함). MVP 표시(선택): 마피아를 지목해 처형시킨 표를 가장 많이 던진 시민.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `deathReveal=none` | 블라인드 룰(사망자 정보 비공개). 마피아에게 유리, 영매가 의미 있어짐 | `team` |
| `doctorSelfHeal=off` | 의사 자힐 금지(국내 오프라인에서 흔함) | on |
| `doctorRepeat=off` | 같은 대상 연속 치료 금지 | on |
| `noVoteDayOne` | 인사 낮을 생략하고 첫날부터 투표 | `firstPhase`로 표현 |
| `policeRevealRole` | 경찰이 "마피아 여부" 대신 정확한 직업을 앎 | off |
| `mafiaMustKill=random` | 마피아가 대상 미선택 시 무작위 처치 | `noKill` |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Role = 'mafia' | 'police' | 'doctor' | 'citizen' | 'soldier' | 'politician' | 'medium' | 'lover';
type Phase =
| { kind: 'roleReveal'; endsAt: number }
| { kind: 'greeting'; endsAt: number }
| { kind: 'discussion'; endsAt: number; adjustedBy: PlayerId[] }
| { kind: 'vote'; endsAt: number; votes: Record<PlayerId, PlayerId | 'abstain'> }
| { kind: 'defense'; endsAt: number; accused: PlayerId }
| { kind: 'confirm'; endsAt: number; accused: PlayerId; ballots: Record<PlayerId, 'yes' | 'no'> }
| { kind: 'night'; endsAt: number; mafiaTarget: PlayerId | null; mafiaPickedBy: PlayerId | null;
investigate: PlayerId | null; heal: PlayerId | null; mediumPeek: PlayerId | null }
| { kind: 'ended' };
interface ChatMessage { id: number; channel: 'day' | 'mafia' | 'dead' | 'lovers' | 'system'; from: PlayerId | null; text: string; at: number; day: number }
interface MafiaState {
options: MafiaOptions;
rng: RngState;
players: { id: PlayerId; role: Role; alive: boolean; soldierUsed: boolean; diedDay: number | null; diedBy: 'vote' | 'mafia' | 'disconnect' | null }[];
day: number;
phase: Phase;
lastHealTarget: PlayerId | null;
privateResults: { to: PlayerId; day: number; text: string }[]; // 경찰/영매 결과
chat: ChatMessage[]; // 서버 상태에 전부 저장, view에서 필터
lastChatAt: Record<PlayerId, number>; // 레이트 리밋
history: PublicEvent[]; // 공개 발표 기록
winner: null | 'town' | 'mafia';
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `chat` | `{ channel, text }` | 채널 쓰기 권한자(5.6) | 권한·페이즈 일치, 1~200자, 레이트 리밋, 필터 통과 |
| `adjustTime` | `{ delta: 15 \| -15 }` | 생존자, discussion | `timeAdjust` on, 오늘 미사용 |
| `vote` | `{ target: PlayerId \| 'abstain' }` | 생존자, vote | 대상 생존·본인 아님 |
| `confirm` | `{ choice: 'yes' \| 'no' }` | 피고 제외 생존자, confirm | — |
| `mafiaTarget` | `{ target: PlayerId \| null }` | 살아 있는 마피아, night | 대상 생존·비마피아 |
| `investigate` | `{ target }` | 살아 있는 경찰, night | 대상 생존·본인 아님 |
| `heal` | `{ target }` | 살아 있는 의사, night | 대상 생존, 자기면 `doctorSelfHeal`, 연속이면 `doctorRepeat` |
| `mediumPeek` | `{ target }` | 살아 있는 영매, night | 대상 사망자 |
| `phaseTimeout` | `{}` | 서버 내부(타이머) | `now >= phase.endsAt` |
| `readyEarly` | `{}` | 생존자, discussion | 생존자 전원이 누르면 토론 즉시 종료(선택 기능) |
페이즈 전환은 서버 타이머가 `phaseTimeout`을 apply하는 방식으로 일관되게 처리한다(재생 가능).
### 8.3 공개/비공개 정보 (view)
- 모두: 생존 여부, 페이즈와 남은 시간, 공개 발표(사망자, `deathReveal`에 따른 소속/직업), 낮 채팅, 투표 집계(`openVote`에 따라), 찬반 수, 공개된 군인/정치인.
- 본인: 자기 직업. 마피아는 동료 목록 + 현재 공용 대상 + `mafia` 채팅. 연인은 상대 + `lovers` 채팅. 경찰/영매는 자기 결과 목록. 의사는 자기 치료 대상.
- 죽은 사람: 위 + `dead` 채팅. `deadSeeAll` on이면 전원 직업 + `mafia` 채팅 열람.
- 관전자: `day`, `system`과 공개 발표만. 진행 중에는 직업을 절대 받지 않는다.
- 밤 행동 대상(경찰 조사, 의사 치료, 마피아 대상)은 해당 권한자 외에는 보내지 않는다. 밤에 "누가 행동을 마쳤는지"도 보내지 않는다(직업 유추 방지).
- 채팅 필터링은 반드시 view에서 채널 권한으로 한다. 클라이언트에 전체 chat을 보내고 숨기는 방식 금지.
- 게임 종료 후 전체 직업과 모든 채널 공개(선택: 마피아 채팅 공개 여부 방장 설정).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
직업 배정, `mafiaMustKill=random`일 때 대상 선택. 그 외에는 없음.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)` = `phase.endsAt`(모든 페이즈가 시간제). `activePlayers`: 낮 = 생존자 전원, 밤 = 행동 직업 보유 생존자, 찬반 = 피고 제외 생존자.
- `onTimeout(player)`: vote → `abstain`, confirm → 미투표(집계 제외), night → 행동 없음. 페이즈 자체는 `phaseTimeout`으로 넘어간다.
- 연결 끊김: 진행은 멈추지 않는다. 같은 플레이어가 연속 2번의 낮 동안 접속하지 않으면 "자리 비움 탈락" 처리(사망과 동일, `deathReveal` 적용). 이후 승리 판정.
- 재접속 시 현재 view 전체(허용된 채팅 기록 포함) 재전송.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface MafiaResult {
winner: 'town' | 'mafia';
days: number;
players: { id: PlayerId; role: Role; team: 'town' | 'mafia'; alive: boolean; diedDay: number | null; diedBy: string | null; won: boolean }[];
ranking: PlayerId[]; // 승리 팀(생존자 먼저) → 패배 팀
summary: string; // 예: "시민 승리! 3일차 낮 투표로 마지막 마피아를 찾아냈습니다."
}
```
## 9. UI/UX
- 모바일 세로: 상단 페이즈 배너(낮=밝은 색, 밤=어두운 색) + 큰 남은 시간. 가운데 채팅(채널 탭: 전체 / 마피아 / 죽은 사람 / 연인 — 권한 있는 탭만 표시). 하단 입력창 + 상황별 큰 버튼.
- 플레이어 목록: 아바타 그리드(2~3열), 죽은 사람은 흑백 + 사망 표시. 투표·밤 행동 시 그리드에서 탭으로 선택, 고를 수 있는 사람만 강조.
- 내 직업은 상단 "내 직업" 버튼을 눌러야 보인다(화면 공유·옆 사람 방지). 밤 행동은 "오늘 밤 할 일: 조사할 사람을 고르세요" 문장으로 안내.
- 밤 화면은 행동 없는 직업도 같은 레이아웃("밤입니다. 잠시 기다려 주세요")으로 보여 줘서 화면만으로 직업이 드러나지 않게 한다.
- 투표 결과 애니메이션: 득표 막대. 처형/사망 발표는 큰 카드 팝업.
- 어린이용: 직업 설명 그림 카드, "규칙 보기"에 낮/밤 흐름 그림.
- PC: 왼쪽 플레이어 목록, 가운데 채팅, 오른쪽 행동 패널.
## 10. 테스트 체크리스트
- [ ] 4~12인 자동 배분이 표와 일치, 추가 직업 선택 시 시민 수만큼만 대체, 부족하면 대기실 거부.
- [ ] 지목 투표 동률 → 처형 없음. 전원 기권 → 처형 없음. 정치인 2표로 동률이 깨지는 경우.
- [ ] 찬반 동률 → 생존. 정치인 피고 찬반 통과 → 정치인 공개 후 생존.
- [ ] 의사가 마피아 대상 치료 → 사망 없음, 발표에 이름 미포함. `doctorSelfHeal`/`doctorRepeat` off 시 검증 거부.
- [ ] 군인: 첫 공격 생존·공개, 두 번째 사망. 의사 치료와 겹치면 의사 우선(군인 능력 미소모).
- [ ] 마피아 2명이 서로 다른 대상 선택 → 마지막 선택만 유효, 대상 미선택 + `noKill` → 사망 없음.
- [ ] 경찰이 밤 중 대상 3번 변경 → 마감 시 마지막 1명 결과만 수신.
- [ ] 승리 판정: 처형 직후 마피아 = 비마피아 → 즉시 마피아 승리(밤으로 넘어가지 않음).
- [ ] 채팅 권한: 밤에 시민의 `day` 채팅 거부, 반론 중 피고 외 거부, 죽은 사람의 `day` 채팅 거부, 레이트 리밋 초과 거부.
- [ ] 비공개 누출 검사: 각 생존 시민/관전자 view 직렬화 결과에 다른 사람 role, `mafia`/`dead`/`lovers` 메시지, 밤 행동 대상, privateResults가 없는지 자동 검사. 영매는 낮 동안 `dead` 메시지를 받지 않는지.
- [ ] 밤 화면 view 형태가 시민과 경찰·의사에서 필드 구성상 구별되지 않는지(행동 정보는 별도 private 필드로만).
- [ ] 연결 끊김: 2번의 낮 동안 미접속 → 자리 비움 탈락, 그 결과로 승리 조건 충족 시 종료.
- [ ] `timeAdjust`: 1인 1일 1회, -15초로 5초 미만 내려가지 않음.
- [ ] 같은 시드와 액션(채팅 포함) 로그 재생 시 동일 결과.
## 11. 참고 자료
- 나무위키 "마피아 게임" — https://namu.wiki/w/마피아%20게임
- 나무위키 "마피아42" — https://namu.wiki/w/마피아42
- Wikipedia "Mafia (party game)" — https://en.wikipedia.org/wiki/Mafia_(party_game)
- BoardGameGeek "Mafia" — https://boardgamegeek.com/boardgame/925/werewolf (늑대인간/마피아 계열)
## 12. 메모 (상표·법적 주의 등)
- "마피아 게임"은 공유 놀이 이름이라 상표 문제가 거의 없다. "마피아42"는 특정 회사 제품명이므로 언급·모방 표기를 피한다. 표시 이름 후보: "마피아", "마피아 찾기", "밤의 마을".
- 아동 이용자: "처치/처형" 표현을 "탈락/퇴장"으로 바꾸는 어린이 표현 모드 검토. 채팅이 핵심이므로 신고 버튼, 방장 강퇴, 비속어 필터, 개인정보(전화번호 등) 패턴 차단을 기본 제공.
- 채팅 로그 보관: 신고 처리용으로 짧게(예: 7일) 보관하고 이후 삭제. 개인정보 처리방침에 명시.
- 도박 요소 없음.

589
docs/games/mahjong.md Normal file
View File

@@ -0,0 +1,589 @@
# 마작 (`mahjong`)
> 마일스톤: M5 · 인원: 최소 3 ~ 최대 4명 (기본 4인 리치 마작, 3인은 산마 모드) · 예상 시간: 동풍전 약 25분 / 반장전 약 50분 · 난이도: 어려움
## 1. 개요
- 136장의 마작패로 14장 화료형(4면자 1머리 등)을 먼저 완성하는 게임. 기본 규칙은 일본식 **리치 마작**이다. 한국 온라인에서는 작혼(Mahjong Soul), 텐호 등을 통해 리치 마작이 가장 널리 쓰이므로 기본 모드로 삼는다. 화료하려면 **역(役)이 최소 1개** 있어야 하고(도라는 역이 아님), 점수는 판(翻)과 부(符)로 계산한다.
- 기본 모드: 4인 리치 마작(반장전/동풍전 선택). 보조 모드: 3인 마작(산마, 三麻).
- 인원 근거: 리치 마작 표준 규칙은 4인 전용이다. 3인용 산마는 일본에서 정착된 공식 변형(텐호·작혼 랭크전 지원)이라 최소 인원을 3명으로 둔다. 2인 변형은 전통 규칙에 없으므로 지원하지 않는다. 모드는 방 인원으로 정해진다: 4명이면 4인 마작, 3명이면 산마. 빈 자리를 AI로 채우는 기능은 이 문서 범위 밖이다.
- 점수는 **방 안에서만 쓰는 가상 점수**다. 저장되는 재화, 결제, 환전은 전혀 없다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `length` | 대국 길이 | `tonpuu`(동풍전, 동1~동4) / `hanchan`(반장전, 동1~남4) | `hanchan` |
| `startPoints` | 시작 점수 | 정수(100 단위) | 4인 25000 / 3인 35000 |
| `returnPoints` | 반환점(오카·서입 기준) | 정수 | 4인 30000 / 3인 40000 |
| `uma` | 순위 우마(천 점 단위) | `none` / `5-10` / `10-20` / `10-30` (3인은 `0` / `15`) | 4인 `10-20`(+20,+10,-10,-20) / 3인 `15`(+15,0,-15) |
| `akaDora` | 적도라(빨간 5) 개수 | 0 / 3(5m·5p·5s 각 1) — 3인은 0 / 2(5p·5s 각 1) | 4인 3 / 3인 2 |
| `kuitan` | 울어도 탕야오 인정 | true / false | true |
| `doubleRon` | 동시 론 처리 | `allow`(더블론 허용) / `headBump`(머리튀기: 방총자 다음 순서 1명만) | `allow` |
| `tripleRon` | 3명 동시 론 | `abort`(삼가화 유국) / `allow` | `abort` |
| `tobi` | 0점 미만이 되면 즉시 종료(토비) | true / false | true |
| `agariYame` | 올라스트 친이 화료·텐파이로 1위면 자동 종료 | true / false | true |
| `westEntry` | 마지막 국 종료 시 반환점 이상이 없으면 연장전(서입/남입) | true / false | true |
| `renchanOn` | 친 연장 조건 | `tenpai`(친 화료 또는 유국 시 친 텐파이) / `agari`(친 화료 시만) | `tenpai` |
| `kiriageMangan` | 4판30부·3판60부를 만관으로 올림 | true / false | false |
| `kazoeYakuman` | 13판 이상 헤아림 역만 | true / false (false면 삼배만 상한) | true |
| `doubleYakuman` | 더블 역만(국사 13면·사암각 단기·순정구련·대사희) | true / false | true |
| `abortiveDraws` | 도중유국(구종구패·사풍연타·사깡유국·사가리치) | true / false | true |
| `nagashiMangan` | 유국만관 | true / false | true |
| `pao` | 책임지불(대삼원·대사희 확정 울기) | true / false | true |
| `ankanChankanKokushi` | 국사무쌍만 안깡을 창깡 가능 | true / false | true |
| `renpuuPairFu` | 연풍패(장풍=자풍) 머리 부수 | 2 / 4 | 4 |
| `sanmaTsumo` | 산마 쯔모 지불 방식 | `tsumoLoss`(쯔모손: 없는 북가 몫 소멸) / `northBisection`(북가 몫을 둘이 반씩) | `tsumoLoss` |
| `sanmaHonba` | 산마 본장 1개당 점수 합계 | 200 / 300 / 1000 | 200 |
| `sanmaNotenPool` | 산마 유국 텐파이료 총액 | 2000 / 3000 | 2000 |
| `turnSeconds` | 타패 기본 제한 시간(초) | 5~60 | 15 |
| `claimSeconds` | 울기·론 응답 창 시간(초) | 3~20 | 8 |
| `timeBankSeconds` | 개인 예비 시간(대국 전체, 매 국 +5초 충전, 최대값까지) | 0~300 | 60 |
| `beginnerMode` | 초보 모드 허용(개인별로 켜고 끔) | true / false | true |
`mode`(4인/3인)는 옵션이 아니라 `setup` 시점의 인원 수로 정한다. 3인일 때 4인 전용 옵션 값은 무시하고 3인 기본값을 쓴다.
## 3. 구성물
- 수패 3종 x 1~9 x 각 4장 = 108장
- 만수(m, 萬子) 1m~9m, 통수(p, 筒子) 1p~9p, 삭수(s, 索子) 1s~9s
- 자패 7종 x 4장 = 28장
- 풍패: 동(東)·남(南)·서(西)·북(北)
- 삼원패: 백(白)·발(發)·중(中)
- 합계 136장. 적도라 옵션이면 5m·5p·5s 중 각 1장이 빨간 5(아카)로 바뀐다(장수는 그대로).
- 3인(산마): 2m~8m 28장을 빼고 108장을 쓴다(1m·9m만 남음). 적도라는 5p·5s 각 1장.
- 용어: 노두패 = 1·9 수패, 요구패 = 노두패 + 자패, 중장패 = 2~8 수패.
- 점봉·주사위는 화면 표시용 가상 물건이다. 리치봉 1개 = 1000점, 본장봉 1개 = 연장 카운터.
## 4. 준비(셋업)
1. 자리 정하기: RNG로 좌석 순서를 섞는다. 좌석 0이 첫 친(기가, 起家)으로 동1국의 동가가 된다. 진행 방향은 반시계(동 → 남 → 서 → 북 → 동). 화면에서는 내 오른쪽 사람이 다음 차례(하가)이고 왼쪽 사람이 앞 차례(상가)다.
2. 각 국 시작:
- 136장(산마 108장)을 RNG로 셔플(Fisher–Yates)해 패산 배열을 만든다. 실제 패산 쌓기와 주사위 굴리기는 연출이다. 셔플 결과가 곧 패산이다.
- 배열 끝 14장은 **왕패**: 영상패(보충패) 4장, 도라 표시패 5장, 우라도라 표시패 5장. 첫 도라 표시패 1장을 공개한다.
- 친부터 13장씩 나눠 준다(연출은 4장씩 3회 + 1장). 친은 바로 첫 쯔모를 해서 14장으로 시작한다.
- 남은 산패(뽑을 수 있는 패)는 4인 70장, 산마 55장이다.
3. 점수 초기화: 전원 `startPoints`. 본장 0, 공탁(리치봉) 0.
4. 자풍: 친 = 동, 그다음 = 남, 서, 북(산마는 동·남·서만). 장풍: 동장이면 동, 남장이면 남, 서입하면 서.
## 5. 진행 규칙
### 5.1 한 차례(턴)의 흐름
1. **쯔모**: 산패 맨 앞에서 1장 가져온다(울기 직후에는 쯔모하지 않는다).
2. 쯔모 뒤 손패 14장(울기한 면자 포함 기준) 상태에서 할 수 있는 행동:
- 쯔모 화료(역이 있어야 함)
- 안깡, 가깡(산패가 1장 이상 남았고 총 깡 4회 미만일 때)
- 북 뽑기(산마만)
- 구종구패 선언(첫 순 첫 쯔모일 때만)
- 리치 선언 + 타패
- 그냥 타패 1장
3. **타패** 뒤 **응답 창(클레임 창)**을 연다(5.3).
4. 아무도 가져가지 않으면 하가가 다음 쯔모를 한다.
### 5.2 울기(치·퐁·깡)
| 울기 | 대상 | 조건 | 처리 |
|---|---|---|---|
| 치 | **상가(직전 차례)**의 타패만 | 손패 2장 + 그 패로 같은 종류 연속 3장(순쯔). 자패 불가. **산마에서는 치 금지** | 노출 면자 생성 → 쯔모 없이 바로 타패 |
| 퐁 | 누구의 타패든 | 같은 패 2장 보유 | 노출 각쯔 → 바로 타패 |
| 대명깡 | 누구의 타패든 | 같은 패 3장 보유, 산패 1장 이상, 총 깡 4회 미만 | 노출 깡쯔 → 영상패 쯔모 → 타패 |
| 안깡 | 자기 차례 | 같은 패 4장 보유 | 비공개 깡쯔(2장만 뒤집어 보임) → 영상패 쯔모 |
| 가깡 | 자기 차례 | 이미 퐁한 패의 4번째 보유 | 퐁 → 깡으로 승격 → 다른 사람의 창깡 론 창을 연 뒤 영상패 쯔모 |
- **하저패(마지막 타패)는 론만 가능**하다. 치·퐁·깡은 할 수 없다. 해저패(마지막 쯔모)로 깡할 수 없다.
- **구이가에(喰い替え) 금지**: 치·퐁 직후 같은 차례에 (a) 방금 울은 패와 같은 종류, (b) 치의 경우 반대쪽 끝 스지 패(예: 34m에 2m을 치고 5m 버리기, 34m에 5m을 치고 2m 버리기)를 버릴 수 없다. 울고 나서 버릴 수 있는 패가 이 규칙 때문에 하나도 없으면 그 울기 자체가 불법이다.
- 치를 할 때 쓸 손패 조합이 여러 가지면(예: 4m 치기에 23m / 35m / 56m, 적5 포함 여부) 클라이언트가 `tiles`로 고른다.
- 깡 후 새 도라 표시패: 안깡은 즉시 공개한다. 대명깡·가깡은 그 사람의 **다음 타패 시점**(응답 창이 열리기 전)에 공개한다. 영상개화로 바로 화료하면 대명깡·가깡의 새 도라는 공개하지 않는다.
- 깡으로 왕패에서 1장을 쓰면 산패 끝 1장이 왕패로 넘어가 왕패 14장을 유지한다. 즉 깡 1회마다 산패가 1장 줄어든다.
- 깡은 판 전체에서 4회까지다. 한 사람이 4깡을 하면 유국 없이 계속하되 그 국에서 더는 아무도 깡할 수 없다.
### 5.3 응답 창(클레임 창) — 실시간 설계
타패(또는 가깡 패, 북 뽑기 패, 국사 대상 안깡 패)가 나오면 서버는 다음을 한다.
1. 타패자를 뺀 각 플레이어의 합법 응답 목록을 계산한다: `ron`, `pon`, `kan`, `chi`(상가 관계·4인일 때만). 론은 역 있음 + 후리텐 아님 + 화료형일 때만 넣는다.
2. 합법 응답이 있는 플레이어가 아무도 없으면 창을 열지 않고 바로 다음 차례로 넘어간다. 단 **정보 누출 완화**를 위해 모든 타패 뒤에 같은 짧은 최소 지연(기본 400ms)을 둔다. 이 지연은 응답 가능자가 있든 없든 똑같다.
3. 있으면 phase=`claim`. `activePlayers`는 아직 응답하지 않은, 응답 가능한 플레이어 전원이다. 각자 `claim` 또는 `pass` 액션을 보낸다. 마감 시각 = 지금 + `claimSeconds`. 예비 시간은 쓰지 않는다.
4. **확정 규칙(조기 종료)**: 응답이 들어올 때마다 다음을 확인한다.
- 현재까지 선언된 최고 우선순위 응답이 있고, 아직 응답하지 않은 사람들이 그보다 **높거나 같은** 우선순위 응답을 할 수 없으면 바로 확정한다.
- 우선순위: **론 > 퐁 = 깡 > 치**. 같은 패로 퐁과 깡이 동시에 나올 수는 없다(4장 한계). 퐁끼리도 겹칠 수 없다.
- 론이 하나라도 선언되면 다른 론 가능자의 응답(더블론 여부)을 기다린다. 론 가능자가 모두 응답하거나 마감되면 확정한다.
- 모든 응답 가능자가 응답했거나 마감 시각이 지나면 확정한다. 미응답자는 `pass`로 본다.
5. 확정 결과:
- 론 1명: 화료. 론 2명: `doubleRon=allow`면 둘 다 화료, `headBump`면 방총자 다음 차례 순으로 가장 가까운 1명. 론 3명: `tripleRon=abort`면 삼가화 유국, `allow`면 3명 모두 화료.
- 퐁/깡: 그 사람에게 차례가 넘어간다(사이 사람들은 건너뜀).
- 치: 하가에게 차례.
- 전원 패스: 하가 쯔모.
6. 론할 수 있었는데 패스(또는 시간 초과)한 사람은 **동순 후리텐**이 된다. 리치 중이면 **영구 후리텐**이 된다.
7. 클라이언트에는 내 응답 버튼만 보여 준다. 다른 사람이 응답할 수 있는지, 누가 고민 중인지는 절대 알리지 않는다("다른 플레이어 확인 중"만 표시).
8. 리치 선언 타패가 론 당하면 리치는 성립하지 않는다(1000점을 내지 않음). 론이 없으면 응답 창이 끝날 때 1000점을 공탁에 낸다.
### 5.4 리치
- 조건: 멘젠(안깡만 허용), 텐파이, 점수 1000점 이상, 남은 산패 4장 이상(산마 3장 이상).
- 선언 타패는 옆으로 눕혀 표시한다. 그 타패가 울려서 가져가지면 다음 타패를 눕힌다(표시용).
- 리치 뒤에는 손패를 바꿀 수 없다. 쯔모한 패를 바로 버리는 것(쯔모기리)만 된다. 단 다음은 허용한다.
- 쯔모 화료, 론
- 안깡: 쯔모한 패로 하는 안깡이고, 대기패가 바뀌지 않으며, 손패 해석(면자 구성)이 바뀌지 않을 때만
- 북 뽑기(산마): 대기가 바뀌지 않을 때만
- **일발**: 리치 선언 후 자기 다음 타패 전까지(다음 쯔모 포함) 화료하면 +1판. 그 사이 누구든 울기(안깡 포함)를 하면 사라진다. 북 뽑기는 울기가 아니므로 일발을 지우지 않는다.
- **더블리치**: 첫 순(자기 첫 타패, 그 전에 누구의 울기도 없음)에 리치하면 2판(리치 1판 대신).
### 5.5 후리텐(振聴)
- 텐파이 상태에서 대기패의 화료형 기준(역 유무와 상관없이)으로 판단한다.
- **버림패 후리텐**: 내 대기패 중 하나라도 내 버림패(남이 울어 간 것 포함)에 있으면 론할 수 없다.
- **동순 후리텐**: 다른 사람의 타패나 깡 패가 내 화료패였는데 론하지 않았으면(역이 없어 못한 경우 포함) 내 다음 타패 전까지 론할 수 없다.
- **리치 후 후리텐**: 리치 뒤 화료패를 놓치면 그 국 끝까지 론할 수 없다.
- 후리텐이어도 쯔모 화료는 할 수 있다.
- 대기패 4장을 내가 모두 가진 형태(예: 1111m 단기)는 그 패로는 화료할 수 없으므로 대기로 치지 않는다.
### 5.6 화료 조건
- 화료형: (a) 4면자 + 1머리(면자 = 순쯔 또는 각쯔/깡쯔), (b) 칠대자(서로 다른 7쌍, 같은 패 4장을 2쌍으로 볼 수 없음), (c) 국사무쌍.
- 역이 1개 이상 있어야 한다. 도라·적도라·북은 역이 아니다.
- 대기에 따라 역이 생기는 패와 안 생기는 패가 섞여 있으면(가타아가리) 역이 생기는 패로만 화료할 수 있다. 후 붙이기(後付け)는 허용한다.
### 5.7 산마(3인) 차이
- 2m~8m 없음. 도라 표시패가 1m이면 도라는 9m, 9m이면 1m.
- 치 금지. 퐁·깡·론은 같다.
- **북 뽑기(拔北)**: 자기 차례(쯔모 후, 울기 직후 제외)에 손의 북을 옆에 빼 두고 영상패 1장을 쯔모한다. 뺀 북 1장 = 도라 1. 북이 도라 표시로 도라가 되면 뺀 북도 도라로 센다. 산패가 0장이면 할 수 없다. 북 뽑기에도 응답 창을 연다. 북을 기다리는 사람은 론할 수 있다(창깡 역은 붙지 않음). 북 뽑기 뒤 영상패로 쯔모 화료하면 영상개화를 인정한다.
- 북은 손에 남겨 머리·각쯔로 써도 된다. 다만 산마의 북은 장풍·자풍이 아닌 손님 바람이라 북 각쯔에 역은 없다. 국사무쌍·자일색·소사희 등의 구성에는 쓸 수 있다.
- 쯔모 지불: `tsumoLoss`면 4인 기준으로 계산한 뒤 없는 북가 몫을 받지 않는다(예: 자 만관 쯔모 = 친 4000 + 자 2000 = 6000). `northBisection`이면 북가 몫을 남은 두 사람이 반씩 더 낸다(각자 100 단위 올림).
- 본장: 1개당 `sanmaHonba`(기본 200). 론이면 방총자가 전부 내고, 쯔모면 둘이 반씩 낸다.
- 유국 텐파이료: 총 `sanmaNotenPool`(기본 2000). 텐파이 1명이면 노텐 둘이 1000씩, 텐파이 2명이면 노텐 1명이 1000씩 두 명에게 낸다.
- 사풍연타: 3명 모두 같은 바람을 처음에 버리면 성립. 사가리치는 없고 대신 3명 모두 리치해도 계속 진행한다.
- 삼색동순은 만수에 2~8이 없어 성립하지 않는다. 일기통관도 만수로는 불가.
### 5.8 국 종료
- **화료**: 점수 정산(6장) → 다음 국.
- **황패유국(산패 소진)**: 마지막 쯔모자가 타패하고 론이 없으면 유국.
- 텐파이/노텐 자동 판정(형식 텐파이 인정, 역 없어도 텐파이). 텐파이자의 손패는 공개하고 노텐 손패는 공개하지 않는다.
- 4인 텐파이료 3000: 텐파이 1명 → 노텐 3명이 1000씩. 2명 → 노텐 2명이 1500씩 각각 텐파이 1명에게. 3명 → 노텐 1명이 1000씩 3명에게. 0명/4명 → 없음.
- 유국만관(옵션): 버림패가 전부 요구패이고 그중 하나도 남에게 울리지 않은 사람은 만관 쯔모만큼 받는다(친이면 4000 올, 자이면 2000/4000). 본장은 붙지 않고 텐파이료는 정산하지 않는다. 여러 명이면 각각 받는다. 친 연장 여부는 친 텐파이로 정한다.
- **도중유국**(옵션, 공통: 친 연장, 본장 +1, 공탁 유지, 점수 이동 없음):
- 구종구패: 첫 순, 아무 울기 전, 자기 첫 쯔모 시점에 요구패 종류가 9종 이상이면 선언할 수 있다(선택).
- 사풍연타: 4인이 첫 타패를 모두 같은 풍패로 했고 그 사이 울기가 없음.
- 사깡유국: 2명 이상이 합계 4깡을 했을 때, 4번째 깡 뒤 타패가 론되지 않으면 유국.
- 사가리치: 4명 모두 리치(4번째 리치 타패가 론되지 않았을 때).
- 삼가화: `tripleRon=abort`일 때.
### 5.9 친·본장·공탁
- 친 화료 → 친 연장(같은 국 번호 반복), 본장 +1.
- 자 화료 → 친이 하가로 넘어가고 국 번호 +1, 본장 0.
- 황패유국 → 본장 +1. 친이 텐파이면 연장, 아니면 친이 넘어간다(`renchanOn=agari`면 무조건 넘어감).
- 공탁(리치봉): 화료자가 모두 가져간다. 더블론이면 방총자에서 가장 가까운 화료자가 가져간다. 대국이 끝날 때 남은 공탁은 1위가 가져간다.
- 본장 보너스: 4인은 본장 1개당 300점(론: 방총자 300 x 본장, 쯔모: 각자 100 x 본장). 더블론이면 머리튀기 위치의 1명만 받는다.
### 5.10 대국 종료 조건
1. 마지막 국(동풍전 동4, 반장전 남4) 종료 시 친이 넘어가면 종료.
2. 올라스트 친 연장 중 `agariYame`이고 친이 단독 1위가 되면 종료.
3. `tobi`이고 누군가 0점 미만이 되면 즉시 종료(정확히 0점은 계속).
4. `westEntry`: 마지막 국이 끝났는데 아무도 `returnPoints` 이상이 아니면 다음 장(반장전은 서장, 동풍전은 남장)으로 들어간다. 연장 장에서는 **국이 끝난 시점에 누군가 반환점 이상이면 바로 종료**(서든데스). 연장 장의 4국까지 끝나면 무조건 종료.
5. 산마: 북가가 없으므로 국 번호는 1~3(동1~동3, 남1~남3).
## 6. 승패와 점수 계산
### 6.1 역 목록 (멘젠 / 울었을 때)
"멘젠 한정"은 울면 성립하지 않는다. "울면 -1"은 울면 판수가 1 내려간다(쿠이사가리).
1판
| 역 | 조건 | 멘젠 | 울기 |
|---|---|---|---|
| 리치 | 5.4 | 1 | 불가 |
| 일발 | 리치 후 1순 안 화료 | 1 | 불가 |
| 멘젠쯔모 | 멘젠으로 쯔모 화료 | 1 | 불가 |
| 핑후 | 멘젠, 4면자 모두 순쯔, 머리가 역패가 아님, 양면 대기 | 1 | 불가 |
| 탕야오 | 요구패 하나도 없음 | 1 | 1 (`kuitan` false면 불가) |
| 이페코 | 같은 순쯔 2개 | 1 | 불가 |
| 역패: 백 / 발 / 중 | 해당 각쯔·깡쯔 | 각 1 | 각 1 |
| 역패: 장풍 | 장풍 각쯔 | 1 | 1 |
| 역패: 자풍 | 자풍 각쯔(장풍과 같으면 합 2판) | 1 | 1 |
| 해저로월 | 마지막 산패로 쯔모 화료 | 1 | 1 |
| 하저로어 | 마지막 타패로 론 | 1 | 1 |
| 영상개화 | 깡·북 보충패로 쯔모 화료 | 1 | 1 |
| 창깡 | 남의 가깡 패로 론(국사는 안깡도 옵션) | 1 | 1 |
2판
| 역 | 조건 | 멘젠 | 울기 |
|---|---|---|---|
| 더블리치 | 첫 순 리치(리치 대신) | 2 | 불가 |
| 칠대자 | 서로 다른 7쌍, 부수 25 고정 | 2 | 불가 |
| 삼색동순 | 만·통·삭 같은 숫자 순쯔 | 2 | 1 |
| 일기통관 | 한 종류 123·456·789 | 2 | 1 |
| 찬타(혼전대요구) | 모든 면자·머리에 요구패, 순쯔 1개 이상, 자패 포함 | 2 | 1 |
| 또이또이(대대화) | 4면자 모두 각쯔·깡쯔 | 2 | 2 |
| 산안커(삼암각) | 암각 3개(론으로 완성한 각쯔는 명각) | 2 | 2 |
| 삼색동각 | 만·통·삭 같은 숫자 각쯔 | 2 | 2 |
| 산깡쯔 | 깡 3개 | 2 | 2 |
| 소삼원 | 삼원패 2각쯔 + 1머리(역패 2판 별도 합산) | 2 | 2 |
| 혼노두 | 요구패만(또이또이 또는 칠대자와 복합) | 2 | 2 |
3판 / 6판
| 역 | 조건 | 멘젠 | 울기 |
|---|---|---|---|
| 혼일색 | 한 종류 수패 + 자패 | 3 | 2 |
| 준찬타(순전대요구) | 모든 면자·머리에 노두패, 자패 없음, 순쯔 1개 이상 | 3 | 2 |
| 량페코 | 이페코 2개(칠대자와 중복 불가, 높은 쪽 채택) | 3 | 불가 |
| 청일색 | 한 종류 수패만 | 6 | 5 |
역만(기본점 8000, 일반 역과 합산하지 않음. 여러 역만은 합산)
| 역만 | 조건 | 비고 |
|---|---|---|
| 국사무쌍 | 요구패 13종 각 1장 + 그중 1장 더 | 13면 대기(13종 모두 가진 상태에서 화료)는 더블 |
| 사암각 | 암각 4개(쯔모, 또는 단기 대기 론) | 단기 대기는 더블. 샤보 대기 론은 산안커+또이또이 |
| 대삼원 | 삼원패 3각쯔 | 책임지불 대상 |
| 소사희 | 풍패 3각쯔 + 풍패 머리 | |
| 대사희 | 풍패 4각쯔 | 더블. 책임지불 대상 |
| 자일색 | 자패만 | 칠대자형 인정 |
| 청노두 | 노두패만 | |
| 녹일색 | 23468s·발만(발 없어도 인정) | |
| 구련보등 | 멘젠 청일색 1112345678999 + 같은 종류 1장 | 9면 대기(순정)는 더블 |
| 사깡쯔 | 깡 4개 | |
| 천화 | 친이 배패 14장으로 화료 | 멘젠 한정 |
| 지화 | 자가 첫 쯔모로 화료, 그 전에 울기 없음 | 멘젠 한정 |
| 헤아림 역만 | 일반 역+도라 합 13판 이상 | `kazoeYakuman` |
`doubleYakuman=false`면 더블 역만도 1배 역만으로 계산한다.
### 6.2 역 판정 세부
- 멘젠: 치·퐁·대명깡·가깡이 없음(안깡은 멘젠 유지).
- 한 손패를 여러 면자 구성으로 해석할 수 있으면 **점수가 가장 높은 해석**을 고른다(판 → 부 → 최종 점수 순). 대기 형태도 같다. 예: 23455m에 5m 화료는 단기로 볼 수도 있고 양면으로 볼 수도 있다.
- 배타 관계: 량페코는 이페코를 포함하므로 이페코 별도 없음. 준찬타 ⊃ 찬타. 청일색 ⊃ 혼일색. 혼노두와 찬타는 동시 불가(찬타는 순쯔 필수). 칠대자와 핑후·이페코는 불가.
- 핑후 머리: 삼원패, 장풍, 자풍이면 불가. 그 밖의 풍패(손님 바람) 머리는 된다.
- 역패 머리는 판이 아니라 부(머리 2부)만 준다.
- 도라: 표시패의 다음 패. 수패 1→2→…→9→1, 풍패 동→남→서→북→동, 삼원패 백→발→중→백. 표시패 수만큼 따로 센다(같은 도라가 두 번 표시되면 장당 2).
- 우라도라: 리치 화료자만. 화료 시 공개된 도라 표시패마다 그 아래 패를 뒤집는다.
- 적도라: 장당 +1. 북 뽑기(산마): 장당 +1.
- 도라류는 역이 있어야만 더한다. 헤아림 역만 판정에는 포함한다.
### 6.3 부(符) 계산
1. 칠대자: **25부 고정**(아래 계산 없음).
2. 핑후 쯔모: **20부 고정**. 핑후 론: 30부(20 + 멘젠론 10).
3. 그 밖의 경우:
- 부저 20
- 멘젠 론 +10
- 쯔모 +2(핑후 쯔모 제외, 영상개화도 +2)
- 면자 부(론으로 완성한 각쯔는 명각으로 계산)
| 면자 | 중장패 | 요구패 |
|---|---|---|
| 순쯔 | 0 | 0 |
| 명각 | 2 | 4 |
| 암각 | 4 | 8 |
| 명깡 | 8 | 16 |
| 암깡 | 16 | 32 |
- 머리: 삼원패 2, 장풍 2, 자풍 2, 연풍패(장풍=자풍) `renpuuPairFu`(기본 4), 그 밖 0
- 대기: 간짱(4_6), 변짱(12_ / _89), 단기 +2 / 양면, 샤보 0
- 합계를 **10 단위로 올림**.
- 울었고 합계가 20부(부가 하나도 없는 울기 핑후형)이면 **30부**로 한다.
4. 예: 멘젠 론, 중 암각(8) + 9s 암각(8) + 간짱(2) → 20+10+8+8+2 = 48 → 50부.
### 6.4 점수 공식
- 기본점 = 부 x 2^(판+2). 단 2000을 넘으면 만관(2000)으로 자른다.
- 판에 따른 상한: 5판 만관 2000 / 6~7판 하네만 3000 / 8~10판 배만 4000 / 11~12판 삼배만 6000 / 13판 이상 헤아림 역만 8000(옵션 off면 삼배만) / 역만 8000 x 배수.
- 3판 70부 이상, 4판 40부 이상은 계산값이 만관(2000)을 넘으므로 만관. `kiriageMangan`이면 4판30부·3판60부도 만관.
- 지불(각 지불액을 **100 단위 올림**):
- 자 론: 방총자가 기본점 x 4
- 친 론: 방총자가 기본점 x 6
- 자 쯔모: 친이 기본점 x 2, 다른 자가 각 기본점 x 1
- 친 쯔모: 자 전원이 각 기본점 x 2
- 그다음 본장 보너스와 공탁을 더한다.
만관 이상 표
| 이름 | 판 | 기본점 | 자 론 | 자 쯔모(자/친) | 친 론 | 친 쯔모(각자) |
|---|---|---|---|---|---|---|
| 만관 | 5 (또는 3판70+ / 4판40+) | 2000 | 8000 | 2000 / 4000 | 12000 | 4000 |
| 하네만 | 6~7 | 3000 | 12000 | 3000 / 6000 | 18000 | 6000 |
| 배만 | 8~10 | 4000 | 16000 | 4000 / 8000 | 24000 | 8000 |
| 삼배만 | 11~12 | 6000 | 24000 | 6000 / 12000 | 36000 | 12000 |
| 역만 | 13+ / 역만 | 8000 | 32000 | 8000 / 16000 | 48000 | 16000 |
| 더블 역만 | | 16000 | 64000 | 16000 / 32000 | 96000 | 32000 |
1~4판 점수표("-"는 불가능한 조합. 쯔모 칸의 "a/b"는 자 a, 친 b. "올"은 각자)
자(비친) 화료 (론 / 쯔모)
| 부 | 1판 | 2판 | 3판 | 4판 |
|---|---|---|---|---|
| 20 | - / - | - / 400/700 | - / 700/1300 | - / 1300/2600 |
| 25 | - / - | 1600 / - | 3200 / 800/1600 | 6400 / 1600/3200 |
| 30 | 1000 / 300/500 | 2000 / 500/1000 | 3900 / 1000/2000 | 7700 / 2000/3900 |
| 40 | 1300 / 400/700 | 2600 / 700/1300 | 5200 / 1300/2600 | 8000(만관) / 2000/4000 |
| 50 | 1600 / 400/800 | 3200 / 800/1600 | 6400 / 1600/3200 | 8000(만관) / 2000/4000 |
| 60 | 2000 / 500/1000 | 3900 / 1000/2000 | 7700 / 2000/3900 | 8000(만관) / 2000/4000 |
| 70 | 2300 / 600/1200 | 4500 / 1200/2300 | 8000(만관) / 2000/4000 | 8000(만관) / 2000/4000 |
| 80 | 2600 / 700/1300 | 5200 / 1300/2600 | 8000(만관) / 2000/4000 | 8000(만관) / 2000/4000 |
| 90 | 2900 / 800/1500 | 5800 / 1500/2900 | 8000(만관) / 2000/4000 | 8000(만관) / 2000/4000 |
| 100 | 3200 / 800/1600 | 6400 / 1600/3200 | 8000(만관) / 2000/4000 | 8000(만관) / 2000/4000 |
| 110 | 3600 / 900/1800 | 7100 / 1800/3600 | 8000(만관) / 2000/4000 | 8000(만관) / 2000/4000 |
친 화료 (론 / 쯔모)
| 부 | 1판 | 2판 | 3판 | 4판 |
|---|---|---|---|---|
| 20 | - / - | - / 700 올 | - / 1300 올 | - / 2600 올 |
| 25 | - / - | 2400 / - | 4800 / 1600 올 | 9600 / 3200 올 |
| 30 | 1500 / 500 올 | 2900 / 1000 올 | 5800 / 2000 올 | 11600 / 3900 올 |
| 40 | 2000 / 700 올 | 3900 / 1300 올 | 7700 / 2600 올 | 12000(만관) / 4000 올 |
| 50 | 2400 / 800 올 | 4800 / 1600 올 | 9600 / 3200 올 | 12000(만관) / 4000 올 |
| 60 | 2900 / 1000 올 | 5800 / 2000 올 | 11600 / 3900 올 | 12000(만관) / 4000 올 |
| 70 | 3400 / 1200 올 | 6800 / 2300 올 | 12000(만관) / 4000 올 | 12000(만관) / 4000 올 |
| 80 | 3900 / 1300 올 | 7700 / 2600 올 | 12000(만관) / 4000 올 | 12000(만관) / 4000 올 |
| 90 | 4400 / 1500 올 | 8700 / 2900 올 | 12000(만관) / 4000 올 | 12000(만관) / 4000 올 |
| 100 | 4800 / 1600 올 | 9600 / 3200 올 | 12000(만관) / 4000 올 | 12000(만관) / 4000 올 |
| 110 | 5300 / 1800 올 | 10600 / 3600 올 | 12000(만관) / 4000 올 | 12000(만관) / 4000 올 |
엔진은 표가 아니라 공식으로 계산하고, 이 표는 단위 테스트의 기대값으로 쓴다.
### 6.5 책임지불(파오, `pao`)
- 대삼원의 3번째 삼원 각쯔, 대사희의 4번째 풍패 각쯔를 퐁·대명깡으로 확정시킨 타패자가 책임자다.
- 그 역만을 쯔모하면 책임자가 전액(역만 부분) 낸다. 다른 사람이 방총하면 방총자와 책임자가 반씩 낸다. 본장은 쯔모면 책임자, 론이면 방총자가 낸다.
### 6.6 계산 예시
1. 자, 멘젠 론. 손: 234m 567p 789s 22s 45p + 화료 3p(양면). 역: 핑후 1판. 도라 없음. 부: 30(핑후 론). → 1000점. 본장 1개면 1300점.
2. 친, 쯔모. 리치·멘젠쯔모·탕야오·도라1 = 4판. 부: 20 + 쯔모 2 + 암각(중장) 4 + 간짱 2 = 28 → 30부. 기본점 30 x 2^6 = 1920 → 친 쯔모 각 1920 x 2 = 3840 → 3900 올(합 11700).
3. 자, 론. 혼일색(울기 2) + 백 1 + 도라 2 = 5판 → 만관 8000.
4. 칠대자 리치 론, 도라 2 = 5판 → 만관. 칠대자 리치 론 도라 0 = 3판 25부 → 기본점 25 x 32 = 800 → 자 3200.
5. 더블론: 자 A(3900)와 친 B(12000)가 동시에 같은 사람의 타패로 론. 방총자가 둘 다 지불(15900). 공탁과 본장은 방총자 다음 차례에서 가까운 쪽이 받는다.
6. 산마, 쯔모손: 자 만관 쯔모 → 친 4000, 자 2000, 합 6000.
### 6.7 최종 순위와 결과 점수
- 순위: 최종 점수 내림차순. **동점이면 기가(좌석 0)에 가까운 좌석이 위**.
- 남은 공탁은 1위에게.
- 결과 점수(방 표시용, 천 점 단위 소수 1자리): `(점수 - returnPoints) / 1000 + 우마`. 1위에게 오카 `(returnPoints - startPoints) x 인원 / 1000`을 더한다(4인 기본 +20, 3인 기본 +15).
- 예(4인, 25000/30000, 우마 10-20): 최종 42000 / 31000 / 18000 / 9000 → 1위 (12)+20+20 = +52.0, 2위 1+10 = +11.0, 3위 -12-10 = -22.0, 4위 -21-20 = -41.0. 합 0.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 울어도 탕야오(쿠이탕): `kuitan` (기본 on)
- 적도라 수: `akaDora`
- 더블론/머리튀기, 삼가화: `doubleRon`, `tripleRon`
- 토비 종료, 아가리야메, 서입: `tobi`, `agariYame`, `westEntry`
- 텐파이 연장/화료 연장: `renchanOn`
- 절상만관: `kiriageMangan`
- 헤아림 역만, 더블 역만: `kazoeYakuman`, `doubleYakuman`
- 도중유국, 유국만관, 책임지불: `abortiveDraws`, `nagashiMangan`, `pao`
- 국사무쌍 안깡 창깡: `ankanChankanKokushi`
- 연풍패 머리 2부/4부: `renpuuPairFu`
- 산마 쯔모손/북가절반, 본장 금액, 텐파이료 총액
- 지원하지 않는 로컬 역(향후 확장 후보): 인화, 대차륜, 8연장, 오픈 리치, 십삼불탑, 석상삼년 등. 기본 규칙과 섞이면 혼란이 커서 1차 범위에서 뺀다.
### 7.1 초보 모드(개인별, `beginnerMode` 허용 시)
- 손패 자동 정렬(만→통→삭→자, 숫자순, 쯔모패는 오른쪽에 띄움). 자동 정렬은 모든 사람에게 기본이고 끌 수 없다.
- 텐파이 유지 타패 하이라이트: 버리면 텐파이가 되는 패 위에 표시, 탭하면 그 경우의 대기패와 남은 장수(내가 볼 수 있는 정보로만 계산)를 보여 준다.
- 역 힌트: 지금 손에서 노릴 만한 역 후보(탕야오, 역패, 혼일색 등)와 "역이 없음" 경고를 띄운다.
- 화료 가능하면 [쯔모]/[론] 버튼을 크게 강조하고 진동을 준다. 후리텐이면 "후리텐: 론 불가, 쯔모만 가능" 배지를 띄운다.
- 울기 버튼 옆에 "울면 리치를 못 해요" 같은 짧은 설명을 붙인다.
- 자동 화료(쯔모·론 자동), 울기 안 함(자동 패스) 토글.
- 초보 모드 계산은 모두 본인 `view`에 이미 있는 정보만으로 클라이언트에서 해도 된다. 다른 사람 손패나 패산 정보는 쓰지 않는다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
// 패 표현: 0..135 고유 ID. kind = id >> 2 (0..33)
// kind 0-8: 1m-9m, 9-17: 1p-9p, 18-26: 1s-9s, 27-30: 동남서북, 31-33: 백발중
// 적도라: ID 16(5m), 52(5p), 88(5s) — 옵션 수만큼만 적도라로 취급
type TileId = number;
type Kind = number;
type Seat = 0 | 1 | 2 | 3;
interface MahjongOptions {
length: 'tonpuu' | 'hanchan';
startPoints: number; returnPoints: number;
uma: 'none' | '5-10' | '10-20' | '10-30' | '0' | '15';
akaDora: 0 | 2 | 3; kuitan: boolean;
doubleRon: 'allow' | 'headBump'; tripleRon: 'abort' | 'allow';
tobi: boolean; agariYame: boolean; westEntry: boolean;
renchanOn: 'tenpai' | 'agari';
kiriageMangan: boolean; kazoeYakuman: boolean; doubleYakuman: boolean;
abortiveDraws: boolean; nagashiMangan: boolean; pao: boolean;
ankanChankanKokushi: boolean; renpuuPairFu: 2 | 4;
sanmaTsumo: 'tsumoLoss' | 'northBisection'; sanmaHonba: 200 | 300 | 1000; sanmaNotenPool: 2000 | 3000;
turnSeconds: number; claimSeconds: number; timeBankSeconds: number;
beginnerMode: boolean;
}
type Meld =
| { type: 'chi'; tiles: [TileId, TileId, TileId]; called: TileId; from: Seat }
| { type: 'pon'; tiles: [TileId, TileId, TileId]; called: TileId; from: Seat }
| { type: 'daiminkan'; tiles: TileId[]; called: TileId; from: Seat }
| { type: 'kakan'; tiles: TileId[]; called: TileId; from: Seat; added: TileId }
| { type: 'ankan'; tiles: TileId[] };
interface DiscardEntry { tile: TileId; tsumogiri: boolean; riichiDeclare: boolean; calledBy: Seat | null; }
interface ClaimWindow {
source: 'discard' | 'kakan' | 'ankan' | 'kita';
tile: TileId; from: Seat;
eligible: Partial<Record<Seat, Array<'ron' | 'pon' | 'kan' | 'chi'>>>; // 서버 내부 전용
responses: Partial<Record<Seat, ClaimResponse>>;
deadline: number;
riichiPending: boolean; // 이 타패가 리치 선언 타패인지
}
type ClaimResponse = { type: 'pass' } | { type: 'ron' } | { type: 'pon' | 'kan' | 'chi'; tiles: TileId[] };
interface HandState {
liveWall: TileId[]; // 앞에서 쯔모, 깡/북 시 끝에서 1장을 왕패로 이동
deadWall: TileId[]; // 항상 14장 유지 (영상패, 도라/우라 표시패)
rinshanUsed: number;
doraIndicators: number; // 공개된 표시패 수 (1~5)
pendingKanDora: number; // 대명깡/가깡 후 다음 타패 때 공개할 수
hands: TileId[][]; // seat별 손패 (정렬 상태 무관)
drawn: (TileId | null)[]; // 이번 차례 쯔모패 (쯔모기리·리치 판정용)
melds: Meld[][];
kita: TileId[][]; // 산마 북 뽑기
discards: DiscardEntry[][];
riichi: ({ discardIndex: number; double: boolean; ippatsuLive: boolean } | null)[];
furiten: { tempUntilNextDiscard: boolean; riichiPermanent: boolean }[];
turn: Seat;
stage: 'draw' | 'afterDraw' | 'afterCall' | 'claim' | 'ended';
firstRoundClean: boolean; // 첫 순이 울기 없이 진행 중 (더블리치/천화/지화/구종구패/사풍연타)
firstDiscardWinds: (Kind | null)[];
kanCount: number; kanBySeat: number[];
lastDrawWasRinshan: boolean; // 영상개화 판정
paoBy: Partial<Record<Seat, { yakuman: 'daisangen' | 'daisuushii'; liable: Seat }>>;
claim: ClaimWindow | null;
deadline: number | null;
turnStartedAt: number;
}
interface HandResultRecord {
kind: 'agari' | 'exhaustive' | 'abortive';
winners?: Array<{ seat: Seat; from: Seat | null; han: number; fu: number; yaku: Array<{ name: string; han: number }>;
yakumanMultiplier: number; points: number; hand: TileId[]; melds: Meld[]; winTile: TileId;
uraIndicators: TileId[] }>;
tenpai?: Seat[]; abortiveReason?: 'kyuushu' | 'suufon' | 'suukan' | 'suucha' | 'sanchaho';
deltas: number[]; // seat별 점수 변화
}
interface MahjongState {
options: MahjongOptions;
mode: 'yonma' | 'sanma';
players: PlayerId[]; // index = seat
rng: RngState;
points: number[];
roundWind: 0 | 1 | 2 | 3; // 동/남/서/북
handNumber: number; // 0..3 (산마 0..2)
dealer: Seat;
honba: number;
riichiPool: number; // 공탁 점수 (1000 단위)
hand: HandState | null;
phase: 'playing' | 'handResult' | 'finished';
lastHandResult: HandResultRecord | null;
readyForNext: Seat[]; // 결과 화면 확인
timeBank: number[]; // seat별 남은 예비 시간(ms)
autoPilot: boolean[]; // 연속 시간 초과/연결 끊김
prefs: { autoWin: boolean; noCalls: boolean }[];
history: HandResultRecord[];
stats: { wins: number; dealIns: number; riichi: number; calls: number }[];
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `discard` | `{ tile: TileId; riichi?: boolean }` | 차례인 사람, stage `afterDraw`/`afterCall` | tile이 손패에 있음. 리치 중이면 tile == drawn. `afterCall`이면 구이가에 금지 패 아님. `riichi`면 5.4 조건 모두 충족 + 버린 뒤 텐파이 |
| `tsumo` | `{}` | 차례인 사람, `afterDraw` | 화료형 + 역 1개 이상 |
| `ankan` | `{ kind: Kind }` | 차례인 사람, `afterDraw` | 같은 kind 4장 보유, 산패 ≥1, kanCount<4, 리치 중이면 drawn 패로 하는 깡이고 대기·면자 해석이 바뀌지 않음 |
| `kakan` | `{ tile: TileId }` | 차례인 사람, `afterDraw` | 같은 kind 퐁 보유, 산패 ≥1, kanCount<4, 리치 아님 |
| `kita` | `{}` | 산마, 차례인 사람, `afterDraw` | 북 보유, 산패 ≥1, 리치 중이면 대기 불변 |
| `kyuushu` | `{}` | 차례인 사람, 첫 쯔모 직후 | `abortiveDraws`, firstRoundClean, 자기 첫 쯔모, 요구패 종류 ≥9 |
| `claim` | `{ type: 'ron' } \| { type: 'pon' \| 'kan' \| 'chi'; tiles: TileId[] }` | 응답 창의 `eligible` 대상자 중 미응답자 | 해당 타입이 eligible에 있음. tiles가 손패에 있고 대상 패와 맞는 조합. 치/퐁은 울고 난 뒤 구이가에 아닌 버릴 패가 1장 이상 존재 |
| `pass` | `{}` | 응답 창 미응답자 | eligible 대상자 |
| `ready` | `{}` | phase `handResult`의 모든 플레이어 | 아직 ready 안 함 |
| `setPref` | `{ autoWin?: boolean; noCalls?: boolean }` | 언제든 본인 | 불리언 |
- `validate` 실패 사유 예(한국어): "지금은 당신 차례가 아닙니다", "리치 후에는 쯔모한 패만 버릴 수 있습니다", "역이 없어 화료할 수 없습니다", "후리텐이라 론할 수 없습니다", "방금 울은 패와 같은 패는 버릴 수 없습니다", "남은 패가 부족해 리치할 수 없습니다", "점수가 1000점 미만이라 리치할 수 없습니다".
- `apply` 이벤트 예: `draw`(본인에게만 패 정보, 다른 사람에게는 "쯔모함"), `discard`, `riichi`, `call`, `kan`, `doraRevealed`, `kita`, `claimWindowOpened`(본인 옵션만), `agari`, `ryuukyoku`, `pointsChanged`, `handStarted`, `gameEnded`.
- `activePlayers`: stage가 `draw`/`afterDraw`/`afterCall`이면 `[turn]`. `claim`이면 eligible이면서 미응답인 사람들. `handResult`면 ready 안 한 사람들. `finished`면 `[]`.
- 쯔모(`draw`)는 액션이 아니다. 서버가 이전 상태를 확정할 때 `apply` 안에서 자동으로 한다(stage `draw` → `afterDraw`).
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함)에게 공개: 좌석·닉네임·점수, 장/국/본장/공탁, 친, 공개된 도라 표시패, 남은 산패 수, 모든 버림패(쯔모기리 여부·리치 표시·울린 패 표시), 모든 노출 면자, 안깡(양 끝 2장만 뒷면 표시하되 kind는 공개), 북 뽑기, 리치 여부, 각자 손패 장수, 현재 차례, 마감 시각.
- 본인에게만: 내 손패 전체, 내 쯔모패, 내 합법 액션 목록(타패 가능 패, 리치 가능 패, 깡/북/쯔모 가능 여부), 응답 창이 열렸을 때 **내** 응답 선택지, 내 후리텐 상태, 내 예비 시간.
- 절대 보내지 않음: 패산·왕패 순서, 공개 안 된 도라/우라 표시패, 남의 손패, 남의 `eligible`(응답 가능 여부), 남의 응답 내용(확정 전), RNG 상태.
- 국 종료 시 추가 공개: 화료자 손패와 화료패, 우라도라 표시패(리치 화료가 있을 때만), 유국 시 텐파이자 손패(노텐자 손패는 비공개).
- 관전자 view는 `viewer=null`로 공개 정보만 준다. 관전자 지연 송출은 플랫폼 공통 옵션으로 둔다(마작은 기본 지연 없음이지만 손패를 아예 주지 않으므로 누출 위험이 없다).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `setup`: 좌석 순서 셔플(기가 결정).
2. 각 국 시작: 패산 셔플(Fisher–Yates, 136/108장).
3. 주사위 2개 값(연출용, 패산 자르는 위치 표시). 실제 패 순서에는 영향 없음.
- 그 밖에는 RNG를 쓰지 않는다. 시간 초과 자동 행동도 결정적이다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 차례 타이머: `turnSeconds` 소진 후 개인 `timeBank`를 깎는다. 둘 다 0이면 `onTimeout`.
- 응답 창: `claimSeconds` 고정, 예비 시간을 쓰지 않는다.
- `deadline(state)`: 현재 단계의 마감 시각(차례 = 시작 + 기본 + 남은 예비, 응답 창 = 창 마감, 결과 화면 = 표시 후 15초).
- `onTimeout(state, player)`:
- `afterDraw`: `prefs.autoWin`이고 쯔모 화료가 되면 `tsumo`. 아니면 쯔모기리(`discard drawn`). 리치·깡·북·구종구패는 하지 않는다.
- `afterCall`: 손패 맨 오른쪽(정렬 기준 마지막)부터 구이가에가 아닌 첫 패를 버린다.
- `claim`: `prefs.autoWin`이고 론 가능하면 `ron`, 아니면 `pass`(동순/리치 후리텐 규칙 적용).
- `handResult`: `ready`.
- 연속 2회 시간 초과나 연결 끊김이면 `autoPilot=true`가 된다. 이때 차례 마감을 2초, 응답 창 대상이면 즉시 `pass` 처리해 다른 사람을 기다리게 하지 않는다. 재접속해 액션을 1회 보내면 해제된다.
- 응답 창은 자동 행동이 와도 확정 규칙(5.3)대로 처리한다. 서버는 항상 도착 순서대로 처리하며, 마감 시각 이후 도착한 응답은 버린다.
### 8.6 종료 조건과 결과(GameResult)
- 5.10의 조건이 되면 phase=`finished`.
- `result(state)`:
```ts
interface MahjongResult {
ranking: Array<{ player: PlayerId; seat: Seat; rank: 1 | 2 | 3 | 4; points: number; score: number /* 우마·오카 반영, 소수 1자리 */ }>;
summary: {
rounds: number; // 진행한 국 수
bestHand: { player: PlayerId; han: number; fu: number; yaku: string[]; points: number } | null;
perPlayer: Array<{ player: PlayerId; wins: number; dealIns: number; riichi: number; calls: number }>;
endReason: 'normal' | 'tobi' | 'agariYame' | 'westEntryEnd';
};
}
```
## 9. UI/UX
- 화면 배치(모바일 세로)
- 아래: 내 손패 14장 한 줄. 세로 360px 폭이면 패 1장이 약 24px로 48px 터치 기준보다 좁다. 그래서 (1) 패 영역 높이를 64px 이상으로 잡고 (2) 탭하면 그 패를 확대해 띄우고(선택), 다시 탭하거나 위로 밀면 버린다(2단계 확정). 큰 [버리기] 버튼도 함께 둔다. 오탭 방지를 위해 기본은 2단계 확정이며 설정에서 1탭 버리기로 바꿀 수 있다.
- 가운데: 4방향 버림패(6장 x 3줄), 중앙 정보판(장/국, 본장, 공탁, 남은 패 수, 도라 표시패, 각자 점수와 바람).
- 위·좌·우: 상대 이름, 점수, 리치봉, 노출 면자.
- 응답 창: 화면 아래쪽에 [론] [퐁] [깡] [치] [패스] 큰 버튼(높이 56px 이상), 남은 시간 원형 게이지. 치 조합이 여럿이면 조합 그림 버튼을 따로 띄운다.
- 내 차례 행동 버튼: [쯔모] [리치] [깡] [북](산마) [구종구패].
- PC/가로: 정사각 테이블 배치. 가로 화면을 권장 안내로 띄운다.
- 합법 수 하이라이트: 리치 선언 모드에서 리치 가능한 타패만 밝게, 구이가에 금지 패는 회색 + 자물쇠.
- 애니메이션: 쯔모/타패 150ms, 울기 시 "퐁!" 같은 큰 글자(텍스트, 음성 옵션), 화료 화면에서 역 목록이 한 줄씩 나오고 판/부/점수 표시. 모든 애니메이션은 "애니메이션 줄이기" 설정으로 끌 수 있다.
- 패 디자인: 큰 글씨 모드(한자 대신 한글 보조 표기 "1만", "동", "백" 병기)를 기본으로 켠다. 색약 대응으로 종류별 테두리 무늬를 넣는다.
- 규칙 보기: 역 목록(그림 예시), 점수표, 후리텐 설명, 용어집. 대국 중 하단 [?] 버튼으로 연다.
- 결과 화면: 국별 점수 변화 그래프, 최종 순위, 다시 하기.
## 10. 테스트 체크리스트
- [ ] 셋업: 같은 시드면 같은 좌석·패산·배패가 나온다. 4인 산패 70장, 산마 55장, 왕패 14장.
- [ ] 산마 패 구성: 2m~8m 없음, 108장, 1m 표시 → 9m 도라.
- [ ] 화료 판정: 칠대자에서 같은 패 4장은 2쌍으로 인정하지 않는다. 국사무쌍 13면 대기에서 더블 역만(옵션 on/off).
- [ ] 고점법: 223344m 556677p 88s(칠대자로도 량페코로도 해석 가능)에서 점수가 높은 쪽(량페코)을 고른다. 23455m에 5m 화료 시 단기(+2부) 대 양면(핑후) 중 점수가 높은 쪽.
- [ ] 부 계산: 6.6 예시 2(28→30부, 친 쯔모 3900 올), 울기 20부 → 30부, 핑후 쯔모 20부, 칠대자 25부.
- [ ] 점수표: 6.4의 1~4판 x 20~110부 표 전 칸과 공식 결과가 일치한다(론 불가 칸은 상태 생성 단계에서 막힘).
- [ ] 응답 창 우선순위: A가 치, B가 퐁을 동시에 선언 → B가 이김. B가 퐁 선언 후 C(론 가능)가 미응답 → 확정하지 않고 C를 기다린다.
- [ ] 응답 창 조기 확정: 퐁 선언 후 남은 미응답자가 치만 가능 → 즉시 확정.
- [ ] 더블론: 두 사람 론 → 둘 다 정산, 공탁·본장은 방총자 다음 순으로 가까운 사람. `headBump`면 그 1명만. 3명 론 + `abort` → 삼가화 유국.
- [ ] 후리텐: 내가 버린 패가 대기 → 론 버튼 없음, 쯔모는 가능. 론 패스 후 같은 순 다른 사람 타패로 론 불가, 내 타패 후 해제. 리치 후 놓침 → 국 끝까지 론 불가.
- [ ] 역 없는 화료 시도: 울기 후 역 없는 손에서 `tsumo` → "역이 없어 화료할 수 없습니다". 가타아가리 대기에서 역 없는 패로 론 불가.
- [ ] 리치 검증: 999점에서 리치 불가. 남은 패 3장(4인)에서 리치 불가. 리치 선언 타패가 론 당하면 공탁 1000이 들어가지 않는다.
- [ ] 리치 후 안깡: 대기가 바뀌는 안깡은 거부된다(예: 3334m 리치 중 3m 안깡).
- [ ] 구이가에: 34m 상태로 2m 치 후 5m·2m 타패 거부. 버릴 수 있는 패가 없게 되는 치는 응답 선택지에 아예 나오지 않는다.
- [ ] 깡: 대명깡 후 영상개화 → 새 도라 미공개. 대명깡 후 타패 → 그 시점에 도라 공개되고 그 타패로 론하면 새 도라 포함. 가깡 → 창깡 론 가능, 창깡 1판. 2명 합계 4깡 후 타패 통과 → 사깡유국.
- [ ] 하저패: 마지막 타패에 퐁/치 선택지가 없고 론만 있다. 해저 쯔모 후 깡 불가.
- [ ] 유국: 텐파이 1/2/3명 각각 텐파이료(4인 3000, 산마 2000). 대기패 4장 자가 보유 형태는 노텐. 친 노텐이면 친 이동, 본장 +1.
- [ ] 유국만관: 요구패만 버리고 울리지 않음 → 만관 지불, 텐파이료 없음.
- [ ] 도중유국: 구종구패 선언 → 친 연장, 본장 +1, 공탁 유지. 사풍연타, 사가리치.
- [ ] 책임지불: 백·발 퐁 후 중 퐁을 준 사람이 있을 때 대삼원 쯔모 → 책임자 전액. 다른 사람 방총 → 반반.
- [ ] 종료: 남4 자 화료로 종료. 남4 친 1위 화료 + agariYame → 종료. 토비(-100점)로 즉시 종료, 0점은 계속. 남4 후 전원 30000 미만 → 서입, 서1에서 30000 넘으면 종료.
- [ ] 순위 동점: 점수가 같으면 기가에 가까운 좌석이 위. 남은 공탁 1위 지급, 우마·오카 합 0.
- [ ] 산마: 북 뽑기 → 영상패 쯔모, 북 도라 +1. 북 뽑기 패로 론 가능. 쯔모손 계산 6000. 치 선택지 없음.
- [ ] 시간 초과: 차례 시간 초과 → 쯔모기리, 예비 시간 감소. 응답 창 시간 초과 → 패스 + 동순 후리텐. 2회 연속 → autoPilot, 다음 차례 2초 처리. 재접속 후 액션 → 해제.
- [ ] 연결 끊김 중 응답 창: 끊긴 사람이 론 가능해도 다른 사람의 퐁이 무한정 기다리지 않는다(즉시 패스 처리).
- [ ] 정보 누출: 다른 사람 view와 관전자 view에 내 손패, 쯔모패, 패산, 미공개 도라/우라, `eligible`, 응답 내용이 없다(JSON 전체 문자열 검색으로 TileId 누출 확인). 응답 창 지연이 응답 가능 여부와 상관없이 최소 400ms로 같다.
- [ ] 재현성: 같은 시드 + 같은 액션 로그 → 같은 최종 상태와 결과.
## 11. 참고 자료
- 일본 마작 위키백과(역 목록·판수): https://en.wikipedia.org/wiki/Japanese_mahjong_yaku
- 일본 마작 점수 규칙(부 계산, 기본점 공식, 만관 이상, 지불, 본장, 텐파이료): https://en.wikipedia.org/wiki/Japanese_mahjong_scoring_rules
- Riichi Wiki — Mahjong Soul 규칙, 산마(쯔모손, 북 뽑기, 본장): https://riichi.wiki/Mahjong_Soul , https://riichi.wiki/Sanma
- 산마 규칙 체크리스트(Memojong): https://memojong.com/en/guides/sanma-rule-list/
- 나무위키 "리치 마작" 문서(한국어 용어 대조용)
- World Riichi Championship 규칙(대회 표준 규칙, 연풍패·깡 도라 시점 등 비교용)
## 12. 메모 (상표·법적 주의 등)
- "작혼", "Mahjong Soul", "天鳳/텐호", "M리그" 등은 상표이거나 서비스 이름이다. 화면·마케팅에 쓰지 않는다. 게임 이름은 일반 명칭인 "마작" 또는 "리치 마작"을 쓴다(사용자 선택 후보: "마작", "리치 마작", "4인 마작").
- 패 그림은 직접 그리거나 라이선스가 확인된 공개 자산(예: CC0/CC-BY 마작패 SVG)을 쓴다. 상용 게임 패 그림을 가져오지 않는다.
- 법적 주의: 마작은 점수 내기로 흐르기 쉬운 게임이다. 이 사이트의 점수는 방 안에서만 쓰는 가상 점수이며, 대국이 끝나면 기록용 결과만 남고 재화·포인트로 쌓이거나 바뀌지 않는다. 유료 아이템·재화·환전·선물 기능을 붙이면 「게임산업진흥에 관한 법률」의 사행성·환전 금지 규정(제28조, 제32조)과 형법 도박죄(제246조) 문제가 생기므로 넣지 않는다.
- 게임물 등급분류: 국내에서 온라인 게임을 서비스하려면 원칙적으로 게임물관리위원회 등급분류가 필요하다(게임산업법 제21조). 비영리·무료라도 예외 해당 여부를 출시 전에 확인해야 한다. 마작은 화투·포커류 웹보드 게임과 달리 사행성 운영이 없으면 청소년이용불가로 일괄 분류되지는 않는다. 다만 대상 연령(아이 포함)을 고려해 사전 검토를 권장한다.
- 용어 표기는 한국 커뮤니티 관용(작혼 한국어판·나무위키)에 맞추고, 화면에는 일본어 원어를 괄호로 보조 표기한다.

350
docs/games/marble.md Normal file
View File

@@ -0,0 +1,350 @@
# 세계 여행 땅따먹기 (`marble`)
> 마일스톤: M6 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~50분(기본 20라운드 제한) · 난이도: 쉬움~보통
## 1. 개요
- 주사위 2개를 굴려 세계 도시 40칸 판을 돌며 땅을 사고 건물을 지어, 남이 내 땅에 오면 통행료를 받는 "주사위 땅따먹기" 게임. 상대를 모두 파산시키거나, 제한 라운드가 끝났을 때 총자산이 가장 많은 사람이 이긴다.
- 한국에서는 1982년 출시된 "부루마불"(씨앗사)과 모바일 게임 "모두의마블"(넷마블)로 세대를 가리지 않고 알려진 장르다. 이 문서는 두 게임의 공통 뼈대(무인도, 사회복지기금, 우주여행, 카드 칸, 더블 규칙, 파산)를 따르되, 판·도시 배치·가격·카드는 모두 자체 설계했다. 모두의마블식 요소(인수, 랜드마크, 독점 승리)는 옵션으로 둔다.
- 인원 근거: 부루마불 기본 2~4명. 모두의마블도 개인전 최대 4명.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `startMoney` | 시작 자금(만) | 200, 300, 500 | 300 |
| `salary` | 출발 칸을 지나거나 도착할 때 받는 월급(만) | 10, 20, 30 | 20 |
| `maxRounds` | 라운드 제한(0 = 무제한, 파산으로만 종료) | 0, 15, 20, 30 | 20 |
| `buildOnPurchase` | 땅을 산 그 방문에서 별장까지 바로 지을 수 있는지 | `true`, `false` | `false` |
| `islandTurns` | 무인도에서 최대 쉬는 차례 수 | 1, 2, 3 | 3 |
| `islandFee` | 무인도 탈출 비용을 내고 바로 나갈 수 있는지(만, 0 = 불가) | 0, 10, 20 | 0 |
| `takeover` | 인수: 남의 도시에 도착해 통행료를 낸 뒤 2배 값에 강제 매입(모두의마블식) | `true`, `false` | `false` |
| `landmark` | 호텔 다음 단계 랜드마크(인수 불가, 통행료 최고) | `true`, `false` | `false` |
| `monopolyWin` | 독점 즉시 승리(트리플·라인·관광지) | `true`, `false` | `false` |
| `trade` | 플레이어 간 도시·돈 교환 | `true`, `false` | `false` |
| `turnSeconds` | 결정 1회당 제한 시간 | 15, 30, 60, `null` | 30 |
- 돈은 방 안에서만 쓰는 가상 화폐이며 단위는 "만"으로 표시한다. 게임이 끝나면 사라지고 계정에 저장·이월·교환되지 않는다.
## 3. 구성물
- 판 40칸(아래 표), 주사위 2개, 말 4개, 땅 문서(도시 24 + 관광지 4), 건물(별장·빌딩·호텔·랜드마크), 행운카드 20장, 은행(무제한 돈), 사회복지기금 적립금.
### 3.1 판 배치(시계 방향 0→39)
| 칸 | 이름 | 종류 | | 칸 | 이름 | 종류 |
|---|---|---|---|---|---|---|
| 0 | 출발 | 모서리 | | 20 | 사회복지기금 받는 곳 | 모서리 |
| 1 | 타이베이 | 도시 A | | 21 | 부에노스아이레스 | 도시 E |
| 2 | 행운카드 | 카드 | | 22 | 행운카드 | 카드 |
| 3 | 마닐라 | 도시 A | | 23 | 상파울루 | 도시 E |
| 4 | 하노이 | 도시 A | | 24 | 시드니 | 도시 E |
| 5 | 제주도 | 관광지 | | 25 | 우주센터 | 관광지 |
| 6 | 방콕 | 도시 B | | 26 | 리스본 | 도시 F |
| 7 | 행운카드 | 카드 | | 27 | 마드리드 | 도시 F |
| 8 | 싱가포르 | 도시 B | | 28 | 행운카드 | 카드 |
| 9 | 카이로 | 도시 B | | 29 | 파리 | 도시 F |
| 10 | 무인도 | 모서리 | | 30 | 우주여행 | 모서리 |
| 11 | 이스탄불 | 도시 C | | 31 | 런던 | 도시 G |
| 12 | 행운카드 | 카드 | | 32 | 밴쿠버 | 도시 G |
| 13 | 아테네 | 도시 C | | 33 | 행운카드 | 카드 |
| 14 | 로마 | 도시 C | | 34 | 샌프란시스코 | 도시 G |
| 15 | 경주 | 관광지 | | 35 | 부산 | 관광지 |
| 16 | 코펜하겐 | 도시 D | | 36 | 뉴욕 | 도시 H |
| 17 | 행운카드 | 카드 | | 37 | 도쿄 | 도시 H |
| 18 | 스톡홀름 | 도시 D | | 38 | 사회복지기금 내는 곳 | 특수 |
| 19 | 베를린 | 도시 D | | 39 | 서울 | 도시 H |
- 합계: 도시 24(8그룹 × 3), 관광지 4, 행운카드 7, 모서리 4, 사회복지기금 내는 곳 1 = 40.
- "라인"(독점 판정용): 1번 줄 1~9, 2번 줄 11~19, 3번 줄 21~29, 4번 줄 31~39(각 줄 도시 6곳, 관광지 제외).
### 3.2 도시 가격표(단위: 만)
- 건설비는 단계마다 따로 내는 금액(별장 = 땅값 × 0.5, 빌딩 × 1.0, 호텔 × 1.5, 랜드마크 × 2.0, 반올림).
- 통행료 = 땅값 × [땅만 0.2(최소 1), 별장 0.6, 빌딩 1.5, 호텔 2.5, 랜드마크 4.0], 반올림.
- `*` 랜드마크 열은 `landmark=true`일 때만 사용.
| 칸 | 도시 | 그룹 | 땅값 | 별장 | 빌딩 | 호텔 | 랜드마크* | 통행료: 땅 | 별장 | 빌딩 | 호텔 | 랜드마크* |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 타이베이 | A | 6 | 3 | 6 | 9 | 12 | 1 | 4 | 9 | 15 | 24 |
| 3 | 마닐라 | A | 6 | 3 | 6 | 9 | 12 | 1 | 4 | 9 | 15 | 24 |
| 4 | 하노이 | A | 8 | 4 | 8 | 12 | 16 | 2 | 5 | 12 | 20 | 32 |
| 6 | 방콕 | B | 10 | 5 | 10 | 15 | 20 | 2 | 6 | 15 | 25 | 40 |
| 8 | 싱가포르 | B | 10 | 5 | 10 | 15 | 20 | 2 | 6 | 15 | 25 | 40 |
| 9 | 카이로 | B | 12 | 6 | 12 | 18 | 24 | 2 | 7 | 18 | 30 | 48 |
| 11 | 이스탄불 | C | 14 | 7 | 14 | 21 | 28 | 3 | 8 | 21 | 35 | 56 |
| 13 | 아테네 | C | 14 | 7 | 14 | 21 | 28 | 3 | 8 | 21 | 35 | 56 |
| 14 | 로마 | C | 16 | 8 | 16 | 24 | 32 | 3 | 10 | 24 | 40 | 64 |
| 16 | 코펜하겐 | D | 18 | 9 | 18 | 27 | 36 | 4 | 11 | 27 | 45 | 72 |
| 18 | 스톡홀름 | D | 18 | 9 | 18 | 27 | 36 | 4 | 11 | 27 | 45 | 72 |
| 19 | 베를린 | D | 20 | 10 | 20 | 30 | 40 | 4 | 12 | 30 | 50 | 80 |
| 21 | 부에노스아이레스 | E | 22 | 11 | 22 | 33 | 44 | 4 | 13 | 33 | 55 | 88 |
| 23 | 상파울루 | E | 22 | 11 | 22 | 33 | 44 | 4 | 13 | 33 | 55 | 88 |
| 24 | 시드니 | E | 24 | 12 | 24 | 36 | 48 | 5 | 14 | 36 | 60 | 96 |
| 26 | 리스본 | F | 26 | 13 | 26 | 39 | 52 | 5 | 16 | 39 | 65 | 104 |
| 27 | 마드리드 | F | 26 | 13 | 26 | 39 | 52 | 5 | 16 | 39 | 65 | 104 |
| 29 | 파리 | F | 28 | 14 | 28 | 42 | 56 | 6 | 17 | 42 | 70 | 112 |
| 31 | 런던 | G | 30 | 15 | 30 | 45 | 60 | 6 | 18 | 45 | 75 | 120 |
| 32 | 밴쿠버 | G | 30 | 15 | 30 | 45 | 60 | 6 | 18 | 45 | 75 | 120 |
| 34 | 샌프란시스코 | G | 32 | 16 | 32 | 48 | 64 | 6 | 19 | 48 | 80 | 128 |
| 36 | 뉴욕 | H | 35 | 18 | 35 | 53 | 70 | 7 | 21 | 53 | 88 | 140 |
| 37 | 도쿄 | H | 35 | 18 | 35 | 53 | 70 | 7 | 21 | 53 | 88 | 140 |
| 39 | 서울 | H | 40 | 20 | 40 | 60 | 80 | 8 | 24 | 60 | 100 | 160 |
### 3.3 관광지(건물 없음)
| 칸 | 이름 | 땅값 | 통행료 | 비고 |
|---|---|---|---|---|
| 5 | 제주도 | 20 | 10 | |
| 15 | 경주 | 20 | 10 | |
| 25 | 우주센터 | 30 | 15 | 우주여행 탑승료(20)도 소유자가 받음 |
| 35 | 부산 | 25 | 12 | |
- 같은 사람이 관광지를 여러 곳 가지면 각 관광지 통행료가 2배(2곳 이상 소유 시). 인수 불가.
### 3.4 고정 금액
| 항목 | 금액(만) |
|---|---|
| 월급(출발 통과·도착) | `salary`(기본 20) |
| 사회복지기금 납부(38번 칸) | 15 |
| 우주여행 탑승료 | 20(우주센터를 다른 사람이 가지고 있을 때만 그 사람에게) |
| 무인도 탈출비 | `islandFee`(기본 0 = 불가) |
| 건물 매각가 | 그 단계에 낸 건설비(또는 땅값)의 50%, 내림 |
### 3.5 행운카드 20장(자체 설계, 각 1장)
| ID | 이름 | 효과 |
|---|---|---|
| L01 | 복권 당첨 | 은행에서 20 받기 |
| L02 | 장학금 | 은행에서 10 받기 |
| L03 | 주식 배당 | 은행에서 15 받기 |
| L04 | 생일 축하 | 다른 플레이어 모두에게서 각 5 받기 |
| L05 | 예금 이자 | 은행에서 10 받기 |
| L06 | 과속 벌금 | 5를 사회복지기금에 적립 |
| L07 | 병원비 | 은행에 10 내기 |
| L08 | 이웃 돕기 성금 | 10을 사회복지기금에 적립 |
| L09 | 건물 수리비 | 내 건물마다 별장 3, 빌딩 6, 호텔 10, 랜드마크 15를 은행에 내기(도시 하나의 건물 단계마다 해당 금액을 합산: 호텔까지 지은 도시는 별장+빌딩+호텔 = 19) |
| L10 | 폭풍 경보 | 무인도로 바로 이동(출발을 지나도 월급 없음, 더블 연속 끊김) |
| L11 | 무인도 탈출권 | 보관. 무인도에서 사용하면 즉시 탈출. 쓰면 카드 더미 맨 아래로 |
| L12 | 통행료 면제권 | 보관. 다음에 다른 플레이어에게 통행료를 낼 때 자동 사용(1회). 쓰면 맨 아래로 |
| L13 | 출발지로 | 출발(0)로 이동, 월급 받기 |
| L14 | 우주 초대장 | 우주여행(30)으로 앞으로 이동(출발을 지나면 월급). 도착 효과 적용 |
| L15 | 서울 관광 | 서울(39)로 앞으로 이동(출발을 지나면 월급). 도착 효과 적용 |
| L16 | 뒤로 3칸 | 뒤로 3칸 이동(월급 없음). 도착 효과 적용 |
| L17 | 기부 천사 | 사회복지기금 받는 곳(20)으로 앞으로 이동(출발 지나면 월급), 적립금 받기 |
| L18 | 관광 안내 | 앞으로 가장 가까운 관광지로 이동(출발 지나면 월급). 도착 효과 적용 |
| L19 | 여행 경비 | 다른 플레이어 모두에게 각 5 주기 |
| L20 | 재산세 | 내가 가진 땅(도시·관광지) 1곳당 2를 은행에 내기 |
- 이동 카드로 도착한 칸이 행운카드 칸이면 다시 카드를 뽑는다(연쇄 가능, 이론상 무한 반복 방지를 위해 한 차례에 카드 5장 이상이면 이후 카드 칸 효과 무시).
- 사용한 카드는 더미 맨 아래로. 보관 카드(L11, L12)는 사용할 때까지 그 플레이어 앞에 두고, 사용 후 맨 아래로. 파산하면 보관 카드도 맨 아래로.
## 4. 준비(셋업)
1. 차례 순서 RNG로 섞기(원작: 주사위로 결정).
2. 모두 출발(0) 칸, 현금 `startMoney`.
3. 행운카드 20장 섞어 비공개 더미.
4. 사회복지기금 적립금 0. 모든 땅 은행 소유(주인 없음).
5. 라운드 1.
## 5. 진행 규칙
### 5.1 차례
1. (무인도에 있으면 5.4, 우주여행 대기 중이면 5.5 먼저)
2. `ROLL`: 주사위 2개 굴려 합만큼 시계 방향 이동. 출발(0)을 지나거나 도착하면 월급.
3. 도착 칸 처리(5.2).
4. 더블(두 주사위 같은 눈)이면 도착 처리 후 한 번 더 `ROLL`. 같은 차례에 3번째 더블이 나오면 이동하지 않고 즉시 무인도로 가며 차례 끝(월급 없음).
- 더블로 이동했어도 무인도로 가게 되거나(카드 L10 포함), 우주여행 칸에 도착하면 추가 굴림은 사라진다.
5. 차례 종료.
### 5.2 도착 칸 처리
- 주인 없는 도시/관광지: `BUY`(땅값 지불) 또는 `PASS`. 현금이 부족하면 살 수 없다(돈을 만들기 위해 팔고 사는 것은 불가). 경매 없음.
- `buildOnPurchase=true`이면 산 직후 별장 1단계까지 바로 지을 수 있다.
- 내 도시: 다음 단계 건물 1개를 지을 수 있다(`BUILD`) 또는 `PASS`. 순서는 별장 → 빌딩 → 호텔 → (랜드마크). 한 방문에 1단계만. 땅을 산 그 방문에는 짓지 못한다(옵션 예외).
- 남의 도시/관광지: 통행료를 그 주인에게 낸다(3.2/3.3 표). 통행료 면제권이 있으면 자동 사용. 주인이 무인도에 있어도 받는다. 이후 `takeover=true`이고 랜드마크가 아닌 도시면 인수 선택(5.7).
- 행운카드: 맨 위 카드를 뽑아 즉시 적용(3.5).
- 사회복지기금 내는 곳(38): 15를 적립금에 넣는다.
- 사회복지기금 받는 곳(20): 적립금 전부를 받는다(0이면 아무 일 없음).
- 무인도(10): 무인도에 갇힘(5.4). 단순히 지나가면 아무 일 없음.
- 우주여행(30): 다음 차례에 우주여행(5.5). 이번 차례 종료(더블 추가 굴림 소멸).
- 출발(0)에 정확히 도착: 월급(지나갈 때와 같은 1회분).
### 5.3 지불과 파산
- 반드시 내야 하는 돈(통행료, 카드 지출, 기금 납부, 우주여행 탑승료)이 현금보다 많으면 "빚 갚기" 단계(무인도 탈출비·구매·건설·인수처럼 스스로 고르는 지출은 현금이 충분할 때만 가능하므로 빚이 생기지 않는다):
- 먼저 서버가 최대 확보 가능액 = 현금 + 모든 건물·땅 매각가 합을 계산한다. 이것이 빚보다 작으면 즉시 파산.
- 그렇지 않으면 그 플레이어가 `SELL`로 건물(맨 위 단계부터 1단계씩) 또는 땅(건물 전부 포함)을 은행에 팔아 현금이 빚 이상이 될 때까지 반복. 매각가 = 그 단계에 냈던 금액의 50%(내림). 다 모이면 자동 지불.
- 파산: 가진 모든 건물·땅을 매각가로 은행에 팔아 생긴 현금 전부를 채권자(다른 플레이어, 은행, 기금 중 빚을 진 상대)에게 준다. 땅은 은행 소유(건물 없음)로 돌아간다. 파산한 사람은 탈락(관전으로 전환).
- 여러 사람에게 내야 하는 카드(L19)는 좌석 순으로 1명씩 별도의 빚으로 처리한다. 도중에 파산하면 그때의 채권자가 남은 돈을 받고 나머지 사람은 받지 못한다.
- 은행 돈은 무한. 은행에 돈이 모자라는 일은 없다.
### 5.4 무인도
- 무인도에 들어가면 `islandLeft = islandTurns`(기본 3). 더블 연속은 끊긴다.
- 무인도에 있는 내 차례에 다음 중 하나:
- `ROLL`: 더블이면 즉시 탈출해 그 합만큼 이동(이번 더블로는 추가 굴림 없음). 더블이 아니면 이동하지 않고 `islandLeft -= 1`, 차례 끝. `islandLeft`가 0이 되면 다음 차례부터 일반 진행.
- `USE_ESCAPE_CARD`(L11 보유 시) 또는 `PAY_ISLAND_FEE`(`islandFee > 0`이고 현금 충분): 즉시 탈출 후 같은 차례에 일반 `ROLL`(더블 규칙 정상 적용).
- 무인도에 있어도 땅 주인으로서 통행료는 받는다. 교환(옵션)도 가능.
### 5.5 우주여행
- 우주여행 칸에 도착한 다음 내 차례에는 주사위를 굴리지 않고 `SPACE_TRAVEL { dest }`로 판의 아무 칸(30번 제외)이나 골라 시계 방향으로 이동한다.
- 탑승료: 우주센터(25)가 다른 플레이어 소유면 그 사람에게 20. 은행 소유거나 내 것이면 무료.
- 이동 경로가 출발(0)을 지나거나 0에 도착하면 월급.
- 도착 칸 효과를 적용(구매·통행료·카드 등). 이 차례는 더블이 없으므로 추가 굴림 없음.
### 5.6 라운드와 종료
- 라운드: 생존한 모든 플레이어가 한 차례씩 마치면 1라운드. 선(차례 순서 첫 사람)이 파산하면 다음 생존자가 라운드 기준이 된다.
- 종료: (1) 생존자 1명, (2) `maxRounds` 라운드가 끝남, (3) `monopolyWin=true`이고 누군가 독점 달성(즉시).
### 5.7 모두의마블식 옵션
- 인수(`takeover`): 남의 도시(관광지·랜드마크 제외)에서 통행료를 낸 뒤, 인수가 = 2 × (그 도시의 땅값 + 지어진 건물의 건설비 합)을 그 주인에게 내고 소유권(건물 포함)을 가져올 수 있다. 주인은 거부할 수 없다. 인수한 방문에는 추가 건설 불가. 현금이 부족하면 인수 불가(빚 갚기로 만들 수 없음).
- 랜드마크(`landmark`): 호텔이 있는 내 도시에 다음 방문 때 지을 수 있는 5번째 단계. 인수 불가.
- 독점 승리(`monopolyWin`): 차례 중 어느 시점이든 아래를 만족하면 즉시 승리.
- 트리플 독점: 도시 그룹(A~H) 3개를 각각 3곳 모두 소유.
- 라인 독점: 한 줄(3.1의 라인)의 도시 6곳 모두 소유.
- 관광지 독점: 관광지 4곳 모두 소유.
### 5.8 교환(옵션, 기본 꺼짐)
- `trade=true`일 때, 내 차례에 주사위를 굴리기 전(또는 무인도·우주여행 선택 전)에 1명에게 제안할 수 있다: 주는 것(내 땅들 + 현금) ↔ 받는 것(상대 땅들 + 현금). 건물이 있는 땅은 건물째로 넘어간다.
- 제안을 받은 사람은 `TRADE_RESPOND { accept }`. 제한 시간 안에 응답이 없으면 거절. 한 차례에 제안은 최대 2번. 현금은 제안 시점과 수락 시점 모두 보유액 이내여야 한다.
### 5.9 예시
- A가 서울(39, B 소유, 호텔)에 도착 → 통행료 100. A 현금 70, 확보 가능액 = 70 + 매각가 합 50 = 120 ≥ 100 → 빚 갚기 단계. A가 리스본 빌딩(건설비 26 → 매각 13)과 별장(13 → 6), 리스본 땅(26 → 13)을 팔아 현금 102 → 자동으로 100 지불, 현금 2.
- B가 37(도쿄)에서 더블 4-4 → 8칸 이동, 출발(0)을 지나 5(제주도) 도착, 월급 20 받고 제주도 구매 여부 결정 → 더블이므로 다시 굴림.
- C가 우주여행 칸 도착 → 다음 차례 `SPACE_TRAVEL { dest: 39 }`, 우주센터 주인 D에게 20, 30→39 이동(출발 안 지남, 월급 없음), 서울 처리.
## 6. 승패와 점수 계산
- 파산으로 생존자 1명 → 그 사람 1위. 탈락 순서의 역순으로 순위(먼저 파산한 사람이 꼴찌).
- 라운드 제한 종료 → 총자산 = 현금 + Σ(소유 땅의 땅값 + 지어진 건물 건설비 합, 정가 100%). 총자산 많은 순. 동점이면 현금 많은 순, 그래도 같으면 공동 순위. 파산자는 생존자보다 아래.
- 독점 승리 → 달성자 1위, 나머지는 총자산 순.
- 예: 현금 120, 서울(땅 40 + 별장 20 + 빌딩 40), 제주도(20) → 총자산 = 120 + 100 + 20 = 240.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 이미 옵션화: 시작 자금, 월급, 라운드 제한, 구매 즉시 건설, 무인도 쉬는 차례 수와 탈출비, 인수, 랜드마크, 독점 승리, 교환.
- 지원하지 않는 변형(문서화만): 경매, 대출, 같은 그룹 독점 시 통행료 2배(모노폴리식), 모두의마블의 올림픽·세계여행 비용 변형·통행료 배수 증가, 팀전. 요구가 있으면 옵션으로 추가.
- 원작 부루마불은 한 도시에 별장 최대 2채·빌딩·호텔을 자유롭게 섞어 짓지만, 우리 판은 어린이도 이해하기 쉽게 "한 단계씩 올리는" 방식으로 단순화했다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Pos = number; // 0..39
type Level = 0 | 1 | 2 | 3 | 4; // 0 땅만, 1 별장, 2 빌딩, 3 호텔, 4 랜드마크
type Creditor = { kind: 'player'; id: PlayerId } | { kind: 'bank' } | { kind: 'fund' };
interface MarbleState {
options: MarbleOptions;
rng: RngState;
order: PlayerId[];
current: number;
round: number;
phase:
| 'turnStart' // 굴리기(또는 무인도/우주여행 선택, 교환 제안)
| 'decide' // 구매/건설/인수 결정
| 'debt' // 빚 갚기(매각)
| 'tradeResponse' // 교환 응답 대기
| 'finished';
players: Record<PlayerId, {
pos: Pos; cash: number; alive: boolean; bankruptAtRound: number | null;
islandLeft: number; // 0이면 무인도 아님
spacePending: boolean;
heldCards: ('L11' | 'L12')[];
}>;
owners: Record<Pos, { owner: PlayerId; level: Level } | undefined>; // 도시·관광지만
fund: number;
deck: string[]; // 비공개, 0번 = 맨 위
doublesThisTurn: number;
cardsThisTurn: number;
decision: null | {
pos: Pos;
canBuy: boolean; canBuild: Level | null; canTakeover: number | null; // 인수가
boughtThisVisit: boolean;
};
debt: null | { amount: number; to: Creditor; then: 'continueTurn' | 'nextCreditor'; queue: { amount: number; to: Creditor }[] };
pendingTrade: null | { from: PlayerId; to: PlayerId; give: { lands: Pos[]; cash: number }; take: { lands: Pos[]; cash: number } };
tradesThisTurn: number;
extraRoll: boolean; // 더블로 한 번 더
lastRoll: [number, number] | null;
turnSeq: number;
deadlineAt: number | null;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `ROLL` | 없음 | 현재 차례, `turnStart` | 우주여행 대기 아님. 무인도면 무인도 굴림으로 처리 |
| `PAY_ISLAND_FEE` | 없음 | 현재 차례, `turnStart`, `islandLeft > 0` | `islandFee > 0`, 현금 ≥ 탈출비 |
| `USE_ESCAPE_CARD` | 없음 | 위와 같음 | L11 보유 |
| `SPACE_TRAVEL` | `{ dest: Pos }` | 현재 차례, `turnStart`, `spacePending` | `dest ≠ 30`, 0..39. 탑승료는 빚 규칙 적용 |
| `BUY` | 없음 | 현재 차례, `decide`, `canBuy` | 현금 ≥ 땅값. 위반: "돈이 부족해요." |
| `BUILD` | `{ level }` | 현재 차례, `decide` | `level = 현재 단계 + 1`, 랜드마크는 옵션 켜짐, 이번 방문 미건설, 구매 직후면 `buildOnPurchase`이고 `level=1`만, 현금 ≥ 건설비 |
| `TAKEOVER` | 없음 | 현재 차례, `decide`, `canTakeover` | 옵션 켜짐, 통행료 지불 완료, 랜드마크·관광지 아님, 현금 ≥ 인수가 |
| `PASS` | 없음 | 현재 차례, `decide` | 항상 가능 |
| `SELL` | `{ pos; what: 'top' \| 'all' }` | 빚진 사람, `debt` | 내 땅. `top`은 건물 단계 ≥ 1 |
| `PROPOSE_TRADE` | `{ to; give; take }` | 현재 차례, `turnStart`, `trade=true` | 상대 생존, 땅 소유 확인, 현금 ≤ 보유, 이번 차례 2회 미만, 빈 제안 아님 |
| `TRADE_RESPOND` | `{ accept: boolean }` | 제안받은 사람, `tradeResponse` | `pendingTrade.to`와 일치. 수락 시 소유·현금 재검증 |
| `CONCEDE` | 없음 | 참가자 | 파산과 같은 처리(채권자 은행). 생존자 1명이면 종료 |
- 처리 흐름(apply 내부, 자동 연쇄): 굴림 → 이동(월급) → 칸 효과 → 필요 시 `decide`/`debt` 단계 → 결정 후 독점 판정 → `extraRoll`이면 `turnStart`로, 아니면 다음 차례.
- 이벤트: `rolled{dice,double}`, `moved{from,to,passedStart}`, `salary`, `bought`, `built`, `tollPaid{from,to,amount,waived}`, `takeover`, `cardDrawn{cardId}`, `fundPaid`, `fundReceived`, `toIsland`, `islandRoll`, `escaped`, `spaceTravel`, `sold`, `bankrupt{player,creditor,amount}`, `tradeProposed`, `tradeResolved`, `roundEnd`, `monopoly`, `gameOver`.
### 8.3 공개/비공개 정보 (view)
- 공개(모두·관전자 동일): 말 위치, 모든 현금, 땅 소유와 건물 단계, 적립금, 보관 카드 종류, 무인도·우주여행 상태, 라운드, 주사위, 뽑힌 카드(뽑는 순간 공개), 교환 제안 내용.
- 비공개: 행운카드 더미 순서(남은 장 수만 공개), `rng`.
- 현재 결정 중인 사람에게 `decision`의 선택지와 비용 미리보기, 빚 갚기 중이면 매각 후보와 매각가.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 셋업: 차례 순서, 행운카드 더미 섞기.
- `ROLL`(무인도 굴림 포함): 주사위 2개 `1 + floor(rng()*6)` 순서대로.
- 더미는 맨 아래로 돌려 넣으므로 다시 섞지 않는다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 결정이 필요할 때마다 `now + turnSeconds`. 교환 응답은 20초 고정.
- `onTimeout`(결정적):
- `turnStart`: 무인도면 `ROLL`(돈 쓰지 않음). 우주여행이면 `SPACE_TRAVEL { dest: 0 }`(출발, 월급). 그 외 `ROLL`.
- `decide`: `PASS`(자리 비운 사람의 돈을 마음대로 쓰지 않음).
- `debt`: 매각가가 작은 것부터 자동 `SELL top`, 건물이 없으면 땅값 작은 땅부터 `SELL all`(동률이면 칸 번호 작은 것).
- `tradeResponse`: 거절.
- 끊긴 사람: 5초 유예 후 위 규칙. 2라운드 연속 자리 비움이면 방장에게 "강퇴/계속" 선택 제공(강퇴 = `CONCEDE`).
### 8.6 종료 조건과 결과(GameResult)
```ts
interface MarbleResult {
reason: 'lastSurvivor' | 'roundLimit' | 'monopoly';
monopolyType?: 'triple' | 'line' | 'tour';
ranking: { rank: number; player: PlayerId; totalAssets: number; cash: number; lands: number; bankruptAtRound: number | null }[];
winners: PlayerId[];
summary: string; // 예: "20라운드 종료! 수진 총자산 812만으로 승리"
}
```
## 9. UI/UX
- 모바일 세로: 정사각형 판을 화면 폭에 맞춰 위에 배치(칸이 작으므로 칸을 누르면 큰 정보 카드 팝업: 주인, 건물, 통행료표). 판 가운데 빈 공간에 주사위와 큰 "굴리기" 버튼, 적립금 표시. 판 아래에 플레이어 카드(현금, 땅 수, 색) 가로 4칸.
- 결정 화면: 하단 시트로 "타이베이를 6만에 살까요?" + 큰 "사기"/"안 사기" 버튼, 남은 현금 미리보기.
- 하이라이트: 이동 경로 칸이 차례로 빛나며 말이 이동. 내 땅은 내 색 테두리, 건물 아이콘(별장 집 1채 → 빌딩 → 호텔 → 랜드마크 탑).
- 우주여행: 판의 칸들이 모두 선택 가능 상태로 빛나고, 칸을 누르면 "여기로 가면: 월급 받음/통행료 ○○" 미리보기.
- 빚 갚기: "○○만이 모자라요" 진행 막대와 매각 후보 목록(매각가 표시), 다 모이면 자동 지불.
- 애니메이션: 주사위 굴림, 돈이 오가는 숫자 팝업(+20, −100), 파산 시 땅 색이 회색으로.
- 초보자 도움말: 칸 종류별 설명, "더블이면 한 번 더, 3번 연속이면 무인도" 안내. 힌트 옵션: "이 땅을 사면 남은 돈 ○○" 경고(현금이 50 미만이 되면 주황색).
## 10. 테스트 체크리스트
- [ ] 판 데이터: 40칸 구성(도시 24, 관광지 4, 카드 7, 특수 5), 가격표 스냅샷 일치.
- [ ] 월급: 37에서 5칸 → 2 도착, 월급 1회. 0에 정확히 도착해도 1회. 뒤로 3칸 카드로 0을 거꾸로 지나면 월급 없음.
- [ ] 더블: 더블 → 처리 후 재굴림. 3번째 더블 → 이동 없이 무인도, 월급 없음.
- [ ] 더블로 우주여행 도착 → 추가 굴림 소멸, 다음 차례 `SPACE_TRAVEL`만 허용(`ROLL` 거부).
- [ ] 무인도: `islandTurns=3`, 비더블 3번 → 4번째 차례 일반 진행. 무인도 더블 → 탈출·이동, 추가 굴림 없음. 탈출권 사용 → 같은 차례 일반 굴림, 더블이면 재굴림.
- [ ] 건설: 구매한 방문에 `BUILD` 거부(`buildOnPurchase=false`). 다음 방문 별장만 허용, 빌딩 건너뛰기 거부. 한 방문 2단계 거부.
- [ ] 통행료: 표 값 그대로. 면제권 자동 사용 후 카드 맨 아래로. 관광지 2곳 소유 시 통행료 2배.
- [ ] 빚 갚기: 5.9 예시 재현. 확보 가능액 < 빚이면 즉시 파산, 채권자가 매각 후 현금 전부 받음, 땅은 은행으로.
- [ ] L19(여행 경비) 중 파산: 좌석 순 앞사람만 받고 이후 사람은 못 받음.
- [ ] 사회복지기금: 38 도착 15 적립, L06/L08 적립, 20 도착 시 전액 수령 후 0.
- [ ] 우주여행: 우주센터 타인 소유 시 탑승료 20, 30→5 이동 시 출발 통과 월급.
- [ ] 인수(옵션): 통행료 지불 후 인수가 = 2 × 투자액, 랜드마크·관광지 거부. 옵션 꺼짐 시 액션 거부.
- [ ] 독점 승리(옵션): 관광지 4곳 소유 순간 즉시 종료.
- [ ] 라운드 제한 20: 20라운드 마지막 사람 차례 후 총자산 순위, 동점 시 현금 비교.
- [ ] 교환(옵션): 제안 후 상대 미응답 20초 → 거절. 수락 시점에 현금 부족하면 실패 처리.
- [ ] 시간 초과: `decide`는 항상 `PASS`(돈을 쓰지 않음), `debt`는 싼 것부터 자동 매각.
- [ ] 연결 끊김: 끊긴 사람의 차례가 자동 진행되고 통행료는 정상 수령.
- [ ] 정보 유출: View·이벤트에 카드 더미 순서와 `rng` 없음. 카드는 뽑힌 뒤에만 ID 공개.
- [ ] 리플레이 결정성: 같은 시드·액션 열로 같은 최종 자산.
## 11. 참고 자료
- 나무위키 "부루마불": https://namu.wiki/w/부루마불 (무인도 3턴·더블 탈출, 사회복지기금 납부/수령, 우주여행 탑승료, 황금열쇠, 별장·빌딩·호텔, 더블 3번 무인도, 파산)
- 나무위키 "모두의마블 for kakao/규칙": https://namu.wiki/w/모두의마블%20for%20kakao/규칙 (인수 2배, 랜드마크 인수 불가, 트리플·라인·관광지 독점, 턴 제한)
- Wikipedia "Monopoly (game)"(원형 장르, 파산·매각 규칙 참고): https://en.wikipedia.org/wiki/Monopoly_(game)
## 12. 메모 (상표·법적 주의 등)
- "부루마불"은 씨앗사, "모두의마블"은 넷마블, "Monopoly"는 Hasbro의 상표다. 화면·광고에 쓰지 말고 표시 이름은 자체 명칭(후보: "세계 여행 땅따먹기", "주사위 부자 여행", "지구 한 바퀴")으로 한다. "황금열쇠"라는 카드 이름도 부루마불을 강하게 연상시키므로 "행운카드"로 쓴다.
- 판 배치·가격·카드는 모두 자체 설계이며 원작의 수치·카드 문구를 옮기지 않았다. 도시 이름은 실제 지명이라 자유롭게 쓸 수 있다. 원작의 우주선 이름(콜롬비아호 등) 대신 "우주센터"를 쓴다.
- 사행성 주의: 돈은 방 안에서만 쓰는 가상 화폐로 저장·이월·환전이 없다. 실제 돈·상품 연동 금지. 규칙 보기에 명시한다.

267
docs/games/ninety-nine.md Normal file
View File

@@ -0,0 +1,267 @@
# 구십구 (`ninety-nine`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 10명 · 예상 시간: 약 5~15분 · 난이도: 쉬움
## 1. 개요
- 차례대로 카드를 1장씩 내며 숫자를 더해 간다. 합계를 99보다 크게 만들 수밖에 없는 사람이 탈락(또는 목숨 1개를 잃음)하는 서바이벌 카드 게임이다.
- 이 문서는 두 가지 규칙을 담는다.
- `kr-product` 모드(기본): 코리아보드게임즈의 카드게임 "구십구"(2~10명, 만 8세 이상, 약 5분) 방식. 전용 카드(1~8, 10, ±9, ±10, 0, 조커)를 쓴다.
- `classic` 모드: 트럼프 52장으로 하는 전통 게임 "99(Ninety-Nine)". 영미권 놀이 게임이고, 나무위키 "99(플레잉 카드)"에도 같은 계열 규칙이 있다.
- 인원 근거
- 코리아보드게임즈 공식 상품 페이지에 2~10명으로 나온다(일부 쇼핑몰은 2~6명으로 표기).
- 나무위키 "99(플레잉 카드)"도 2~10명이다.
- 그래서 2~10명으로 한다.
### 확인된 것과 확인되지 않은 것 (오너 확인 필요)
- 확인됨(코리아보드게임즈 매거진 기사, 공식 상품 페이지)
- 1~8 카드 각 7장, 10 카드 21장.
- 특수 카드 ±9, ±10, 0, 조커(정확히 2장).
- 자기 차례에 1장을 내고 이전 수에 더한 새 수를 말한다.
- 99를 넘게 만드는 카드는 낼 수 없고, 낼 카드가 1장도 없으면 탈락한다. 혼자 남으면 승리한다.
- ±9·±10은 더하거나 뺄 수 있다. 0은 수 변화 없이 진행 방향을 바꾼다. 조커는 앞 수와 상관없이 60~99 중 원하는 수를 부른다.
- 확인되지 않음(공식 규칙서 원문을 구하지 못함. 아래는 이 문서의 기본값이고 모두 옵션)
- ±9, ±10, 0 카드의 장수. 기본값은 각 4장(추정)이다.
- 시작 손패 장수. 기본값은 3장(전통 99와 같음)이다.
- 카드를 낸 뒤 1장 보충 여부. 기본값은 보충한다(전통 99와 같음).
- 빼기로 0 미만이 될 때 처리. 기본값은 0 미만이 되는 빼기는 금지다.
- 탈락자가 생긴 뒤 합계를 이어 갈지 초기화할지. 기본값은 이어 간다.
- 칩(목숨) 변형이 공식 규칙서에 있는지. 일부 소개 글에 "칩 3개, 99를 넘기면 1개 잃음" 변형이 언급되지만 원문으로 확인하지 못했다. 전통 99의 토큰 3개 규칙과 같은 구조라 `lives` 옵션으로 둔다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mode` | 규칙 | `kr-product` / `classic` | `kr-product` |
| `handSize` | 손패 장수 | 3 / 4 / 5 | 3 |
| `refill` | 카드를 낸 뒤 1장 보충 | on / off | on |
| `lives` | 목숨(토큰) 수. 0이면 목숨 없이 바로 탈락 | 0 / 1 / 2 / 3 / 5 | `kr-product` 0, `classic` 3 |
| `onTheBoard` | 목숨을 다 잃은 뒤에도 한 번 더 질 때까지 남음(전통 변형) | on / off | off |
| `afterElimination` | (`lives=0`) 탈락 뒤 합계 처리 | `continue`(그대로 이어 감) / `reset`(0으로, 다시 나눔) | `continue` |
| `floorAtZero` | 빼기 결과가 0 미만일 때 | `forbid`(그런 빼기는 못 냄) / `clamp`(0으로 맞춤) | `forbid` |
| `specialCounts` | (`kr-product`) ±9, ±10, 0 각 장수 | 2 / 3 / 4 / 5 / 6 | 4 |
| `jokerRange` | (`kr-product`) 조커로 부를 수 있는 범위 | `60-99`(원작) / `0-99` | `60-99` |
| `aceHigh` | (`classic`) A의 큰 값 | 10(나무위키) / 11(Bicycle) | 11 |
| `deckCount` | (`classic`) 덱 수 | `auto`(7인 이상 2벌) / 1 / 2 | `auto` |
| `turnSeconds` | 턴 제한 시간 | 10 / 15 / 20 / 30 | 15 |
## 3. 구성물
`kr-product` (기본값 기준 91장)
| 카드 | 효과 | 장수 |
|---|---|---|
| 1~8 | 그 숫자만큼 더함 | 각 7장(56장) |
| 10 | 10 더함 | 21장 |
| ±9 | 9를 더하거나 뺌(낸 사람이 선택) | `specialCounts`(기본 4, 추정) |
| ±10 | 10을 더하거나 뺌 | `specialCounts`(기본 4, 추정) |
| 0 | 수 변화 없음, 진행 방향 반대 | `specialCounts`(기본 4, 추정) |
| 조커 | 앞 수와 상관없이 60~99 중 원하는 수로 만듦 | 2장 |
- 9 숫자 카드는 없다(±9만 있음).
- 목숨을 쓰면(`lives > 0`) 플레이어마다 목숨 토큰을 그만큼 준다. 방 안에서만 쓰는 표시용이다.
`classic` (트럼프 52장, Bicycle 규칙 기준)
| 카드 | 효과 |
|---|---|
| A | 1 또는 `aceHigh`(11) 더함(낸 사람이 선택) |
| 2, 3, 5, 6, 7, 8 | 그 숫자만큼 더함 |
| 4 | 0(수 변화 없음), 진행 방향 반대 |
| 9 | 0(수 변화 없음), 그냥 넘김 |
| 10 | 10을 뺌 |
| J, Q | 10 더함 |
| K | 합계를 바로 99로 만듦(이미 99면 그대로 99) |
- 조커는 쓰지 않는다. `deckCount = 2`이면 104장이다.
## 4. 준비(셋업)
1. 모드에 맞는 덱을 만들고 시드 RNG로 섞는다.
2. 선 플레이어를 RNG로 정한다. 진행은 시계 방향이다.
3. 각자 `handSize`장을 받는다. 남은 카드는 뽑을 더미가 된다.
4. 합계는 0에서 시작한다.
5. 목숨을 쓰면 각자 `lives`개를 받는다.
## 5. 진행 규칙
### 5.1 턴
1. **탈락 판정(턴 시작 때 서버 자동)**: 현재 플레이어가 낼 수 있는 카드가 하나도 없으면 바로 "패배 처리"(5.3)를 한다. 패배 처리 결과 탈락하지 않은 경우(목숨이 남음)에는 새 라운드가 시작된다.
2. **카드 내기**: 낼 수 있는 카드 1장을 낸다. ±카드는 더하기/빼기를, A는 1/11을, 조커는 숫자를 함께 고른다. 새 합계가 공개된다.
3. **보충**(`refill = on`): 뽑을 더미에서 1장을 가져온다. 더미가 비었으면 버린 카드를 모두 섞어 새 더미를 만든다(합계는 그대로). 그래도 없으면 보충하지 않는다.
4. 다음 차례로 넘긴다. 0(또는 `classic`의 4)이면 방향을 반대로 바꾼 뒤 넘긴다.
- 패스는 없다. 낼 수 있는 카드가 있으면 반드시 1장을 내야 한다.
- 2인일 때 방향 반대 카드는 방향만 바뀌고 다음 차례는 여전히 상대다.
### 5.2 낼 수 있는 카드(합법 조건)
합계를 S, 카드를 낸 뒤 합계를 S'라 할 때 S' ≤ 99여야 한다.
`kr-product`
- 1~8, 10: S' = S + 값.
- ±9, ±10
- 더하기면 S' = S + 값.
- 빼기면 S' = S - 값. `floorAtZero = forbid`이면 S' ≥ 0이어야 하고, `clamp`이면 S' = max(0, S - 값).
- 두 방향 중 하나라도 합법이면 낼 수 있다.
- 0: S' = S. 항상 낼 수 있다.
- 조커: S'를 `jokerRange`(60~99) 안의 아무 정수로 고른다. 항상 낼 수 있다. 현재 합계와 같은 수를 골라도 된다.
`classic`
- A: 1 또는 11 중 S' ≤ 99인 값.
- 2, 3, 5~8, J, Q: S' = S + 값.
- 4, 9: S' = S. 항상 낼 수 있다.
- 10: S' = S - 10. `forbid`이면 S ≥ 10일 때만, `clamp`이면 항상 가능.
- K: S' = 99. 항상 낼 수 있다.
### 5.3 패배 처리
- `lives = 0`(`kr-product` 기본)
- 그 플레이어는 탈락한다. 손패는 버린 더미로 간다.
- `afterElimination = continue`면 같은 합계로 다음 사람 차례가 된다(다음 사람도 낼 카드가 없으면 연달아 탈락할 수 있다).
- `reset`이면 합계를 0으로 하고 남은 사람 손패를 모두 거둬 다시 섞어 나눈다. 탈락자의 다음 사람이 시작한다.
- `lives > 0`
- 그 플레이어가 목숨 1개를 잃는다. 이번 라운드는 끝난다.
- 목숨이 0이 되면 탈락한다. `onTheBoard = on`이면 목숨 0인 상태로 계속 하고, 그 상태에서 한 번 더 지면 탈락한다.
- 새 라운드: 모든 카드를 거둬 섞고, 남은 사람에게 다시 나누고, 합계 0에서 시작한다. 진 사람이 탈락하지 않았으면 진 사람이 먼저 하고, 탈락했으면 그 다음 사람이 먼저 한다.
- 남은 사람이 1명이면 게임이 끝난다.
### 5.4 예시 (`kr-product`, 3인)
- 합계 85에서 A가 10을 내 95가 된다. A가 보충한다.
- B의 손패는 [8, 7, ±9]이다. 8과 7은 99를 넘는다. ±9를 빼기로 내서 86이 된다.
- C의 손패는 [10, 10, 0]이다. 0을 내서 86 그대로이고 방향이 반대가 되어 B 차례가 된다.
- B의 손패는 [8, 7, 6](보충한 6)이다. 8 → 94, 7 → 93, 6 → 92 모두 가능하다. B가 7을 내서 93.
- 방향이 바뀌었으므로 다음은 A다. A의 손패가 [8, 10, 7]이면 101, 103, 100이라 낼 카드가 없다. A는 턴 시작 때 탈락하고 C 차례가 된다.
## 6. 승패와 점수 계산
- 마지막까지 남은 1명이 승자다.
- 순위: 늦게 탈락한 사람이 앞선다. 같은 시점 동시 탈락은 없다(턴 순서대로 판정).
- 목숨을 쓰는 경우에도 탈락 순서로 순위를 정한다. 결과 화면에 각자 잃은 목숨 수를 보여 준다.
- 점수, 칩, 돈은 없다. 목숨 토큰은 이 판에서만 쓰는 표시이고 방 밖으로 이어지지 않는다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 목숨(칩) 변형(`lives`, `onTheBoard`): 전통 99의 토큰 3개 규칙이다. 구십구 소개 글 일부에서도 "칩 3개" 변형이 언급된다(원문 미확인).
- 손패 장수(`handSize`), 보충 여부(`refill`).
- 탈락 뒤 합계 처리(`afterElimination`).
- 0 미만 빼기 처리(`floorAtZero`).
- 조커 범위 확장(`jokerRange`, 어린이용 0~99).
- `classic`의 A 큰 값(`aceHigh` 10/11).
- 지원하지 않는 변형(문서화만)
- Wikipedia "표준" 변형: 3이 다음 사람 건너뛰기, 9가 99로 만들기, 10이 ±10, K가 0.
- 정확히 99를 만들면 보너스, 같은 숫자 연속 내기 금지 같은 지역 규칙.
- 필요하면 `classic` 카드 효과표를 옵션 프리셋으로 늘린다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type NNCard =
| { id: string; kind: 'add'; v: number } // kr: 1..8, 10 / classic: 2,3,5,6,7,8, J/Q(10)
| { id: string; kind: 'plusMinus'; v: 9 | 10 } // kr ±9, ±10
| { id: string; kind: 'reverse' } // kr 0 / classic 4
| { id: string; kind: 'hold' } // classic 9
| { id: string; kind: 'minus'; v: 10 } // classic 10
| { id: string; kind: 'ace' } // classic A (1 또는 aceHigh)
| { id: string; kind: 'to99' } // classic K
| { id: string; kind: 'joker' }; // kr 조커 (jokerRange)
// 화면 표시용으로 원래 표기(rank/suit 또는 kr 라벨)를 별도 필드 face로 둔다.
interface NinetyNineState {
mode: 'kr-product' | 'classic';
options: NNOptions;
rng: RngState;
seats: PlayerId[];
hands: Record<PlayerId, NNCard[]>; // 비공개
drawPile: NNCard[]; // 비공개
discard: NNCard[]; // 공개(최근 몇 장만 view)
total: number; // 공개 합계
direction: 1 | -1;
current: PlayerId;
alive: Record<PlayerId, boolean>;
lives: Record<PlayerId, number>; // lives=0 모드에서는 사용 안 함
onBoard: Record<PlayerId, boolean>; // onTheBoard 상태
eliminationOrder: PlayerId[];
round: number;
phase: 'playing' | 'finished';
turnStartedAt: number;
seq: number;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `play` | `{ cardId: string; sign?: '+' \| '-'; aceValue?: 1 \| 10 \| 11; jokerValue?: number }` | `current`, playing | 카드가 손에 있음. ±카드는 `sign` 필수, A는 `aceValue` 필수(1 또는 `aceHigh`), 조커는 `jokerValue` 필수이고 `jokerRange` 안의 정수. 5.2 조건으로 계산한 S' ≤ 99(그리고 `forbid`면 S' ≥ 0) |
- 플레이어가 보내는 액션은 `play` 하나뿐이다. 탈락, 목숨 감소, 라운드 재시작, 보충은 `apply` 안에서 자동으로 처리한다.
- `play`를 적용한 직후 다음 플레이어를 정한다. 그 플레이어에게 합법 수가 없으면 같은 `apply` 안에서 패배 처리를 하고, 다시 다음 사람을 검사한다(반복). 이 과정을 이벤트로 순서대로 남긴다.
- 이렇게 하면 "낼 카드가 없는 사람이 타이머를 기다리는" 상황이 없다.
- 이벤트 예: `cardPlayed{player, face, total}`, `directionChanged`, `cardDrawn{player}`, `reshuffled`, `lifeLost{player, left}`, `eliminated{player}`, `roundStarted{round, starter}`.
- `reason` 예: "99를 넘어서 낼 수 없어요", "0보다 작아져서 뺄 수 없어요", "더하기와 빼기 중 하나를 골라 주세요", "조커는 60부터 99 사이 숫자만 고를 수 있어요".
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함): 현재 합계, 방향, 현재 차례, 각자 손패 장수, 목숨, 생존 여부, 탈락 순서, 뽑을 더미 장수, 최근에 낸 카드 몇 장과 그때의 합계 기록.
- 본인만
- 자기 손패.
- 각 카드의 합법 여부와 결과 미리보기(예: "±9 → 더하면 104 불가 / 빼면 86").
- 탈락 때 손패는 공개하지 않고 장수만 버린 더미로 옮긴다(실물에서는 보일 수 있지만 다른 사람 추리에 영향이 없도록 비공개로 둔다).
- 절대 보내지 않는 것: 남의 손패, 더미 순서, RNG 상태. 보충 이벤트는 본인에게만 카드 내용을 준다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `setup`: 덱 섞기, 선 플레이어.
- 더미 소진 때 버린 카드 다시 섞기.
- 라운드 재시작(목숨 모드, `reset`) 때 다시 섞기.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline = turnStartedAt + turnSeconds*1000`.
- `onTimeout(state, p)`: 합법인 (카드, 선택값) 조합 중 결과 합계 S'가 가장 작은 것을 고른다.
- 같으면 우선순위: 0/4/9(변화 없음) → 일반 숫자 카드 → ±카드 → K/조커. 그래도 같으면 카드 ID 순.
- 조커는 `jokerRange` 최소값(60)으로 낸다.
- 이 규칙은 본인 손패와 공개 합계만 쓰므로 정보 유출이 없고 결정적이다.
- 합법 수가 없는 경우는 타이머까지 가지 않고 `apply`에서 이미 처리된다(8.2).
- 연결이 끊겨도 위 자동 행동으로 계속 진행한다. 연속 3턴 타임아웃이면 서버가 마감을 3초로 줄인다.
### 8.6 종료 조건과 결과(GameResult)
- 생존자가 1명이 되면 `finished`.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; livesLost: number; eliminatedAtRound: number | null }[];
winner: PlayerId[];
summary: string; // "끝까지 살아남은 사람은 지호 님! 마지막 합계 97"
}
```
## 9. UI/UX
- 모바일 세로 화면
- 가운데: 현재 합계를 아주 크게(예: 72pt) 보여 준다. 99에 가까울수록 색이 초록 → 노랑 → 빨강으로 바뀌는 게이지 링을 두른다.
- 위: 상대 아바타(손패 장수, 목숨 하트, 차례 표시), 진행 방향 화살표.
- 아래: 내 손패 3장을 큰 카드로 보여 준다(최소 72×100px).
- 조작
- 카드를 탭하면 "내면 → 81"처럼 결과 합계 미리보기가 카드 위에 뜬다. 한 번 더 탭하면 낸다.
- ±카드를 탭하면 [+9 → 95] [−9 → 77] 두 버튼이 뜬다. 불가능한 쪽은 비활성이다.
- 조커를 탭하면 60~99 숫자 다이얼(+/− 버튼과 빠른 선택 60/90/99)이 뜬다.
- 낼 수 없는 카드는 흐리게 하고 "99 초과" 배지를 붙인다.
- 애니메이션: 합계 숫자가 올라가는 카운트 애니메이션, 방향 전환 화살표 회전, 탈락 때 아바타 흑백 처리, 목숨 하트가 깨지는 연출.
- 초보 도움말: "99를 넘기면 안 돼요!" 한 줄 규칙과 특수 카드 표. 어린이 방 추천 설정(조커 0~99, 손패 4장).
- 실시간 감각: 턴이 빨라서 타이머 링을 크게 보여 주고, 내 차례가 되면 진동(모바일 지원 시)으로 알린다.
## 10. 테스트 체크리스트
- [ ] 덱 구성: `kr-product` 기본값 91장(1~8 각 7, 10이 21, ±9/±10/0 각 4, 조커 2). `specialCounts=2`면 85장. `classic` 52장, 7인 이상 `auto`면 104장.
- [ ] 같은 시드면 같은 분배, 같은 선이 나온다.
- [ ] 합계 95에서 손패 [8, 5, 4] (`kr-product`)이면 4만 합법이다. 4를 내서 99가 된다. 다음 사람이 [1, 2, 3]만 있으면 턴 시작 때 바로 탈락한다.
- [ ] ±10: 합계 5에서 빼기는 `forbid`면 거부된다. `clamp`면 0이 된다. 더하기는 15로 허용된다.
- [ ] 조커: 합계 20에서 조커로 99를 고르면 99가 된다. 59는 거부되고(`60-99`), 100도 거부된다.
- [ ] 0 카드: 3인에서 A → B 차례에 B가 0을 내면 방향이 반대가 되어 다음이 A다. 2인에서는 상대 차례 그대로다.
- [ ] `classic`: 합계 90에서 K → 99, 그다음 사람은 4/9/10/K만 낼 수 있다. A는 1이어도 100이라 거부된다. 합계 88에서 A를 11로 내면 99로 허용된다.
- [ ] `lives=3`: 진 사람이 목숨 1개를 잃고, 합계 0으로 새 라운드가 시작되며 진 사람이 먼저 한다. 목숨 0이면 탈락하고 다음 사람이 시작한다. `onTheBoard`면 0개에서 한 번 더 져야 탈락한다.
- [ ] 연쇄 탈락(`lives=0`, `continue`): 합계 98에서 다음 두 사람 모두 낼 카드가 없으면 한 번의 `apply` 안에서 두 명이 순서대로 탈락하고, 세 번째 사람 차례가 된다. 이벤트 순서가 맞다.
- [ ] 더미 소진: 보충할 때 더미가 비면 버린 카드가 섞여 보충되고 합계는 그대로다.
- [ ] 시간 초과: 손패 [10, 3, 0], 합계 50이면 자동 행동은 0(결과 50, 가장 작음)을 낸다. 손패 [±9, 3, 0], 합계 50이면 −9(결과 41)를 낸다.
- [ ] 연결 끊김: 끊긴 플레이어 차례가 자동으로 진행되고 게임이 멈추지 않는다.
- [ ] 정보 유출: 관전자와 상대 view에 남의 손패, 보충 카드 내용, 더미 순서가 없다. 탈락자 손패도 공개되지 않는다.
- [ ] 차례가 아닌 `play`, `sign` 없는 ±카드, 범위 밖 `jokerValue`, 중복 패킷이 거부되고 상태가 바뀌지 않는다.
## 11. 참고 자료
- 코리아보드게임즈 매거진 "숫자를 더해나가며 한계에 도전하라!": https://www.koreaboardgames.com/magazine/menuDetail?boardCd=contents&postNo=947 (1~8 각 7장, 10이 21장, ±9·±10·0·조커 효과, 조커 2장 60~99, 99 초과 금지, 낼 카드 없으면 탈락)
- 코리아보드게임즈 상품 페이지 "구십구": https://www.koreaboardgames.com/product/detail?prdCd=PD2024002211B1JR (2~10명, 만 8세+, 5분)
- 나무위키 "99(플레잉 카드)": https://namu.wiki/w/99(플레잉%20카드) (트럼프판 2~10명, 손패 3장, 내고 1장 보충, A 1/10, 4 반대, 9는 0, 10은 −10, J·Q 10, K는 99)
- Bicycle Cards "99 (Ninety Nine)": https://bicyclecards.com/how-to-play/99-ninety-nine (토큰 3개, 손패 3장, A 1/11, 4 반대, 9 패스, 10 −10, K 99, 넘기면 토큰 1개 잃고 라운드 종료)
- Wikipedia "Ninety-nine (addition card game)": https://en.wikipedia.org/wiki/Ninety-nine_(addition_card_game) (토큰 3개, 지역 변형 목록)
## 12. 메모 (상표·법적 주의 등)
- "구십구"는 코리아보드게임즈가 파는 상품명이다. 숫자 이름이라 상표 효력이 약할 수 있지만 안전하게 표시 이름을 바꾸는 것을 권한다. 후보: "99 넘기지 마!", "아흔아홉", "숫자 더하기 99"(사용자 선택).
- 전통 게임 "99(Ninety-Nine)" 규칙 자체는 공공 영역이다. `kr-product` 모드의 전용 카드 구성(±9, ±10, 0, 조커 60~99)은 상품의 고유 설계를 따른 것이다. 규칙 자체는 저작권 보호 대상이 아니지만, 카드 그림과 규칙서 문장은 쓰지 않고 직접 만든다.
- 목숨(칩) 토큰은 방 안에서만 쓰는 게임 내 표시다. 돈, 포인트, 교환과 연결하지 않는다. 화면 용어는 도박 느낌을 피하려고 "칩" 대신 "목숨"(하트)을 쓴다.

329
docs/games/omok.md Normal file
View File

@@ -0,0 +1,329 @@
# 오목 (`omok`)
> 마일스톤: M1 · 인원: 최소 2 ~ 최대 2명 · 예상 시간: 약 5~15분 · 난이도: 쉬움 (렌주룰은 보통)
## 1. 개요
- 가로·세로·대각선 중 한 방향으로 자기 돌 5개를 먼저 연속으로 놓으면 이기는 2인 추상 전략 게임. 한국에서는 바둑판과 바둑돌로 누구나 한 번쯤 둬 본 국민 게임이며, "쌍삼 금지"가 일반인 사이의 사실상 표준 규칙이다.
- 공식 경기 규칙은 렌주(Renju, 세계렌주연맹 RIF)이며, 한국오목협회도 렌주룰로 대회를 연다.
- 인원 근거: 원작이 흑·백 2인 게임이다. 최소 2, 최대 2. 그 외 접속자는 모두 관전자.
- 완전 정보 게임이라 숨길 정보가 없다. 다만 시드 RNG(시간 초과 자동 착수용)와 무르기/무승부 요청 상태 같은 서버 내부 값은 view에서 정리해서 내보낸다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `ruleSet` | 규칙 모드 (7장 참고) | `korean`(한국식 오목) / `renju`(렌주룰) / `free`(자유룰) | `korean` |
| `freeWin` | 자유룰에서 승리로 인정할 줄 길이 | `fivePlus`(5목 이상 모두 승) / `exactFive`(정확히 5목만 승, 6목 이상은 승리 아님) | `fivePlus` |
| `boardSize` | 판 크기 | `15` / `19` | `15` |
| `firstMoveCenter` | 흑 첫 수를 천원(정중앙)에 강제 | `auto`(렌주만 켬) / `on` / `off` | `auto` |
| `colorAssignment` | 흑백 정하기 | `random` / `hostBlack` / `hostWhite` / `alternate`(같은 방에서 재대국 시 교대) | `random` |
| `timeControl.kind` | 시간 제한 방식 | `none` / `perMove`(한 수당 N초) / `fischer`(전체 시간 + 수당 추가) | `perMove` |
| `timeControl.perMoveSec` | 한 수당 제한 (`perMove`) | 10 / 20 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 (`fischer`) | 기본 3분+2초, 5분+3초, 10분+5초 프리셋 | 5분+3초 |
| `timeoutPolicy` | `perMove`에서 시간 초과 시 처리 | `autoMove`(무작위 합법 수 자동 착수) / `lose`(시간패) | `autoMove` |
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 120 / 300 / 600 | 300 |
| `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` |
| `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` |
| `hints` | 초보 도움말: 상대의 열린 3·4 경고 표시 | `off` / `warn` | `warn` |
## 3. 구성물
- 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다.
- 흑돌, 백돌: 개수 제한 없음(판이 가득 찰 때까지).
- 좌표 표기: 열 A~O(15줄, I 포함 — 렌주 표기법), 행은 아래에서 위로 1~15. 19줄은 A~S, 1~19. 엔진 내부는 `(x, y)`, `x`는 왼쪽→오른쪽 0부터, `y`는 위→아래 0부터. 표시 좌표는 `열문자[x] + (N - y)`.
- 천원 = 정중앙 (15줄: H8, 19줄: J10).
## 4. 준비(셋업)
1. `colorAssignment`로 흑/백 결정 (`random`이면 시드 RNG 사용).
2. 빈 판으로 시작. 흑이 먼저 둔다.
3. `firstMoveCenter=true`면 흑의 첫 수는 천원만 합법.
4. 시계 초기화: `perMove`는 첫 수부터 카운트, `fischer`는 양쪽 `baseSec`.
## 5. 진행 규칙
- 흑부터 번갈아 한 수씩, 빈 교차점에 자기 돌 1개를 놓는다. 놓은 돌은 움직이거나 따내지 않는다.
- 패스는 없다(판이 가득 차면 무승부로 끝남).
- 착수 합법 조건 (모두 만족):
1. 게임 진행 중이고 내 차례이다.
2. 좌표가 판 안이고 비어 있다.
3. `firstMoveCenter`가 켜져 있고 첫 수라면 천원이다.
4. 해당 규칙에서 금수가 아니다 (아래 금수 판정). 단, 5목이 완성되는 수는 금수 판정보다 우선해 항상 합법이며 즉시 승리.
- 착수 후 승리 판정: 놓은 돌을 지나는 4방향(가로, 세로, 대각 ↘, 대각 ↗)으로 연속된 같은 색 돌의 길이 `len`을 구한다.
### 5.1 용어 정의 (엔진 판정의 기준)
- 줄(line): 한 방향으로 쭉 이어지는 교차점들.
- 연속 길이 `run(p, dir)`: `p`를 포함해 `dir` 방향과 반대 방향으로 끊김 없이 이어진 같은 색 돌 수.
- 오목(five): `run == 5`인 줄. 장목(overline): `run >= 6`.
- 승리 기준 `isWin(len)`:
- `korean`: 흑·백 모두 `len == 5`. 6목 이상은 승리가 아니다(금수도 아님, 그냥 둘 수 있으나 이기지 못함).
- `renju`: 흑은 `len == 5`, 백은 `len >= 5`.
- `free`: `freeWin=fivePlus`면 `len >= 5`, `exactFive`면 `len == 5`.
- 사(four): 지금 놓은 돌 `p`를 포함하는 4개의 돌이 있어, 빈 자리 `q` 하나를 더 채우면 `isWin`을 만족하는 오목이 되는 상태. 한 방향 안의 사의 개수 = "q를 채웠을 때 생기는 오목에서 q를 뺀 4개의 돌 집합"의 서로 다른 개수.
- 예: `.●●●●.`(양쪽이 모두 오목 자리) → 돌 집합이 같으므로 사 1개(열린 사, straight four).
- 예: `●.●●●.●` (가운데 `●●●` 중 하나가 방금 둔 돌) → 왼쪽 빈칸을 채우면 왼쪽 5개, 오른쪽 빈칸을 채우면 오른쪽 5개. 돌 집합이 달라 한 줄에 사 2개 → 한 줄 4-4.
- 열린 사(straight four): 사 중에서 같은 4개 돌 집합으로 오목을 만드는 빈 자리가 2곳 이상인 것 (막을 수 없는 사).
- 삼(three): `p`를 포함한 줄에서 빈 자리 `q` 하나를 채우면 `p`와 `q`를 모두 포함하는 열린 사가 되는 상태. 단 `q`를 채우는 순간 오목이 되면 그 `q`는 제외하고, `q`가 그 색에게 금수 자리라면 그 `q`도 제외한다(이 마지막 조건 때문에 "가짜 삼"이 생긴다). 조건을 만족하는 `q`가 하나라도 있으면 그 방향은 삼 1개로 센다(한 방향당 최대 1개).
### 5.2 금수 판정 알고리즘
규칙별 금수:
| 규칙 | 흑 | 백 |
|---|---|---|
| `korean` | 3-3 금지 | 3-3 금지 |
| `renju` | 3-3, 4-4, 장목 금지 | 없음 (장목도 승리) |
| `free` | 없음 | 없음 |
`korean`에서 4-4와 4-3은 허용, 6목은 금수가 아니지만 승리도 아니다. `renju`에서 4-3은 허용.
```ts
// board에 color 돌을 p에 놓는 것이 금수인지. 재귀로 가짜 3-3을 거른다.
function isForbidden(board, p, color, rule, depth = 0): boolean {
if (!rule.has33 && !rule.has44 && !rule.hasOverline) return false;
place(board, p, color);
try {
// 1) 오목이 되면 금수 아님 (오목 우선)
for (const d of DIRS) if (isWin(run(board, p, d, color), color)) return false;
// 2) 장목 (renju 흑만)
if (rule.hasOverline) for (const d of DIRS) if (run(board, p, d, color) >= 6) return true;
// 3) 4-4: 4방향 사 개수 합 >= 2 (한 줄 4-4 포함)
if (rule.has44) {
let fours = 0;
for (const d of DIRS) fours += fourStoneSets(board, p, d, color).size;
if (fours >= 2) return true;
}
// 4) 3-3: 진짜 삼이 있는 방향 수 >= 2
if (rule.has33) {
let threes = 0;
for (const d of DIRS) if (isThree(board, p, d, color, rule, depth)) threes++;
if (threes >= 2) return true;
}
return false;
} finally { remove(board, p); }
}
// p(이미 놓인 상태)를 지나는 d 방향에서, 빈칸 q 하나로 오목이 되는 "4개 돌 집합" -> 완성 자리 수
function fourStoneSets(board, p, d, color): Map<string, number> {
const sets = new Map();
for (let k = -4; k <= 4; k++) {
if (k === 0) continue;
const q = p + k*d;
if (!onBoard(q) || !isEmpty(board, q)) continue;
place(board, q, color);
const seg = runSegment(board, q, d, color); // q를 지나는 연속 구간
if (isWin(seg.length, color) && seg.includes(p)) {
const key = seg.filter(s => s !== q).sort().join('|');
sets.set(key, (sets.get(key) ?? 0) + 1);
}
remove(board, q);
}
return sets;
}
function isThree(board, p, d, color, rule, depth): boolean {
for (let k = -4; k <= 4; k++) {
if (k === 0) continue;
const q = p + k*d;
if (!onBoard(q) || !isEmpty(board, q)) continue;
place(board, q, color);
const makesFive = run(board, q, d, color) >= 5;
const straight = !makesFive && [...fourStoneSets(board, p, d, color)]
.some(([key, n]) => n >= 2 && key.includes(q)); // q를 포함하는 열린 사
remove(board, q);
if (straight && !isForbidden(board, q, color, rule, depth + 1)) return true;
}
return false;
}
```
- 핵심 포인트
- 오목 판정은 언제나 금수 판정보다 먼저다. 렌주 흑이 5목과 3-3(또는 4-4)을 동시에 만들면 승리.
- 열린 사 판정에서 "오목"은 `isWin` 기준이다. 따라서 렌주 흑/한국식에서 한쪽 끝을 채우면 6목이 되는 자리는 오목 자리가 아니므로, 그런 사는 열린 사가 아니다 → 그런 줄은 삼이 아니다(장목 때문에 생기는 가짜 삼).
- 삼의 확장 자리 `q`가 다시 금수인지 확인할 때 `p`는 이미 놓인 상태로 재귀한다. 재귀는 돌이 하나씩 늘어나므로 반드시 끝난다. 성능상 같은 (판, 자리, 색) 조합은 착수 1회 처리 동안 메모이제이션한다. 실제 판에서 깊이 3을 넘는 경우는 거의 없으며, 깊이 제한은 두지 않는다(정확성 우선).
- `korean` 규칙에서도 같은 재귀를 쓴다(확장 자리가 3-3 금수면 가짜 삼).
- 판정은 서버가 한다. 클라이언트에는 view의 `forbiddenPoints`로 결과만 보낸다(표시용).
- 위 알고리즘은 문서 작성 시 스크립트로 아래 예시들을 모두 검증했다.
### 5.3 금수 판정 예시 (X = 지금 두려는 흑 자리, ○ = 백)
예 A — 진짜 3-3 (렌주·한국식 모두 금수)
```
10 + + + + + + + ● + + +
9 + + + + + + + ● + + +
8 + + + + + ● ● X + + +
7 + + + + + + + + + + +
A B C D E F G H I J K
```
H8에 두면 가로(F8·G8·H8)와 세로(H8·H9·H10) 두 방향 모두 양쪽이 열린 삼 → 금수.
예 B — 한 줄 4-4 (렌주 흑만 금수, 한국식은 허용)
```
8 + + + ● + ● X ● + ● + +
A B C D E F G H I J K L
```
G8에 두면 `●.●●●.●` → E8을 채우면 D8~H8 오목, I8을 채우면 F8~J8 오목. 4돌 집합이 서로 달라 사 2개.
예 C — 한쪽이 막힌 삼은 삼이 아님 (금수 아님)
```
10 + + + + + + + ● + + +
9 + + + + + + + ● + + +
8 + + + + ○ ● ● X + + +
7 + + + + + + + + + + +
A B C D E F G H I J K
```
가로는 E8 백 때문에 열린 사를 만들 수 없다(I8을 채워도 J8 한쪽만 오목 자리). 삼은 세로 1개뿐 → 합법.
예 D — 장목 때문에 가짜 삼 (렌주 흑·한국식 모두 합법)
```
10 + + + + + + + ● + + + +
9 + + + + + + + ● + + + +
8 + + ● + + ● ● X + + ● +
7 + + + + + + + + + + + +
A B C D E F G H I J K L
```
가로 F8·G8·H8. I8을 채우면 J8 쪽 완성이 F8~K8 6목이 되어 오목 자리가 아니고, E8을 채우면 D8 쪽 완성이 C8~H8 6목이 된다. 어느 쪽도 열린 사가 안 되므로 가로는 삼이 아니다 → 삼 1개(세로)뿐 → 합법.
예 E — 확장 자리가 금수라서 가짜 삼 (렌주 흑 합법)
```
10 + + + + ● + + + + + +
9 + + + + ● + + + + + +
8 + + + + ? ● ● X + ○ +
7 + + + + + ● + ● + + +
6 + + + + + + ● ● + + +
A B C D E F G H I J K
```
H8(X)에 두면 겉보기엔 가로(F8·G8·H8)와 세로(H8·H7·H6) 3-3이다. 그러나 가로를 열린 사로 만들 자리는 E8(?)뿐이다(I8을 채우면 J8이 백이라 한쪽만 열린 사). 그런데 E8은 흑에게 금수다: H8이 놓인 상태에서 E8에 두면 세로 E8·E9·E10 삼과 대각 E8·F7·G6 삼이 동시에 생기는 3-3이다. 따라서 가로는 가짜 삼이고, H8은 금수가 아니다.
예 F — 오목 우선: 흑이 한 수로 5목과 3-3을 동시에 만들면 승리. 렌주 흑이 한 방향 5목 + 다른 방향 장목이면? → 오목이 하나라도 있으면 승리(1단계에서 바로 false 반환).
## 6. 승패와 점수 계산
- 착수 직후 `isWin` 줄이 있으면 착수자 승리 (`reason: 'five'`).
- 판이 가득 찼는데 승자가 없으면 무승부 (`reason: 'boardFull'`). 렌주 흑이 남은 빈칸이 모두 금수라 둘 곳이 없는 경우도 무승부로 처리한다(실전상 거의 없음, `reason: 'noLegalMove'`).
- 기권(`resign`) → 상대 승. 무승부 합의 → 무승부.
- 시간 초과: `fischer`에서 시간이 0이 되면 시간패. `perMove`는 `timeoutPolicy`에 따름(`lose`면 시간패, `autoMove`면 자동 착수 후 계속).
- 연결 끊김: 유예 시간 초과 시 `onTimeout` → 같은 정책 적용. 단 `autoMove`가 연속 3회 발생하면 그 플레이어 시간패(방치 방지).
- 점수 개념은 없다. 결과 요약에 총 수(手) 수와 승리 줄 좌표를 남긴다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 한국식 오목 (`korean`, 기본): 국내 일반인 규칙. 흑백 모두 3-3 금지, 4-4·4-3 허용, 6목 이상은 승리 아님(두는 것은 가능).
- 렌주 (`renju`): RIF 공식 규칙의 착수 부분. 흑만 3-3·4-4·장목 금지, 백은 제약 없음·장목도 승리. 공식 대회의 개국 규칙(26주형, 스왑, 5수 제시 등 Soosõrv-8/Taraguchi-10)은 v1에서 구현하지 않고 `firstMoveCenter`만 제공한다. 추후 옵션 `renjuOpening`으로 확장 가능.
- 자유룰 (`free`): 금수 없음. 기본은 5목 이상 승(국제 표준 freestyle gomoku 기준). `exactFive`를 켜면 정확히 5목만 승리(standard gomoku 계열).
- 흑 선착 이점이 매우 크므로 재대국 시 `colorAssignment=alternate`를 권장한다(방 단위).
- 무르기 허용 횟수, 금수 표시, 경고 힌트는 모두 옵션으로 끌 수 있다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Color = 'black' | 'white';
type Cell = 0 | 1 | 2; // 0 빈칸, 1 흑, 2 백
type Pt = { x: number; y: number };
interface OmokState {
options: OmokOptions;
size: 15 | 19;
board: Cell[]; // length size*size, index = y*size + x
players: Record<Color, PlayerId>;
turn: Color;
moves: { color: Color; pt: Pt; at: number; auto: boolean }[]; // 기보
phase: 'playing' | 'finished';
winLine: Pt[] | null; // 승리한 5목 좌표
clock: {
remainingMs: Record<Color, number>; // fischer 전용
turnStartedAt: number; // 현재 차례 시작 시각(ms)
autoMoveStreak: Record<Color, number>;
};
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number> };
forbidden: number[]; // 둘 차례인 색의 금수 칸 캐시(판에서 계산 가능한 파생값)
result: OmokResult | null;
}
// RNG 상태는 게임 상태에 넣지 않고 GameRunner가 따로 저장한다(09-game-engine.md 3절). view에는 절대 포함되지 않는다.
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `place` | `{ x, y }` | 차례인 플레이어, `playing` | 판 안, 빈칸, 첫 수 천원 조건, `isForbidden == false` (5목 완성 수는 항상 허용). 실패 사유 예: "이미 돌이 있는 자리예요", "흑은 3-3 자리에 둘 수 없어요(쌍삼 금지)", "흑은 4-4 자리에 둘 수 없어요", "흑은 6목 이상을 만들 수 없어요", "첫 수는 가운데에 둬야 해요" |
| `requestUndo` | `{}` | 플레이어 누구나, `playing` | `undo != off`, 남은 횟수 > 0, `pending` 없음, 본인이 둔 수가 1개 이상. 직전 수가 본인 수면 1수, 상대가 이미 응수했으면 2수를 무른다 |
| `respondUndo` | `{ accept: boolean }` | 요청받은 상대, `pending` 존재 | 요청자 본인이 아님. 수락 시 `moves`에서 `plies`만큼 제거, 판 복원, 차례는 요청자, 요청자 사용 횟수 +1. 시계는 되돌리지 않음(사용한 시간 유지), `turnStartedAt = now` |
| `offerDraw` | `{}` | 플레이어, `playing` | `drawOffer=true`, `pendingBy == null`, 직전 제안 후 본인이 3수 이상 둠(거절된 경우 도배 방지) |
| `respondDraw` | `{ accept: boolean }` | 제안받은 상대 | `pendingBy`가 상대. 수락 → 무승부 종료 |
| `cancelOffer` | `{}` | 제안/요청한 본인 | 본인의 대기 중인 무르기·무승부 요청 취소 |
| `resign` | `{}` | 플레이어, `playing` | 언제든 가능 |
| `timeout` | `{}` | 시스템(`onTimeout`이 생성) | `now >= deadline` 또는 연결 끊김 유예 만료 |
- 대기 중인 무르기·무승부 요청은 요청받은 쪽이 `place`를 하면 자동 거절로 간주해 삭제한다.
- 모든 `place` 처리 시 시계 갱신: `fischer`면 `remaining[turn] -= now - turnStartedAt`, 0 이하면 시간패로 종료(착수 무효), 아니면 `+= incSec*1000`. 이후 `turnStartedAt = now`.
- 이벤트: `stonePlaced`, `win`, `draw`, `undoRequested/Accepted/Declined`, `drawOffered/...`, `resigned`, `timeoutAutoMove`, `timeLoss`.
### 8.3 공개/비공개 정보 (view)
- 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과.
- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때), `hintThreats`(힌트 옵션: 상대의 열린 삼/사 위치).
- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용). 단 `hints`는 관전자에게 보내지 않는다(관전자가 플레이어에게 훈수 전달하는 것을 굳이 돕지 않기 위함).
- 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `colorAssignment=random`일 때 흑백 결정 (setup).
2. `timeoutPolicy=autoMove`에서 자동 착수 위치 선택: 우선순위 (a) 내가 즉시 5목 완성 가능한 자리 (b) 상대의 5목 자리 막기 (c) 기존 돌에서 거리 2 이내의 합법 빈칸 중 균등 무작위 (d) 그 외 합법 빈칸 중 균등 무작위. 동순위 다수는 RNG로 선택. 판이 비어 있으면 천원.
그 외에는 RNG를 쓰지 않으므로, 같은 시드와 같은 액션열이면 항상 같은 결과가 나온다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`:
- `perMove`: `turnStartedAt + perMoveSec*1000`
- `fischer`: `turnStartedAt + remainingMs[turn]`
- `none`: `turnStartedAt + idleLimitSec*1000`
- `finished`: `null`
- `onTimeout(state, player)`는 항상 `{ type: 'timeout' }`을 반환하고, `apply`가 정책을 적용한다:
- `fischer` 또는 `none`의 무응답 한도 초과, `perMove + lose`: 해당 플레이어 시간패.
- `perMove + autoMove`: 8.4 규칙으로 자동 착수(`moves[].auto = true`), `autoMoveStreak +1`. 자동 착수 2번 뒤 세 번째로 연속 시간 초과하면 시간패. 정상 착수 시 streak 0으로 초기화.
- 서버는 연결 끊긴 플레이어의 차례에 `min(deadline, 끊긴 시각 + 방의 graceSec)`에 `onTimeout`을 호출한다(유예 시간은 방 설정, 기본 60초). 끊긴 플레이어의 차례가 아닐 때는 아무것도 하지 않고, 차례가 오면 위 규칙을 따른다.
- 시간 초과 직전에 도착한 `place`는 서버 도착 시각(`ctx.now`) 기준으로 판정한다.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface OmokResult {
ranking: { playerId: PlayerId; rank: 1 | 2 }[]; // 무승부면 둘 다 rank 1
winner: Color | null;
reason: 'five' | 'boardFull' | 'noLegalMove' | 'resign' | 'timeLoss' | 'drawAgreed';
winLine: Pt[] | null;
moveCount: number;
summaryKo: string; // 예: "흑 승 (H8~L8 오목, 37수)"
}
```
## 9. UI/UX
- 모바일 세로: 상단에 상대 정보(닉네임, 돌 색, 남은 시간 바), 가운데 정사각 판(화면 폭 100%), 하단에 내 정보와 버튼 줄(무르기, 무승부, 기권, 규칙 보기). 15줄 판은 폰 폭 360px에서 교차점 간격이 약 23px라 48px 터치 기준을 못 맞추므로, 착수는 "탭 → 확대 미리보기 돌 표시 → 확인 버튼(48px 이상)" 2단계로 한다. 옵션으로 "한 번 탭으로 바로 두기"(PC 기본값).
- 판 핀치 줌/드래그 지원(19줄 필수).
- 마지막 수 표시(빨간 점), 승리 줄 강조 애니메이션.
- 금수 자리는 흑 차례일 때 작은 ×표. 금수 자리 탭 시 이유를 말풍선으로 설명("여기는 쌍삼이라 둘 수 없어요").
- 힌트 켜짐: 상대가 열린 삼/사를 만들면 해당 줄을 노란색으로 잠깐 표시.
- 무르기/무승부 요청은 상대 화면에 큰 모달(수락/거절 버튼 각 48px 이상).
- 규칙 보기: 선택된 규칙 모드의 요약 + 예시 그림(5.3의 예 A~D).
- PC: 판 좌측, 기보·채팅 우측.
- 관전자: 판 위에 "관전 중" 배지, 기보 되돌려 보기(관전자 로컬에서만).
## 10. 테스트 체크리스트
- [ ] 가로/세로/↘/↗ 4방향 각각 5목 완성 시 승리, 판 가장자리에 닿는 5목도 승리.
- [ ] `korean`: 흑·백 모두 6목 완성 시 승리 아님, 게임 계속. 6목을 만드는 수 자체는 합법.
- [ ] `renju`: 백 6목은 승리, 흑이 6목을 만드는 수는 금수로 거부.
- [ ] `free + fivePlus`: 6목 승리. `free + exactFive`: 6목 승리 아님.
- [ ] 5.3 예 A: H8이 `korean`·`renju` 모두 금수, `free`는 합법.
- [ ] 5.3 예 B: G8이 `renju` 흑 금수(한 줄 4-4), `korean` 합법.
- [ ] 5.3 예 C·D·E: 모두 `renju` 흑 합법(가짜 3-3). 예 E에서 재귀 없이 판정하면 금수로 잘못 나오는지 대조 테스트로 확인.
- [ ] 오목 우선: 흑이 5목과 4-4를 동시에 만드는 수 → 합법 + 흑 승.
- [ ] `korean`에서 백의 3-3도 금수로 거부되는지.
- [ ] `firstMoveCenter`: 첫 수가 천원이 아니면 거부, 두 번째 수부터는 자유.
- [ ] 판 가득 참 → 무승부. 19줄에서도 동작.
- [ ] 무르기: 내 수 직후 요청 → 1수 무름, 상대 응수 후 요청 → 2수 무름, 상대 거절 시 변화 없음, 요청 중 상대가 착수하면 요청 자동 소멸, 횟수 소진 시 거부.
- [ ] 무승부 제안: 거절 후 3수 이내 재제안 거부, 수락 시 무승부 결과.
- [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패.
- [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산.
- [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제.
- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 관전자 view에 `hintThreats`가 없는지 스냅샷 테스트.
- [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일.
## 11. 참고 자료
- RIF(세계렌주연맹) 렌주 규칙: https://www.renju.net/rifrules/ (오목/장목/사/열린 사/삼의 정의, 흑 금수, 오목 우선, 백 장목 승리)
- 나무위키 「오목」: https://namu.wiki/w/%EC%98%A4%EB%AA%A9 (한국식 일반룰: 흑백 모두 삼삼 금지, 장목 무효 / 렌주룰 / 자유룰)
- Wikipedia "Renju": https://en.wikipedia.org/wiki/Renju , "Gomoku": https://en.wikipedia.org/wiki/Gomoku (freestyle vs standard gomoku)
- 5.3의 예시 A~E는 문서의 의사코드를 그대로 구현한 스크립트로 판정 결과를 확인했다.
## 12. 메모 (상표·법적 주의 등)
- "오목", "Gomoku", "Renju"는 전통 게임 명칭으로 상표 문제 없음. "렌주"라는 명칭 사용도 무방하나 RIF 공식 경기가 아님을 오해하지 않도록 "렌주룰(연습용)"처럼 표기 권장.
- 금전·포인트 베팅 요소 없음.
- 개국 규칙(26주형 등)은 공식 대회 재현이 목적일 때만 필요하므로 v1 범위에서 제외했다.

352
docs/games/one-card.md Normal file
View File

@@ -0,0 +1,352 @@
# 원카드 (`one-card`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 9명 (`uno-style` 모드는 최대 10명) · 예상 시간: 약 10~15분 · 난이도: 쉬움
## 1. 개요
- 손에 든 카드를 가장 먼저 모두 내면 이기는 버리기(shedding) 카드 게임이다. 바닥 카드와 무늬나 숫자가 같은 카드를 내고, 공격 카드(2, A, 조커)로 다음 사람에게 카드를 먹인다. 한국에서는 학교와 MT에서 트럼프 카드로 많이 하는 국민 카드 게임이고, 지역과 모임마다 하우스 룰이 많다.
- 모드 두 가지
- `classic`(기본): 트럼프 52장 + 조커 2장으로 하는 한국식 원카드.
- `uno-style`: 4색 숫자/기능 카드 108장으로 하는 "색깔 카드" 규칙. 해외 유명 상품의 공식 규칙을 따르지만, 화면에는 상품명을 쓰지 않는다(12장 참고).
- 인원 근거
- `classic`: 나무위키 기준 2~9명. 덱이 54장뿐이라 6명 이상이면 기본으로 덱 2벌을 쓴다(옵션 `deckCount`).
- `uno-style`: 원작 공식 규칙 기준 2~10명.
- `GameDefinition.minPlayers = 2`, `maxPlayers = 10`. 방을 만들 때와 시작할 때 모드별 최대 인원(`classic` 9명)을 한 번 더 검사한다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mode` | 게임 모드 | `classic` / `uno-style` | `classic` |
| `handSize` | 처음 나눠 주는 장수 | 5 / 6 / 7 | 7 |
| `deckCount` | (`classic`) 사용할 덱 수 | `auto`(2~5명은 1벌, 6~9명은 2벌) / 1 / 2 | `auto` |
| `attack2` | (`classic`) 2의 공격량 | 1~3 | 2 |
| `attackA` | (`classic`) A(♠ 제외)의 공격량 | 2~5 | 3 |
| `attackSpadeA` | (`classic`) ♠A의 공격량 | 3~10 | 5 |
| `attackBlackJoker` | (`classic`) 흑백 조커의 공격량 | 5~10 | 5 |
| `attackColorJoker` | (`classic`) 컬러 조커의 공격량 | 5~13 | 7 |
| `threeDefense` | (`classic`) 같은 무늬 3으로 2/A 공격 막기 | `off` / `same-suit` | `same-suit` |
| `kEffect` | (`classic`) K 효과 | `extra-turn`(한 번 더) / `skip-two`(두 명 건너뛰기) / `none` | `extra-turn` |
| `qInTwoPlayer` | (`classic`) 2인일 때 Q 처리 | `no-effect` / `as-skip`(J처럼) | `no-effect` |
| `lastCardRule` | (`classic`) 마지막 1장으로 낼 수 없는 카드 | `none` / `joker`(조커 금지) / `attack`(2·A·조커 금지) / `special`(모든 특수 카드 금지) | `joker` |
| `afterJoker` | (`classic`) 조커 다음에 낼 수 있는 카드 | `previous`(조커 밑 카드 기준) / `any`(아무 카드) | `previous` |
| `multiPlay` | (`classic`) 같은 숫자 여러 장 동시 내기 | on / off | off |
| `oneCardPenalty` | 원카드 선언을 못 해서 잡혔을 때 먹는 장수 | 1~3 (`uno-style` 원작 2) | `classic` 1, `uno-style` 2 |
| `autoDeclare` | 1장이 되면 자동으로 선언(초보·어린이용) | on / off | off |
| `bankruptLimit` | 파산 장수(이 장수 이상이면 탈락) | 15 / 18 / 20 / 25 / 없음 | `classic` 20, `uno-style` 없음(원작에 없음) |
| `voluntaryDraw` | 낼 카드가 있어도 일부러 1장 먹고 넘기기 허용 | on / off | on |
| `playAfterDraw` | 먹은 카드가 낼 수 있는 카드면 바로 내기 허용 | on / off | `classic` off, `uno-style` on |
| `endCondition` | 종료 시점 | `first-out`(1등이 나오면 종료) / `last-standing`(꼴찌가 정해질 때까지) | `first-out` |
| `turnSeconds` | 턴 제한 시간 | 10 / 15 / 20 / 30 / 60 | 20 |
| `unoStacking` | (`uno-style`) +2/+4 받아치기(하우스 룰) | on / off | off |
| `unoWild4Rule` | (`uno-style`) 색 바꾸기+4 사용 조건 | `strict`(현재 색이 손에 없을 때만, 서버가 막음) / `challenge`(원작: 아무 때나 내되 의심 신청 가능) / `free`(제한 없음) | `strict` |
| `unoTargetScore` | (`uno-style`) 여러 판 점수제 목표 점수 | 없음(한 판) / 200 / 500 | 없음 |
## 3. 구성물
`classic`
- 트럼프 52장(♠♥♦♣ × A,2~10,J,Q,K)과 조커 2장. 흑백 조커(Black Joker)와 컬러 조커(Color Joker)가 한 장씩이다. 덱 2벌이면 108장.
- 특수 카드 요약
| 카드 | 분류 | 효과(기본값) |
|---|---|---|
| 2 | 공격 | 다음 사람에게 2장 |
| A (♥♦♣) | 공격 | 3장 |
| ♠A | 공격 | 5장 |
| 흑백 조커 | 공격 | 5장 |
| 컬러 조커 | 공격 | 7장 |
| 3 | 방어 | 같은 무늬의 2/A 공격을 무효로 함(`threeDefense`) |
| 7 | 무늬 변경 | 낸 사람이 다음 무늬를 지정 |
| J | 점프 | 다음 사람을 건너뜀 |
| Q | 방향 전환 | 진행 방향이 바뀜 |
| K | 한 번 더 | 낸 사람이 한 턴 더 함 |
`uno-style` (108장, 원작과 같은 구성)
- 4색(빨강·노랑·초록·파랑) 각 25장: 0 1장, 1~9 각 2장, 건너뛰기 2장, 방향 바꾸기 2장, +2 2장.
- 무색 8장: 색 바꾸기 4장, 색 바꾸기+4 4장.
- 화면 이름: 건너뛰기, 방향 바꾸기, +2, 색 바꾸기, 색 바꾸기+4.
## 4. 준비(셋업)
1. 덱을 만들고 시드 RNG로 섞는다.
2. 선 플레이어를 RNG로 고른다. 진행 방향은 시계 방향(좌석 인덱스가 커지는 쪽)이다.
3. 각자 `handSize`장씩 받는다.
4. 시작 카드를 뒤집는다.
- `classic`: 일반 숫자 카드(3~6, 8~10)가 나올 때까지 뒤집는다. 특수 카드(2, A, 3, 7, J, Q, K, 조커)는 덱 맨 아래로 돌려보낸다. 시작부터 공격이나 효과가 걸리는 혼란을 막으려는 기본값이다.
- `uno-style`: 원작 규칙을 따른다.
- 색 바꾸기+4: 덱에 다시 넣어 섞고 새로 뒤집는다.
- 색 바꾸기: 선 플레이어가 색을 정하고 첫 카드를 낸다.
- +2: 선 플레이어가 2장을 먹고 턴을 넘긴다.
- 건너뛰기: 선 플레이어를 건너뛴다.
- 방향 바꾸기: 방향을 반대로 바꾸고, 선 플레이어의 오른쪽(새 방향 기준 다음) 사람부터 시작한다. 2인이면 선 플레이어를 건너뛴 것과 같다.
- 숫자: 그대로 시작한다.
5. 남은 카드가 뽑을 더미(드로우 더미)가 된다.
## 5. 진행 규칙
### 5.1 공통 개념
- **현재 기준(top)**: 버린 더미 맨 위 카드의 무늬(색)와 숫자(기호). 7이나 색 바꾸기로 무늬/색이 지정되면 `declaredSuit`(`declaredColor`)가 무늬 기준을 대신한다.
- **공격 누적(pendingAttack)**: 공격 카드가 나오면 누적량이 쌓이고 다음 사람이 받는다. 받는 사람은 방어(받아치기)하거나 누적량만큼 먹는다.
- **턴**: 내 차례에는 아래 행동 중 하나만 한다(K 효과로 생긴 추가 턴은 따로 센다).
### 5.2 `classic` 턴 행동
**A. 공격을 받고 있을 때(`pendingAttack > 0`)**
1. **받아치기**: 아래 표에서 허용되는 공격 카드를 낸다. 그 카드의 공격량이 누적량에 더해지고, 다음 사람에게 넘어간다.
2. **3으로 막기**(`threeDefense = same-suit`): 맨 위 공격 카드가 2나 A이고, 그 무늬와 같은 무늬의 3을 내면 누적량이 0이 된다. 공격은 끝나고 다음 사람은 그 3을 기준으로 일반 턴을 한다. 조커 공격은 3으로 막을 수 없다.
3. **먹기**: 누적량만큼 뽑고 누적량을 0으로 만든다. 턴은 끝나고 아무것도 내지 않는다. 낼 수 있는 카드가 있어도 먹기를 고를 수 있다.
받아치기 허용표(맨 위 공격 카드 기준)
| 맨 위 공격 카드 | 받아칠 수 있는 카드 |
|---|---|
| 2 | 아무 무늬의 2, 맨 위 2와 같은 무늬의 A(♠2 위의 ♠A 포함), 조커 2종 |
| A(♥♦♣) | 아무 무늬의 A(♠A 포함), 조커 2종 |
| ♠A | 조커 2종만. 다른 A로는 받아칠 수 없다(나무위키 "다른 문양의 A로 지원 공격 불가") |
| 흑백 조커 | 컬러 조커 |
| 컬러 조커 | 없음(덱 2벌일 때는 다른 컬러 조커 가능) |
- 덱 2벌이면 같은 종류 카드도 받아칠 수 있다(♠A 위에 다른 ♠A, 흑백 조커 위에 흑백 조커).
- 공격 카드를 받아치기로 내도 `lastCardRule` 검사를 그대로 한다.
**B. 일반 턴(`pendingAttack = 0`)**
1. **카드 내기**: 아래 조건 중 하나를 만족하는 카드 1장(또는 `multiPlay`일 때 같은 숫자 여러 장)을 낸다.
- 무늬 기준(`declaredSuit`가 있으면 그 무늬, 없으면 맨 위 카드의 무늬)과 무늬가 같다.
- 맨 위 카드와 숫자(랭크)가 같다. `declaredSuit`가 있으면 숫자 일치는 쓸 수 없고, 7만 예외로 숫자 일치가 된다.
- 조커: 언제든 낼 수 있다(일반 턴의 공격 시작). 단 `lastCardRule`에 걸리면 안 된다.
- 맨 위 카드가 조커일 때
- `afterJoker = previous`: 조커 바로 밑 카드(조커가 여러 장 쌓였으면 조커가 아닌 첫 카드)를 기준으로 판정한다.
- `afterJoker = any`: 아무 카드나 낼 수 있다.
- 시작 카드는 조커가 될 수 없으므로 기준 카드는 항상 있다.
2. **먹기**: 1장을 뽑고 턴을 끝낸다. `voluntaryDraw = off`이면 낼 카드가 없을 때만 할 수 있다. `playAfterDraw = on`이면 뽑은 카드가 낼 수 있는 카드일 때 그 카드만 바로 낼 수 있다(내지 않아도 된다).
**C. 카드 효과(낸 직후 처리)**
- 2 / A / 조커: `pendingAttack += 공격량`. 다음 사람의 턴이 "공격 받음" 상태로 시작한다.
- 3: 공격 중이 아니면 일반 카드와 같다.
- 7: 낸 사람이 무늬 4개 중 하나를 고른다(같은 액션의 `chooseSuit` 필드). 7과 다른 무늬를 골라도 되고 같은 무늬를 골라도 된다. 다음 카드가 나오면 `declaredSuit`는 지워진다.
- J: 다음 사람을 건너뛴다. 2인이면 낸 사람이 다시 한다.
- Q: 진행 방향을 반대로 바꾼다. 2인이면 `qInTwoPlayer`를 따른다(기본은 효과 없음, 상대 차례).
- K
- `extra-turn`: 낸 사람이 바로 추가 턴을 한 번 한다. 추가 턴은 일반 턴과 같아서 낼 수 없으면 1장을 먹는다. K를 마지막 카드로 냈으면 추가 턴 없이 바로 나간다(승리).
- `skip-two`: 다음 두 사람을 건너뛴다(3인 이하에서는 낸 사람에게 돌아옴).
- `none`: 일반 카드.
- `multiPlay = on`: 같은 숫자 여러 장을 낼 때 첫 장은 위 조건을 만족해야 하고, 나머지는 숫자만 같으면 된다. 효과는 장수만큼 누적한다(2를 두 장 내면 공격 4장, J 두 장이면 두 명 건너뛰기, Q 두 장이면 방향 그대로, K 두 장이면 추가 턴 1번). 마지막 카드가 다음 기준이 된다. 7을 여러 장 내면 무늬 지정은 한 번만 한다.
**D. 마지막 카드 제한(`lastCardRule`)**
- 카드를 낸 뒤 손에 0장이 되는 수에만 적용한다. 금지된 카드로는 마지막 카드를 낼 수 없다.
- 기본 `joker`: 손에 조커 1장만 남으면 그 조커는 낼 수 없으므로 먹어야 한다(나무위키: "카드가 1장인 상태일 때는 조커를 내는 게 불가능하다").
### 5.3 원카드 선언
- 카드를 내서 손패가 정확히 1장이 된 순간 그 플레이어에게 "선언 창"이 열린다. 이때 모든 참가자(관전자 제외)에게 "원카드!" 버튼이 활성화된다.
- 서버 도착 순서로 판정한다.
- 1장 남은 본인이 먼저 누르면 안전하다(`declared`).
- 다른 플레이어가 먼저 누르면 본인이 `oneCardPenalty`장을 먹는다(잡힘). 먹은 뒤에는 손패가 2장 이상이라 창이 닫힌다.
- 카드를 내는 액션에 `declareOneCard: true`를 같이 보낼 수 있다(UI에서 "내면서 원카드 외치기"). 이 경우 창이 열리는 순간 바로 선언된 것으로 처리한다.
- 창이 닫히는 때: 본인 선언, 잡힘, 본인 손패 수가 1이 아니게 됨(공격 등으로 카드를 먹음), 또는 그 사람의 다음 턴이 시작됨. 다음 턴까지 아무도 잡지 못했으면 그대로 마지막 카드를 내고 나갈 수 있다.
- 창이 열려 있는 동안에도 게임은 멈추지 않는다. 다음 사람은 바로 자기 턴을 진행할 수 있다.
- `autoDeclare = on`이면 1장이 되는 순간 자동으로 선언된다.
- `uno-style` 원작 규칙: 끝에서 두 번째 카드를 낼 때 외쳐야 한다. 다음 사람이 턴을 시작(서버에 그 사람의 첫 액션이 적용)하기 전에 잡혀야 벌칙(2장)이 있다. 다음 사람이 행동한 뒤에는 잡을 수 없고 창이 닫힌다.
### 5.4 파산(`classic`, `uno-style` 공통 옵션)
- 카드를 먹은 뒤 손패가 `bankruptLimit`장 이상이 되면 그 플레이어는 즉시 파산(탈락)한다.
- 파산한 사람의 손패는 드로우 더미 맨 아래에 넣고 섞지 않는다(결정적 처리).
- 탈락자는 턴 순서에서 빠진다. 파산을 일으킨 공격의 누적은 이미 0이 되었으므로 다음 사람은 일반 턴을 한다.
- 남은 사람이 1명이면 그 사람이 1등이고 게임이 끝난다.
### 5.5 드로우 더미가 부족할 때
- 뽑아야 할 때 드로우 더미가 비면 버린 더미 맨 위 카드를 뺀 나머지를 시드 RNG로 섞어 새 드로우 더미를 만든다.
- 그래도 모자라면 있는 만큼만 뽑고 부족분은 없던 일로 한다. 공격 누적도 0으로 끝난다.
### 5.6 `uno-style` 턴 행동
- **카드 내기**: 현재 색(`declaredColor` 또는 맨 위 카드의 색)이 같거나, 숫자/기호가 같거나, 무색(색 바꾸기, 색 바꾸기+4) 카드면 낼 수 있다.
- **색 바꾸기+4 조건**
- `strict`: 현재 색과 같은 색 카드가 손에 없을 때만 낼 수 있다. 숫자/기호만 맞는 카드나 다른 무색 카드는 있어도 된다. 서버가 검증해서 막는다.
- `challenge`(원작): 언제든 낼 수 있다. 다음 사람이 "의심하기"를 하면 낸 사람의 손패를 의심한 사람에게만 공개한다.
- 위반(현재 색 카드가 있었음)이면 낸 사람이 4장을 먹는다. 의심한 사람은 먹지 않고 정상적으로 자기 턴을 한다.
- 위반이 아니면 의심한 사람이 6장(4+2)을 먹고 턴을 잃는다.
- "받아들이기"를 고르면 4장을 먹고 턴을 잃는다.
- `free`: 아무 제한 없음.
- **먹기**: 1장을 뽑는다. `playAfterDraw = on`(원작)이면 뽑은 카드가 낼 수 있을 때 그 카드만 바로 낼 수 있다. 낼 카드가 있어도 먹을 수 있다(원작 허용).
- **효과**
- 건너뛰기: 다음 사람 건너뛰기.
- 방향 바꾸기: 방향 반대. 2인이면 건너뛰기와 같다.
- +2: 다음 사람이 2장을 먹고 턴을 잃는다.
- 색 바꾸기: 색 지정.
- 색 바꾸기+4: 색 지정 후, 다음 사람이 4장을 먹고 턴을 잃는다.
- `unoStacking = on`(하우스 룰): +2에는 +2나 +4로, +4에는 +4로 받아칠 수 있고 누적된다. 받아칠 수 없거나 받아치지 않으면 누적량을 먹고 턴을 잃는다. `challenge`와 함께 켜면 의심하기는 누적 전체에 대해 직전 +4 한 장만 판정한다.
- 마지막 카드가 기능 카드여도 된다. +2/+4로 끝나면 다음 사람이 그 장수를 먹는다(점수 계산에 반영).
## 6. 승패와 점수 계산
- `endCondition = first-out`(기본): 처음으로 손패를 모두 낸 사람이 1등이고 게임이 끝난다. 나머지 순위는 아래 순서로 정한다.
1. 파산하지 않은 사람이 파산한 사람보다 앞선다.
2. 파산하지 않은 사람끼리는 남은 장수가 적은 순이다.
3. 장수가 같으면 남은 카드 점수 합(아래 표)이 낮은 순이다.
4. 그래도 같으면 공동 순위다.
5. 파산한 사람끼리는 늦게 파산한 사람이 앞선다.
- `endCondition = last-standing`: 나간 순서대로 1등, 2등…이 정해지고 마지막 1명이 남으면 끝난다. 그 사람이 꼴찌(파산자보다는 위)다.
- 카드 점수(`classic` 동점 처리 및 결과 요약용): 숫자 카드 = 숫자, J/Q/K = 10, A = 1, 조커 = 20.
- `uno-style` 원작 점수: 이긴 사람이 다른 사람들 손패 점수 합을 얻는다.
- 숫자 카드 = 숫자(0~9)
- 건너뛰기, 방향 바꾸기, +2 = 각 20점
- 색 바꾸기, 색 바꾸기+4 = 각 50점
- 예: 남은 카드가 B=[7, +2], C=[색 바꾸기], D=[0, 3]이면 승자는 (7+20) + 50 + (0+3) = 80점.
- `unoTargetScore`가 있으면 누적 점수가 목표에 먼저 닿은 사람이 최종 승리한다. 같은 판에서 둘 이상이 넘을 수는 없다(그 판 승자만 점수를 얻음).
- 이 게임은 칩이나 포인트를 걸지 않는다. 점수는 방 안에서만 보이는 결과 표시용이다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 공격량(`attack2`, `attackA`, `attackSpadeA`, `attackBlackJoker`, `attackColorJoker`): 지역마다 2→1, A→2, ♠A→5~10, 흑백 조커 5~10, 컬러 조커 7~13처럼 다르다(나무위키).
- 3 방어(`threeDefense`): 같은 무늬 3으로 막기. 끌 수 있다.
- K 효과(`kEffect`): 한 번 더 내기, 두 명 건너뛰기 등.
- 마지막 카드 제한(`lastCardRule`): 공격 카드, 특수 카드, 조커로 끝내기 금지.
- 같은 숫자 동시 내기(`multiPlay`).
- 파산 장수(`bankruptLimit`): 15~25장, 또는 파산 없음.
- 조커 다음 카드(`afterJoker`).
- 지원하지 않는 하우스 룰(문서화만): 스트레이트(같은 무늬 연속 숫자 한꺼번에 내기), 끼어들기(같은 카드가 있으면 차례가 아니어도 내기), 7-0 바꾸기. 끼어들기는 실시간 경쟁이라 서버 도착 순서로 처리할 수 있으므로 나중에 옵션으로 추가할 수 있다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Suit = 'S' | 'H' | 'D' | 'C';
type Color = 'R' | 'Y' | 'G' | 'B';
type ClassicCard =
| { id: string; kind: 'std'; suit: Suit; rank: 1|2|3|4|5|6|7|8|9|10|11|12|13 } // 1=A, 11=J, 12=Q, 13=K
| { id: string; kind: 'joker'; joker: 'black' | 'color' };
type UnoCard =
| { id: string; kind: 'num'; color: Color; value: 0|1|2|3|4|5|6|7|8|9 }
| { id: string; kind: 'skip' | 'reverse' | 'draw2'; color: Color }
| { id: string; kind: 'wild' | 'wild4' };
type Card = ClassicCard | UnoCard;
interface OneCardState {
mode: 'classic' | 'uno-style';
options: OneCardOptions;
rng: RngState;
seats: PlayerId[]; // 좌석 순서(시작 시 고정)
hands: Record<PlayerId, Card[]>; // 비공개
drawPile: Card[]; // 비공개, [0]이 맨 위
discard: Card[]; // 공개, 마지막이 맨 위
declaredSuit: Suit | null; // 7로 지정된 무늬
declaredColor: Color | null; // 색 바꾸기로 지정된 색
direction: 1 | -1;
current: PlayerId; // 현재 턴
phase:
| { kind: 'turn' }
| { kind: 'afterDraw'; drawnCardId: string } // playAfterDraw 선택 대기
| { kind: 'wild4Response'; by: PlayerId; target: PlayerId; prevColor: Color } // challenge 모드
| { kind: 'finished' };
pendingAttack: number; // classic 공격 누적 / uno 스태킹 누적
extraTurn: boolean; // K 추가 턴 진행 중
oneCardWindow: { player: PlayerId; declared: boolean; openedSeq: number } | null;
status: Record<PlayerId, 'playing' | 'out' | 'bankrupt'>;
finishOrder: PlayerId[]; // 나간 순서
bankruptOrder: PlayerId[]; // 파산 순서
turnStartedAt: number;
seq: number; // 적용된 액션 수(타이머·창 판정용)
scores?: Record<PlayerId, number>; // uno-style 누적 점수(여러 판)
log: GameEvent[]; // 최근 공개 이벤트(선택)
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `play` | `{ cardIds: string[]; chooseSuit?: Suit; chooseColor?: Color; declareOneCard?: boolean }` | `current`, phase=`turn` 또는 `afterDraw` | 카드가 모두 자기 손에 있음. `multiPlay` off면 1장, on이면 숫자가 모두 같음. 공격 중이면 받아치기표나 3 방어 조건, 아니면 5.2 B 조건. 7이면 `chooseSuit` 필수, 색 바꾸기류면 `chooseColor` 필수. `lastCardRule` 위반 아님. `afterDraw`면 방금 뽑은 카드 1장만. `uno-style`의 `strict`이면 +4 조건 |
| `draw` | `{}` | `current`, phase=`turn` | 공격 중이면 항상 가능(누적량만큼 먹음). 아니면 `voluntaryDraw` on이거나 낼 카드가 없을 때 |
| `pass` | `{}` | `current`, phase=`afterDraw` | 뽑은 카드를 내지 않고 턴 종료 |
| `declareOneCard` | `{}` | 참가자 누구나(관전자·탈락자 제외), `oneCardWindow` 열림 | 창이 열려 있고 아직 판정 전. 본인이면 선언, 타인이면 잡기 |
| `challengeWild4` | `{}` | phase=`wild4Response`의 `target` | `unoWild4Rule = challenge` |
| `acceptWild4` | `{}` | phase=`wild4Response`의 `target` | 같음 |
- `reason` 예: "같은 무늬나 같은 숫자 카드만 낼 수 있어요", "공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요", "마지막 카드로 조커는 낼 수 없어요", "무늬를 골라 주세요".
- 이벤트 예: `cardPlayed`, `cardsDrawn{player,count}`, `attackStacked{total}`, `attackBlocked`, `suitDeclared`, `skipped`, `directionChanged`, `oneCardDeclared`, `oneCardCaught{catcher,target,penalty}`, `bankrupt`, `playerOut`, `reshuffled`, `wild4Challenged{result}`.
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함)에게 공개되는 것
- 버린 더미 맨 위 카드와 최근 몇 장(애니메이션용)
- `declaredSuit`/`declaredColor`, 방향, 현재 턴, `pendingAttack`
- 각 플레이어의 손패 장수, 상태(진행/나감/파산), 드로우 더미 장수
- 원카드 창이 누구에게 열려 있는지, 타이머 마감 시각
- 본인에게만 공개되는 것
- 자기 손패 전체
- 자기 손패 중 지금 낼 수 있는 카드 ID 목록(`legalCardIds`). 하이라이트용으로 서버가 계산한다.
- `wild4Response`에서 의심하기를 한 경우: `challengeReveal` 이벤트로 낸 사람의 손패를 의심한 사람의 view에만 1회 넣는다. 나머지 사람과 관전자는 결과(위반/정상)만 받는다.
- 절대 보내지 않는 것: 드로우 더미 순서, 다른 사람 손패, RNG 상태. 다른 사람이 먹은 카드는 장수만 보낸다(`cardsDrawn` 이벤트도 viewer별로 카드 내용을 걸러서 보낸다).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `setup`: 덱 섞기, 선 플레이어 선택.
- 시작 카드 재뒤집기는 RNG를 쓰지 않는다(맨 아래로 보내기만 함).
- 드로우 더미 재구성 때 버린 더미 섞기.
- `uno-style` 시작 카드가 색 바꾸기+4일 때 다시 섞기.
- `onTimeout` 자동 행동에서 여러 후보 중 고를 때(아래).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: `turnStartedAt + turnSeconds*1000`. `wild4Response`는 10초. 원카드 창에는 마감이 없다.
- `onTimeout(state, player)`
- 공격을 받는 중이면 `draw`(누적량 먹기). 자동 받아치기는 하지 않는다.
- 일반 턴이면 `draw`. 자동으로 카드를 내지 않는 쪽이 공정성 시비가 적다.
- `afterDraw`이면 `pass`.
- `wild4Response`이면 `acceptWild4`.
- 연결이 끊긴 플레이어는 매 턴 즉시 타임아웃 처리하지 않고 정상 타이머를 그대로 쓴다. 3턴 연속 타임아웃이면 "자리 비움"으로 표시하고, 이후에는 타이머를 5초로 줄인다(서버 정책, 엔진 밖).
- 원카드 창에서 본인이 끊겨 있으면 선언하지 못하므로 잡힐 수 있다. 실제 게임과 같게 둔다.
### 8.6 종료 조건과 결과(GameResult)
- 종료 조건
- `first-out`이면 첫 번째로 나간 사람이 생길 때.
- `last-standing`이면 진행 중인 사람이 1명 이하일 때.
- 파산으로 1명만 남았을 때.
- `uno-style` 점수제는 각 판을 한 게임으로 보고, 방 레벨에서 "다음 판"으로 이어간다. 엔진은 `scores`를 다음 `setup`에 넘길 수 있게 `options.carryScores`로 받는다.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; status: 'out' | 'playing' | 'bankrupt'; cardsLeft: number; points: number }[];
winner: PlayerId[];
summary: string; // 예: "민수 님이 가장 먼저 카드를 모두 냈어요!"
}
```
## 9. UI/UX
- 모바일 세로 화면
- 위쪽: 상대 아바타가 원형/가로 스크롤로 배치되고, 각자 손패 장수 배지, 현재 턴 강조, 파산 표시가 붙는다.
- 가운데: 버린 더미(큰 카드), 드로우 더미, 진행 방향 화살표, 지정 무늬/색 아이콘, 공격 누적 배지("+7장!")가 크게 보인다.
- 아래: 내 손패(부채꼴, 2줄 자동 줄바꿈). 카드 하나는 최소 48×68px.
- 손패 정렬 버튼: 무늬별, 숫자별.
- PC: 같은 배치를 가로로 넓힌다. 손패는 한 줄이다.
- 조작
- 카드를 탭하면 위로 올라오고, 한 번 더 탭하거나 "내기" 버튼을 누르면 낸다. 드래그해서 버린 더미에 놓아도 된다.
- 낼 수 없는 카드는 회색 처리하고, 낼 수 있는 카드에 테두리를 표시한다.
- 7이나 색 바꾸기를 내면 큰 무늬/색 버튼 4개가 뜬다.
- 공격을 받을 때는 "N장 먹기" 버튼과 받아칠 수 있는 카드 하이라이트를 같이 보여 준다.
- 원카드 버튼: 화면 오른쪽 아래에 큰 원형 버튼이 있고, 누군가 1장이 되면 흔들리며 활성화된다. 내가 1장을 남기는 카드를 고르면 "원카드 외치며 내기"가 기본 선택이다.
- 애니메이션: 카드 날아가기, 공격 누적 숫자 커지기, 방향 전환 화살표 회전, 파산 시 카드 흩어짐. 모두 300ms 이하이고, "애니메이션 줄이기" 설정을 따른다.
- 초보자 도움말
- "규칙 보기"에 특수 카드 표를 그림으로 넣는다.
- 처음 몇 판은 "힌트" 토글로 낼 수 있는 카드 이유를 보여 준다(예: "♥ 무늬가 같아요").
- 공격받을 때 "막을 수 있는 카드: ♣2, 조커" 같은 안내 문구를 띄운다.
- 접근성: 무늬는 색과 모양을 같이 쓴다(♥♦ 빨강, ♠♣ 검정). `uno-style` 색 카드에는 색맹 대비용 무늬 패턴(점·줄·격자·물결)과 색 이름 글자를 함께 넣는다.
## 10. 테스트 체크리스트
- [ ] 셋업: 같은 시드면 같은 손패와 시작 카드가 나온다. 시작 카드가 2/A/7/J/Q/K/3/조커면 맨 아래로 가고 일반 숫자 카드가 시작 카드가 된다.
- [ ] ♥2 공격(2장) 다음 사람이 ♥A로 받아치면 누적 5장이고, 그다음 사람이 먹으면 정확히 5장을 받고 턴이 끝난다.
- [ ] ♥2 위에 ♣A는 받아치기가 거부된다(다른 무늬 A). ♣2는 허용된다.
- [ ] ♠A 위에 ♥A로 받아치기는 거부되고 흑백 조커는 허용된다. 흑백 조커 위 컬러 조커는 허용되고, 컬러 조커 위 흑백 조커는 거부된다.
- [ ] `threeDefense`: ♦A 공격에 ♦3을 내면 누적이 0이 되고 다음 사람은 ♦3 기준 일반 턴을 한다. ♣3은 거부된다. 조커 공격에는 3이 거부된다.
- [ ] 7을 내고 ♠을 지정하면 다음 사람은 ♠ 카드나 7만 낼 수 있고, 같은 숫자(바닥 7의 원래 무늬) 일치는 쓸 수 없다.
- [ ] J를 2인에서 내면 낸 사람이 다시 턴을 한다. Q를 2인에서 내면 기본값에서 상대 턴이다.
- [ ] K `extra-turn`: 추가 턴에 낼 카드가 없으면 1장을 먹는다. K가 마지막 카드면 바로 나간다.
- [ ] `lastCardRule = joker`: 손패가 [조커, ♥5]인 상태에서 ♥5를 내면 조커 1장이 남는다. 다음 턴에 조커를 내려 하면 "마지막 카드로 조커는 낼 수 없어요"로 거부된다.
- [ ] 원카드: A가 ♥5를 내서 1장이 되었을 때 B의 `declareOneCard`가 먼저 도착하면 A가 1장을 먹는다. A가 먼저 도착하면 B의 요청은 무시된다. `play`에 `declareOneCard: true`를 같이 보내면 잡히지 않는다.
- [ ] 파산: 손패 15장에서 공격 5장을 먹어 20장이 되면 즉시 파산한다. 그 카드는 드로우 더미 아래로 가고 턴 순서에서 빠진다. 2인 게임이면 상대가 바로 1등이다.
- [ ] 드로우 더미 0장에서 7장을 먹을 때 버린 더미(맨 위 제외)가 섞여 보충된다. 그래도 부족하면 있는 만큼만 먹고 오류 없이 진행된다.
- [ ] `uno-style` `strict`: 현재 색이 빨강이고 손에 빨강 3이 있으면 색 바꾸기+4가 거부된다. 빨강이 없고 파랑 +2만 있으면 허용된다.
- [ ] `uno-style` `challenge`: 위반이 확인되면 낸 사람이 4장을 먹는다. 정상이면 의심한 사람이 6장을 먹는다. 낸 사람 손패는 의심한 사람의 view에만 나오고 다른 사람과 관전자의 view에는 없다.
- [ ] `uno-style` 방향 바꾸기가 2인에서 건너뛰기로 동작한다. 시작 카드가 +2이면 선 플레이어가 2장을 먹고 턴을 잃는다.
- [ ] `uno-style` 점수: 위 6장 예시처럼 80점이 계산된다.
- [ ] 타임아웃: 공격받는 중 시간이 지나면 누적량을 먹는다. `afterDraw`에서 시간이 지나면 `pass`한다.
- [ ] 연결 끊김: 끊긴 플레이어 턴은 타이머대로 자동 먹기로 진행되고 게임이 멈추지 않는다.
- [ ] 정보 유출: 관전자 view와 상대 view를 JSON으로 직렬화했을 때 다른 사람의 카드 ID/무늬/숫자, 드로우 더미 내용이 들어 있지 않다. `cardsDrawn` 이벤트도 본인 외에는 장수만 들어 있다.
- [ ] 차례가 아닌 사람의 `play`, 손에 없는 카드 ID, 이미 낸 카드 ID 재전송(중복 패킷)이 모두 거부되고 상태가 바뀌지 않는다.
## 11. 참고 자료
- 나무위키 "원카드": https://namu.wiki/w/원카드 (공격 카드 장수와 방어, 7/J/Q/K, 원카드 선언, 파산 15~20장, 마지막 카드 제한, 2~9명)
- 위키백과 "원카드": https://ko.wikipedia.org/wiki/원카드 (♠A 5장, 흑백 조커 5장, 컬러 조커 7장 등)
- Wikipedia "Uno (card game)": https://en.wikipedia.org/wiki/Uno_(card_game) (108장 구성, 7장 배분, +4 의심 규칙, 미선언 벌칙 2장, 점수 20/50점, 500점)
- 원작 공식 규칙서(제조사 배포 PDF, 2~10명, 시작 카드 처리, 2인 방향 바꾸기 규칙)
## 12. 메모 (상표·법적 주의 등)
- "원카드"는 특정 회사 상품이 아닌 민속 게임 이름이라 그대로 써도 된다. 카드 그림은 트럼프의 일반적인 디자인을 직접 그려서 쓴다(특정 회사의 카드 뒷면 디자인을 베끼지 않는다).
- `uno-style` 모드: "UNO"는 Mattel의 등록 상표다. 화면, 도메인, 홍보에 "UNO", "우노", 원작 로고, 고유 카드 디자인(빨간 타원 로고 등)을 쓰지 않는다. 표시 이름 후보: "색깔 카드", "컬러 원카드", "무지개 카드"(사용자 선택). 게임 규칙 자체는 저작권 보호 대상이 아니지만, 규칙서 문구를 그대로 옮기지 않고 직접 쓴 설명을 쓴다.
- 돈, 칩, 포인트를 걸지 않는 게임이다. `uno-style` 점수는 방 안 결과 표시용이고 저장, 교환, 이월(방 밖)이 없다.

155
docs/games/othello.md Normal file
View File

@@ -0,0 +1,155 @@
# 리버시 (오셀로) (`othello`)
> 마일스톤: M2 · 인원: 최소 2 ~ 최대 2명 · 예상 시간: 약 10~15분 · 난이도: 쉬움
## 1. 개요
- 8×8 판에서 상대 돌을 내 돌 사이에 끼우면 뒤집어 내 색으로 만드는 2인 게임. 끝났을 때 자기 색 돌이 더 많으면 승리. 한국에서는 "오셀로"라는 이름으로 잘 알려져 있고 규칙 설명이 1분이면 끝나 어린이·어르신 모두에게 적합하다.
- 인원 근거: 원작이 흑·백 2인 게임. 최소 2, 최대 2.
- 규칙 기준: 세계오셀로연맹(WOF) 공식 규칙.
- 완전 정보 게임. view에서 제외할 것은 RNG 상태뿐.
- 표시 이름은 상표 문제로 "리버시"를 권장(12장).
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `colorAssignment` | 흑백 결정 | `random` / `hostBlack` / `hostWhite` / `alternate` | `random` |
| `emptyToWinner` | 종료 시 빈칸을 승자 점수에 더함(WOF 대회 방식, 승패에는 영향 없음 — 점수 표시만 바뀜) | `true` / `false` | `false` |
| `timeControl.kind` | 시간 방식 | `none` / `perMove` / `fischer` | `perMove` |
| `timeControl.perMoveSec` | 한 수당 제한 | 10 / 20 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 | 3+2, 5+3, 10+5 | 5+3 |
| `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(무작위 합법 수) / `lose` | `autoMove` |
| `idleLimitSec` | `none` 무응답 한도 | 120 / 300 / 600 | 300 |
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `showLegalMoves` | 둘 수 있는 칸 표시 | `true` / `false` | `true` |
| `showFlipCount` | 둘 수 있는 칸에 뒤집히는 개수 숫자 표시(초보 도움말) | `true` / `false` | `false` |
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
## 3. 구성물
- 8×8 판(64칸). 돌은 칸 안에 놓는다.
- 한 면이 흑, 다른 면이 백인 원판 돌 64개(엔진에서는 개수 제한 없음 — 칸 수가 상한).
- 좌표: 열 a~h(왼→오), 행 1~8(위→아래, 오셀로 표준 표기). 내부 `(x, y)` 0~7, 표시 `'abcdefgh'[x] + (y+1)`.
## 4. 준비(셋업)
1. `colorAssignment`로 흑백 결정.
2. 가운데 4칸에 배치: d4 백, e4 흑, d5 흑, e5 백.
3. 흑이 먼저 둔다. 시작 시 흑의 합법 수는 d3, c4, f5, e6 네 곳.
## 5. 진행 규칙
- 합법 수: 빈칸 `p`에 내 돌을 놓았을 때, 8방향(가로·세로·대각) 중 적어도 한 방향에서 `p` 바로 옆부터 상대 돌이 1개 이상 연속되고 그 끝에 내 돌이 있는 경우.
- 착수 처리: 위 조건을 만족하는 모든 방향에 대해, 사이에 낀 상대 돌을 전부 내 색으로 뒤집는다. 뒤집힌 돌로 인해 새로 끼워지는 돌은 연쇄로 뒤집지 않는다(한 수에서 놓은 돌 기준의 직선만).
- 뒤집을 수 있는 칸이 있으면 반드시 그중 한 곳에 둬야 한다(자발적 패스 없음).
- 패스: 차례인 플레이어에게 합법 수가 하나도 없으면 자동으로 차례가 상대에게 넘어간다(서버가 처리, 플레이어 행동 불필요). 이벤트 `passed`를 남긴다.
- 종료: 양쪽 모두 합법 수가 없으면(판이 가득 찬 경우 포함) 즉시 종료.
- 예시: 시작 직후 흑이 d3에 두면 d4 백이 흑으로 뒤집힌다(d3–d4–d5 세로). 흑 4, 백 1.
## 6. 승패와 점수 계산
- 점수 = 종료 시 판 위 자기 색 돌 수.
- 많은 쪽 승리, 같으면 무승부.
- `emptyToWinner=true`(WOF 대회 방식): 빈칸이 남은 채 끝났으면 빈칸 수를 승자 점수에 더한다. 무승부면 빈칸을 반씩 나눈다(빈칸 수는 무승부 시 항상 짝수). 예: 흑 40, 백 20, 빈칸 4 → 표시 점수 44 : 20. 승패는 동일.
- 그 외 종료: 기권, 시간패, 무승부 합의.
- 기권·시간패로 끝나도 결과에 당시 돌 수를 함께 기록.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 가운데 4칸 초기 배치를 직접 놓는 옛 방식(초기 4수를 번갈아 가운데 4칸에 두는 리버시 원형 규칙)은 지원하지 않는다. 표준 대각 배치 고정.
- 6×6, 10×10 판 변형은 v1 미지원(추후 `boardSize` 옵션 후보).
- 빈칸을 승자에게 주는 대회 점수 방식은 옵션.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Color = 'black' | 'white';
type Cell = 0 | 1 | 2;
interface OthelloState {
options: OthelloOptions;
board: Cell[]; // 64, index = y*8 + x
players: Record<Color, PlayerId>;
turn: Color;
phase: 'playing' | 'finished';
moves: ({ kind: 'place'; color: Color; x: number; y: number; flipped: number[]; auto: boolean }
| { kind: 'pass'; color: Color })[]; // 자동 패스도 기록(무르기·리플레이용)
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; autoMoveStreak: Record<Color, number> };
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: number } | null };
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number> };
result: OthelloResult | null;
rng: RngState;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `place` | `{ x, y }` | 차례인 플레이어, `playing` | 판 안, 빈칸, 뒤집히는 돌이 1개 이상("여기에 두면 뒤집을 돌이 없어요") |
| `requestUndo` | `{}` | 플레이어 | 횟수 남음, 대기 없음, 본인 착수 존재. 무를 범위 = 기록의 끝에서부터 본인의 마지막 `place`까지(그 사이 자동 패스 포함) |
| `respondUndo` | `{ accept }` | 상대 | 수락 시 `moves`를 처음부터 재생해 판 복원, 차례 = 요청자. 시계 유지 |
| `offerDraw` / `respondDraw` / `cancelOffer` | | 플레이어 | 거절 후 본인 3수 이후 재제안 |
| `resign` | `{}` | 플레이어 | 언제든 |
| `timeout` | `{}` | 시스템 | 8.5 |
- `place` 처리 후: 상대에게 합법 수가 있으면 차례 넘김. 없고 나에게 있으면 상대 자동 패스 기록 후 다시 내 차례. 둘 다 없으면 종료.
- 시계: `fischer`는 착수 시 차감 후 증가분 가산. 자동 패스는 시간이 들지 않으며 다시 차례가 온 플레이어의 `turnStartedAt = now`.
- 이벤트: `placed`(뒤집힌 칸 목록 — 애니메이션용), `passed`, `gameEnded`.
### 8.3 공개/비공개 정보 (view)
- 공통 공개: 판, 차례, 돌 수, 기보, 마지막 수, 시계, `deadline`, 대기 요청, 결과.
- 차례인 플레이어에게만: `legalMoves: { x, y, flips: number }[]` (`showLegalMoves`/`showFlipCount` 옵션에 따라 클라이언트가 표시).
- 관전자: `legalMoves` 없음(해설 모드 필요 시 추후).
- 제외: `rng`.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `colorAssignment=random`.
2. `timeoutPolicy=autoMove`의 자동 착수: 모서리(a1, h1, a8, h8)가 합법이면 그중 무작위, 아니면 전체 합법 수 중 균등 무작위. 모서리 우선은 시간 초과 당한 쪽이 지나치게 불리해지지 않게 하는 최소한의 배려.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[turn]`, `none` → `turnStartedAt + idleLimitSec*1000`, 종료 → `null`.
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- `perMove + autoMove`: 자동 착수(`auto: true`), streak +1, 3연속이면 시간패.
- 그 외: 시간패.
- 연결 끊김: 끊긴 플레이어 차례에 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout`.
### 8.6 종료 조건과 결과(GameResult)
```ts
interface OthelloResult {
ranking: { playerId: PlayerId; rank: 1 | 2 }[];
winner: Color | null;
reason: 'noMoves' | 'resign' | 'timeLoss' | 'drawAgreed';
discs: { black: number; white: number; empty: number };
displayScore: { black: number; white: number }; // emptyToWinner 반영
summaryKo: string; // "흑 승 40 : 24"
}
```
## 9. UI/UX
- 모바일 세로: 상단 상대(돌 수, 시계), 가운데 8×8 판(폰 360px에서 칸 약 44px — 칸 전체가 터치 영역, 48px에 근접하므로 한 번 탭 착수 허용), 하단 내 정보와 버튼.
- 둘 수 있는 칸은 작은 반투명 점. `showFlipCount`면 숫자.
- 착수 시 뒤집히는 돌을 놓은 돌에서 가까운 순서로 순차 회전 애니메이션(전체 400ms 이내).
- 자동 패스 시 "둘 곳이 없어서 차례가 넘어갔어요" 토스트(상대 화면에도).
- 돌 수 막대(흑/백 비율)를 상단에 상시 표시.
- 규칙 보기: 끼워서 뒤집기 3단계 그림, 패스 설명, 모서리 팁(선택).
- PC: 판 좌측, 기보·채팅 우측.
## 10. 테스트 체크리스트
- [ ] 초기 배치(d4 백, e4 흑, d5 흑, e5 백)와 흑의 첫 합법 수 4개(d3, c4, f5, e6).
- [ ] 8방향 각각 뒤집기, 한 수에 여러 방향 동시 뒤집기.
- [ ] 연쇄 뒤집기 없음(뒤집힌 돌이 만든 새 끼움은 뒤집지 않음).
- [ ] 뒤집을 돌이 없는 빈칸 착수 거부, 판 밖·이미 찬 칸 거부.
- [ ] 자동 패스: 상대 합법 수 없음 → 패스 기록 후 내 차례 유지.
- [ ] 양쪽 합법 수 없음 → 판이 다 차지 않아도 종료(예: 한 색이 전멸).
- [ ] 동점 무승부, `emptyToWinner` 점수 표시(승리·무승부 각각).
- [ ] 무르기: 자동 패스가 끼어 있는 경우에도 정확히 본인 마지막 착수 전으로 복원.
- [ ] `autoMove`: 모서리 우선, 같은 시드에서 같은 선택, 3연속 시간패.
- [ ] `fischer` 시간패, 증가분 가산.
- [ ] 연결 끊김 유예 후 처리, 재접속 시 해제.
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves` 없음, 모든 view에 `rng` 없음.
- [ ] 리플레이 결정성.
## 11. 참고 자료
- World Othello Federation 공식 규칙: https://www.worldothello.org/about/about-othello/othello-rules/official-rules/english (초기 배치, 흑 선, 합법 수, 패스, 종료, 다수 승리)
- Wikipedia "Reversi": https://en.wikipedia.org/wiki/Reversi (오셀로/리버시 역사, 대회의 빈칸 승자 귀속 관행)
- 위키백과 「오셀로 (보드 게임)」: https://ko.wikipedia.org/wiki/%EC%98%A4%EC%85%80%EB%A1%9C_(%EB%B3%B4%EB%93%9C_%EA%B2%8C%EC%9E%84)
## 12. 메모 (상표·법적 주의 등)
- "Othello"/"오셀로"는 상표(현재 일본 MegaHouse 및 Othello Co. 계열이 보유)이므로 서비스 표시 이름은 일반 명칭 "리버시"를 권장한다. 규칙 설명에서 "오셀로와 같은 규칙" 정도의 비교 서술은 지명적 사용으로 보통 허용되나, 로고·트레이드 드레스(초록 판 + 특정 디자인) 모방은 피한다. 최종 표시 이름은 사용자 결정 사항.
- 게임 id `othello`는 내부 식별자일 뿐 UI에 노출하지 않는다(필요하면 `reversi`로 변경 가능).
- 금전·베팅 요소 없음.

692
docs/games/poker.md Normal file
View File

@@ -0,0 +1,692 @@
# 포커 (`poker`)
> 마일스톤: M3 · 인원: 최소 2 ~ 최대 10명 (모드별 상한은 2.1 표) · 예상 시간: 약 20~40분 (20핸드 기준) · 난이도: 보통(인디언 포커, 7포커) ~ 어려움(오마하, 하이로우)
## 1. 개요
- 52장 트럼프 카드로 하는 베팅 게임 묶음이다. 한 문서·한 엔진에 7개 모드를 `mode` 옵션으로 담는다: 텍사스 홀덤, 7포커(한국식 초이스 세븐 스터드), 7포커 하이로우, 바둑이(한국식 로우 바둑이), 인디언 포커, 5카드 드로우, 오마하.
- 한국에서는 한게임·넷마블·피망 등 웹보드 게임으로 7포커·하이로우·바둑이가 오래 친숙했고, 최근에는 홀덤 펍과 방송 영향으로 텍사스 홀덤 인지도가 높다. 인디언 포커는 예능 "더 지니어스" 이후 파티 게임으로 알려졌다.
- 돈은 전혀 쓰지 않는다. 칩은 방 안에서만 존재하는 가상 칩이며 방이 끝나면 사라진다. 저장·이월·충전 결제·환전·양도가 없다(12장).
- 인원 근거(원작·전통 규칙 기준, 2.1 표에 모드별로 정리):
- 홀덤: 통상 2~10명(위키백과 "usually 2–10").
- 오마하: 2~10명(원작 2~11명이지만 4장씩 받으므로 화면 배치상 10명으로 제한).
- 7포커/하이로우: 2~6명. 한국식 초이스 룰은 1인당 8장(처음 4장 + 4·5·6·7구 4장)을 소모하므로 52장으로 6명(48장)이 상한이다. 7명이면 카드가 모자란다.
- 바둑이: 2~6명(위키백과 "usually played six-handed", 교환 시 버린 카드를 다시 섞어 사용).
- 5카드 드로우: 2~6명(교환이 있는 드로우 포커의 일반적 상한).
- 인디언 포커: 2~6명(1~10 두 벌 20장 덱 기준. 52장 덱 옵션 사용 시 1장 모드는 최대 10명).
## 2. 모드와 옵션
### 2.1 모드별 요약
| 모드(`mode`) | 이름 | 인원 | 받는 카드 | 베팅 라운드 | 기본 베팅 방식 | 강제 베팅 | 동점 처리 |
|---|---|---|---|---|---|---|---|
| `holdem` | 텍사스 홀덤 | 2~10 | 개인 2 + 공용 5 | 4 (프리플랍/플랍/턴/리버) | 노리밋 | 스몰·빅 블라인드 | 키커 비교 후 분할 |
| `omaha` | 오마하 | 2~10 | 개인 4 + 공용 5 (개인 정확히 2장 + 공용 정확히 3장) | 4 | 팟리밋 | 스몰·빅 블라인드 | 키커 비교 후 분할 |
| `sevenPoker` | 7포커 | 2~6 | 4장 받아 1장 버리고 1장 오픈, 이후 4·5·6구 오픈, 7구 히든 | 4 (4구/5구/6구/7구) | 한국식 판돈 | 앤티(기본 판돈) | 무늬 서열(♠>◆>♥>♣) |
| `sevenHiLo` | 7포커 하이로우 | 2~6 | 7포커와 동일 | 4 + 선언 | 한국식 판돈 | 앤티 | 무늬 서열, 하이/로우 반분 |
| `badugi` | 바둑이 | 2~6 | 4장, 교환 3회(아침/점심/저녁) | 4 | 한국식 판돈 | 앤티 | 분할(무늬 비교 없음) |
| `fiveDraw` | 5카드 드로우 | 2~6 | 5장, 교환 1회 | 2 | 한국식 판돈 | 앤티 | 키커 비교 후 분할 |
| `indian` | 인디언 포커 | 2~6 (52장 덱 1장 모드는 2~10) | 1장(이마) 또는 2장(이마 1 + 손 1) | 1장 모드 1회, 2장 모드 2회 | 노리밋(최소 단위 1) | 앤티 | 판돈 이월(옵션: 분할) |
`betting` 옵션으로 어떤 모드든 베팅 방식을 바꿀 수 있다(예: 7포커를 노리밋으로). 단 블라인드는 공용 카드형(`holdem`, `omaha`)에서만 쓰고, 나머지 모드에서 `noLimit`/`potLimit`을 고르면 강제 베팅은 앤티로 한다.
### 2.2 옵션 표
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mode` | 게임 모드 | 2.1 표의 7개 | `sevenPoker` |
| `betting` | 베팅 방식 | `auto`(모드 기본) / `korean` / `noLimit` / `potLimit` | `auto` |
| `startingChips` | 시작 칩 | 500 / 1000 / 2000 / 5000 | 1000 |
| `baseUnit` | 기본 단위. 한국식=앤티(삥) 금액, 블라인드형=빅 블라인드(스몰은 절반, 내림) | 2 / 10 / 20 / 50 | 10 |
| `koreanMaxRaise` | 한국식 베팅에서 허용하는 최대 레이즈 | `half`(하프까지) / `full`(풀까지) | `half` |
| `endCondition` | 게임 종료 방식 | `hands`(정해진 핸드 수) / `lastStanding`(최후 1인) | `hands` |
| `handLimit` | `hands`일 때 총 핸드 수 | 10 / 20 / 30 / 50 | 20 |
| `blindUpEvery` | N핸드마다 기본 단위 2배(0=안 올림) | 0 / 5 / 10 / 15 | 0 (`lastStanding` 선택 시 UI가 10을 추천) |
| `rebuy` | 리바이(재충전) | `off` / `limited` / `unlimited` | `unlimited` (`lastStanding` 선택 시 UI가 `off`를 추천) |
| `rebuyLimit` | `limited`일 때 1인당 횟수 | 1 / 2 / 3 | 1 |
| `turnSeconds` | 행동 제한 시간(초) | 15 / 20 / 30 / 60 | 20 |
| `dealerRule` | 다음 핸드 딜러(선) 결정 | `rotate`(시계 방향 회전) / `winner`(직전 승자) | `rotate` |
| `hints` | 현재 족보 표시·추천 행동 하이라이트 | 켬 / 끔 | 켬 |
| `hiLoLow` | 하이로우 로우 판정 방식 | `korean`(스트레이트·플러시 불인정, 최고 로우 A-2-3-4-6) / `aceToFive`(스트레이트·플러시 무시, 최고 로우 A-2-3-4-5) | `korean` |
| `hiLoQualifier` | 하이로우 로우 자격 | `none`(K탑까지 인정) / `eight`(8탑 이하만) | `none` |
| `drawLimit` | 5카드 드로우 교환 상한 | `any`(0~5장) / `three`(3장, A 보유 시 4장) | `any` |
| `indianCards` | 인디언 포커 카드 수 | 1 / 2 | 1 |
| `indianDeck` | 인디언 포커 덱 | `d20`(1~10 각 2장) / `d52` | `d20` |
| `indianFoldPenalty` | 10(덱 최고 숫자)을 이마에 단 채 다이하면 벌칙 | 켬 / 끔 | 켬 |
| `indianTie` | 인디언 포커 동점 시 | `carry`(판돈 다음 핸드로 이월) / `split`(분할) | `carry` |
| `indianReshuffle` | 인디언 포커 덱 섞기 시점 | `whenEmpty`(덱 소진 시, 카드 기억 요소) / `everyHand` | `whenEmpty` |
옵션 검증(zod `superRefine`):
- 참가 인원이 선택한 모드의 상한을 넘으면 시작 불가("이 모드는 최대 6명까지 할 수 있어요").
- `indian` + `indianCards=1` + `indianDeck=d52`만 최대 10명, 그 외 인디언은 최대 6명.
- `hiLo*` 옵션은 `sevenHiLo`에서만, `drawLimit`은 `fiveDraw`에서만, `indian*`은 `indian`에서만 의미가 있다(다른 모드에서는 무시).
- `baseUnit * 20 > startingChips`이면 거부(시작 칩이 기본 단위의 20배 이상이어야 함).
`auto` 해석: `holdem`→`noLimit`, `omaha`→`potLimit`, `indian`→`noLimit`, 나머지→`korean`.
## 3. 구성물
- 표준 52장 덱(조커 없음). 무늬 4종 ♠스페이드 ◆다이아몬드 ♥하트 ♣클로버, 숫자 2~10, J, Q, K, A.
- 인디언 포커 `d20` 덱: A(=1), 2~10을 ♠와 ♥ 두 벌, 총 20장. 이 덱에서는 A가 가장 낮다.
- 가상 칩(방 전용). 화면에는 금액 숫자와 칩 더미 그림으로 표시.
- 딜러 버튼(D), 홀덤·오마하는 SB/BB 표시, 한국식 모드는 "선" 표시와 "보스" 왕관 표시.
- 판돈(메인 팟)과 사이드 팟 표시 영역, 공용 카드 영역(홀덤·오마하), 버린 카드 더미(뒷면).
## 4. 준비(셋업)
1. 방 참가자를 좌석 0..N-1에 앉힌다. 좌석 순서는 `rng`로 무작위 섞는다.
2. 모든 플레이어 칩 = `startingChips`, 리바이 횟수 0.
3. 첫 딜러(선) 좌석은 `rng`로 고른다.
4. 인디언 `d20`/`d52`이면 인디언 덱을 만들어 `rng`로 섞어 둔다(`whenEmpty`에서는 핸드 사이에 유지).
5. 첫 핸드를 시작한다(5.1).
## 5. 진행 규칙
### 5.1 공통 핸드 흐름
1. 핸드 시작: 칩이 1 이상인 플레이어만 참가한다(`busted`/`eliminated` 제외).
2. 딜러 결정: 첫 핸드는 셋업 값. 이후 `rotate`는 직전 딜러 다음 좌석 중 참가자, `winner`는 직전 핸드에서 가장 큰 팟을 가져간 플레이어(분할이면 그중 딜러 다음 좌석에 가장 가까운 사람).
3. 덱을 새로 만들어 `rng`로 섞는다(인디언 `whenEmpty` 제외).
4. 강제 베팅:
- 한국식·앤티형: 참가자 전원이 `baseUnit`을 판돈에 낸다(앤티, 7포커 용어로 "기본 판돈"). 앤티는 라운드 베팅 금액(`committedRound`)에 넣지 않고 바로 판돈으로 간다.
- 블라인드형(홀덤·오마하): 딜러 다음 좌석 SB(`floor(baseUnit/2)`, 최소 1), 그다음 BB(`baseUnit`). 2인(헤즈업)일 때는 딜러가 SB, 상대가 BB.
- 칩이 강제 베팅보다 적으면 가진 칩을 전부 내고 올인 상태가 된다.
5. 모드별 카드 분배와 베팅 라운드(5.4) 진행.
6. 베팅 도중 한 명만 남으면 그 사람이 모든 팟을 가져가고 카드는 공개하지 않는다.
7. 마지막 라운드 후 2명 이상 남으면 쇼다운(6장) → 팟 분배.
8. 핸드 종료 단계(`handEnd`): 결과를 4초간 보여 준다. 참가자는 "다음 판" 버튼으로 준비 완료할 수 있고, 전원 준비 또는 시간 초과 시 다음 단계로.
9. 칩 0이 된 플레이어 처리(5.6 리바이·탈락), 종료 조건 확인(6.7), 아니면 다음 핸드.
카드 분배 순서: 딜러 다음 좌석부터 시계 방향으로 한 장씩 돈다. 덱은 핸드 시작 시 한 번 섞고 위에서부터 꺼낸다(교환으로 덱이 모자랄 때만 5.4.5 규칙으로 다시 섞음).
### 5.2 베팅 방식 A: 한국식 판돈 베팅 (`korean`)
용어:
- `pot` = 현재까지 모든 판돈 합계(앤티 + 이전 라운드 + 이번 라운드에 이미 낸 금액, 사이드 팟 포함).
- `currentBet` = 이번 라운드에서 한 사람이 낸 최대 금액.
- `toCall` = `currentBet - 내 committedRound`.
- `opened` = 이번 라운드에 누군가 칩을 걸었는가(삥 또는 레이즈).
행동과 금액(낼 금액 = 이번 행동으로 스택에서 나가는 칩):
| 행동 | 조건 | 낼 금액 |
|---|---|---|
| 체크 | `opened=false` (아직 아무도 걸지 않음) | 0 |
| 삥 | `opened=false` | `baseUnit` |
| 콜 | `toCall > 0` | `toCall` |
| 따당 | `toCall > 0`, 레이즈 가능 | `2 × toCall` (앞사람이 건 금액의 2배) |
| 쿼터 | 레이즈 가능 | `toCall + floor((pot + toCall) × 1/4)` |
| 하프 | 레이즈 가능 | `toCall + floor((pot + toCall) × 1/2)` |
| 풀 | 레이즈 가능, `koreanMaxRaise=full` | `toCall + (pot + toCall)` |
| 다이 | 언제나(자기 차례) | 0, 이번 핸드 포기 |
- 쿼터/하프/풀은 "콜을 먼저 맞춘 뒤 그때의 판돈 × 비율만큼 더 건다"로 정의한다(한게임 정의 "전체 판돈의 25%/50%/100%"를 콜 금액 포함 판돈 기준으로 구체화). `opened=false`에서 쓰면 `toCall=0`이므로 `floor(pot × 비율)`이다. `toCall`을 뺀 추가분이 `baseUnit`보다 작으면 추가분을 `baseUnit`으로 올린다.
- "레이즈 가능" 조건(체크레이즈 금지, 콜 후 레이즈 금지):
1. 이번 라운드에서 아직 체크·콜·삥·레이즈 중 어느 것도 하지 않았다. 즉 한 라운드에 레이즈(삥 포함)는 1인 1회이며, 체크나 콜을 한 뒤에는 같은 라운드에서 다시 올릴 수 없다.
2. 내는 금액이 `toCall`보다 크다.
- 위 규칙 때문에 레이즈 당한 사람은 같은 라운드에서 콜 또는 다이만 할 수 있다. 라운드는 반드시 유한하게 끝난다.
- 보스(첫 행동자)가 체크하면 다음 사람도 `opened=false` 상태이므로 체크/삥/쿼터/하프(/풀)/다이를 고를 수 있다. 전원 체크하면 다음 카드로 넘어간다. 체크한 사람은 뒤에 누가 걸면 콜/다이만 가능.
- 올인: 낼 금액이 스택 이상이면 스택 전부를 내고 올인한다. 올인 금액이 `toCall`보다 작으면 "부분 콜"이며 사이드 팟이 생긴다(5.5). 올인한 사람은 이후 행동하지 않고 카드는 계속 받는다. 올인 금액이 `toCall`보다 크면 레이즈로 취급한다(다른 사람의 레이즈 가능 여부는 위 규칙 그대로).
- 라운드 종료: 다이·올인하지 않은 모든 사람이 이번 라운드에 한 번 이상 행동했고, 모두 `committedRound == currentBet`이면 종료. 행동 가능한 사람이 1명 이하이고 그 사람이 `currentBet`을 맞췄으면 남은 라운드는 베팅 없이 카드만 돌린다.
계산 예시(4명, 앤티 10, 기본 단위 10, `koreanMaxRaise=half`):
- 앤티 후 `pot=40`. 보스 A 삥 10 → `pot=50`, `currentBet=10`.
- B 하프: `toCall=10`, 콜 후 판돈 60, 그 절반 30 → 낼 금액 40. `pot=90`, `currentBet=40`.
- C 따당: `toCall=40` → 80. `pot=170`, `currentBet=80`.
- D 다이.
- A 차례: 이미 삥(레이즈)을 했으므로 콜/다이만 가능. 콜 70 → `pot=240`.
- B 차례: 이미 레이즈했으므로 콜 40 → `pot=280`. A, B, C 모두 80을 맞췄으므로 라운드 종료. 검산: 앤티 40 + A 80 + B 80 + C 80 = 280.
### 5.3 베팅 방식 B: 노리밋(`noLimit`) / 팟리밋(`potLimit`)
- 행동: 폴드, 체크(`toCall=0`), 콜, 벳(`currentBet=0`일 때 처음 거는 것), 레이즈, 올인.
- 최소 벳 = `baseUnit`(빅 블라인드). 최소 레이즈 = 현재 최고액 + 직전 레이즈 크기(`lastRaiseSize`, 라운드 시작 시 `baseUnit`). 예: BB 10에서 30으로 올렸으면(레이즈 크기 20) 다음 레이즈는 최소 50.
- 노리밋 최대 = 자기 스택 전부.
- 팟리밋 최대 레이즈 총액(`raiseTo` 최대) = `currentBet + (pot + toCall)`. 여기서 `pot`은 이번 라운드에 이미 걸린 금액까지 포함한 전체 판돈. 예: 판돈 100, 앞사람이 20 벳, 내 `toCall=20` → 콜 후 판돈 140 → 최대 `raiseTo = 20 + 140 = 160`.
- 불완전 올인 레이즈: 올인 금액이 최소 레이즈에 못 미치면 "불완전 레이즈"다. 이미 행동한 뒤 그 레이즈를 맞닥뜨린 사람은 콜/폴드만 가능하고 다시 올릴 수 없다(베팅 재개 안 됨). 아직 행동하지 않은 사람은 정상 레이즈 가능. `lastRaiseSize`는 불완전 레이즈로 바뀌지 않는다.
- 프리플랍 BB 옵션: 아무도 레이즈하지 않고 BB까지 차례가 오면 BB는 체크 또는 레이즈할 수 있다.
- 액션 순서: 프리플랍은 BB 다음 좌석부터(헤즈업은 딜러=SB가 먼저), 플랍 이후는 딜러 다음 좌석의 남은 사람부터(헤즈업은 BB가 먼저).
- 라운드 종료 조건은 5.2와 같다.
### 5.4 모드별 진행
#### 5.4.1 텍사스 홀덤 (`holdem`)
1. 블라인드 → 각자 개인 카드 2장(비공개).
2. 프리플랍 베팅.
3. 번 카드 1장(뒷면으로 버림) 후 플랍 3장 공개 → 베팅.
4. 번 1장 후 턴 1장 → 베팅.
5. 번 1장 후 리버 1장 → 베팅.
6. 쇼다운: 개인 2장 + 공용 5장 중 아무 5장으로 최고 족보(6.1). 개인 카드를 하나도 안 써도 된다("보드 플레이").
#### 5.4.2 오마하 (`omaha`)
- 홀덤과 같지만 개인 카드 4장. 쇼다운에서 반드시 개인 카드 정확히 2장 + 공용 카드 정확히 3장으로 5장을 만든다(가능한 6 × 10 = 60개 조합 중 최고).
- 예: 개인 A♠A♥K♠Q♦, 공용 ♠ 4장이 깔려도 개인 ♠가 A♠, K♠ 두 장뿐이면 플러시 가능. 개인에 ♠가 1장뿐이면 공용 ♠ 4장으로는 플러시를 못 만든다.
#### 5.4.3 7포커 (`sevenPoker`)
1. 앤티.
2. 각자 4장(비공개)을 받는다.
3. 초이스 단계(동시 진행): 각자 4장 중 1장을 버리고(아무도 못 봄, 끝까지 비공개), 남은 3장 중 1장을 오픈 카드로 고른다. 결과: 히든 2장 + 오픈 1장. 전원이 고르면 오픈 카드가 동시에 공개된다.
4. 4구: 각자 1장씩 오픈으로 받는다(오픈 2장). 첫 베팅 라운드. 3구 베팅은 없다(한국식 4구 룰).
5. 5구: 오픈 1장 → 베팅.
6. 6구: 오픈 1장 → 베팅.
7. 7구(히든): 비공개 1장 → 마지막 베팅.
8. 쇼다운: 7장 중 최고 5장(6.2 한국식 족보, 무늬 서열 사용).
- 보스(각 라운드 첫 행동자): 오픈 카드만으로 가장 높은 패를 보인 사람. 오픈 카드 비교는 원페어/투페어/트리플/포카드/탑만 본다(스트레이트·플러시는 오픈 4장 이하라 따지지 않음). 비교는 6.2와 같은 방식(같은 숫자면 무늬). 무늬가 있으므로 동점이 없다. 행동은 보스부터 시계 방향.
- 다이한 사람의 카드는 공개하지 않는다(오픈 카드는 이미 공개된 것 그대로 둠).
#### 5.4.4 7포커 하이로우 (`sevenHiLo`)
- 1~7은 7포커와 동일.
- 마지막 베팅이 끝나고 2명 이상 남으면 선언 단계(동시 진행): 남은 사람 전원이 비공개로 하이/로우/스윙 중 하나를 고르고, 전원 선택 후 동시에 공개한다.
- 하이: 7장 중 최고 하이 족보(6.2)로 경쟁.
- 로우: 7장 중 최고 로우(6.3)로 경쟁.
- 스윙: 하이와 로우 양쪽에서 모두 이겨야 한다(6.6).
- 남은 사람이 1명이면 선언 없이 그 사람이 가져간다.
- 올인한 사람도 선언한다.
#### 5.4.5 바둑이 (`badugi`)
1. 앤티 → 각자 4장(비공개).
2. 1차 베팅(교환 전). 첫 행동자는 딜러 다음 좌석, 이하 모든 라운드 동일.
3. 아침(1차 교환, 동시 진행): 각자 0~4장을 골라 버린다. 0장=패스, 1~3장=커트, 4장=박스. 전원 제출 후 딜러 다음 좌석부터 순서대로 버린 수만큼 새 카드를 받는다. 교환 장수는 모두에게 공개된다.
4. 2차 베팅 → 점심(2차 교환) → 3차 베팅 → 저녁(3차 교환) → 4차(마지막) 베팅.
5. 쇼다운: 6.4 바둑이 족보.
- 덱 부족: 교환할 카드가 덱에 모자라면, 덱에 남은 카드를 먼저 주고, 그동안 버려진 카드(이번 교환에서 버린 카드는 제외)를 `rng`로 섞어 새 덱으로 삼아 나머지를 준다. 그래도 모자라면 이번 교환에서 버린 카드까지 섞는다(이론상 6인에서만 가능한 극단 상황).
- 올인한 사람도 교환은 계속 한다.
#### 5.4.6 5카드 드로우 (`fiveDraw`)
1. 앤티 → 각자 5장(비공개).
2. 1차 베팅.
3. 교환 1회(동시 진행): `drawLimit=any`면 0~5장, `three`면 최대 3장(손에 A가 있고 그 A를 남기면 최대 4장). 덱 부족 처리는 바둑이와 같다.
4. 2차 베팅 → 쇼다운(6.1 국제 족보).
#### 5.4.7 인디언 포커 (`indian`)
- 1장 모드(`indianCards=1`):
1. 앤티(`baseUnit`).
2. 각자 1장을 이마에 붙인다: 자기 카드는 못 보고 남의 카드는 본다.
3. 베팅 1회(노리밋, 최소 단위 `baseUnit`, 딜러 다음 좌석부터).
4. 쇼다운: 숫자가 높은 사람이 이긴다(6.5).
- 2장 모드(`indianCards=2`):
1. 앤티 → 이마 카드 1장 → 1차 베팅.
2. 손 카드 1장(본인만 봄) → 2차 베팅.
3. 쇼다운: 이마 + 손 2장으로 6.5 족보.
- 핸드가 끝나면 다이한 사람까지 모든 이마/손 카드를 전원에게 공개한다(사용된 카드 기록, `whenEmpty`에서 카드 기억 요소).
- 10 벌칙(`indianFoldPenalty`): 덱 최고 숫자(`d20`=10, `d52`=A)를 이마에 단 채 다이하면, 그 사람은 `10 × baseUnit`(스택이 모자라면 남은 전부)을 벌칙으로 낸다. 벌칙 칩은 이번 핸드 팟과 별도로 그 핸드 승자에게 간다(승자가 여럿이면 균등 분할, 판돈이 이월되면 벌칙도 이월 판돈에 더함).
- 덱(`whenEmpty`): 핸드마다 필요한 장수(참가자 수 × 카드 수)가 덱에 없으면 사용된 카드 전부를 모아 새로 섞는다. `everyHand`는 매 핸드 새로 섞는다.
### 5.5 올인과 사이드 팟
- 라운드가 끝날 때(또는 쇼다운 직전) 각 플레이어의 이번 핸드 총 기여액(`committedTotal`)으로 팟을 다시 계산한다:
1. 다이하지 않은 올인 플레이어들의 `committedTotal` 값을 오름차순으로 정렬한 레벨 L1 < L2 < ...
2. 각 레벨 구간마다 모든 플레이어(다이한 사람 포함)의 기여액 중 그 구간에 해당하는 부분을 모아 팟을 만들고, 그 구간까지 기여한 다이하지 않은 플레이어를 자격자로 둔다.
3. 마지막 레벨 위로 남은 금액은 최종 팟(자격자 = 다이·올인하지 않고 끝까지 낸 사람).
- 예: A 100 올인, B 300 올인, C 500(마지막 베팅 300을 콜해 300만 기여). 기여 A100/B300/C300 → 메인 팟 300(A·B·C 자격), 사이드 팟 400(B·C 자격). C가 처음 500을 걸었다면 아무도 받지 못한 200은 C에게 돌려준다(반환은 라운드 종료 시).
- 자격자가 1명뿐인 팟은 쇼다운 없이 그 사람에게 돌려준다.
- 앤티와 다이한 사람의 칩은 해당 레벨 팟에 남는다.
### 5.6 칩이 떨어졌을 때: 리바이와 탈락
- 핸드 종료 후 칩이 0인 플레이어는 `busted`.
- `rebuy=off`: 즉시 탈락(`eliminated`), 관전 화면으로 전환되지만 결과 순위에는 남는다.
- `rebuy=limited/unlimited`(남은 횟수 있음): 리바이 단계에서 "칩 다시 받기 / 그만하기"를 고른다(제한 시간 15초, 시간 초과는 그만하기). 받으면 `startingChips`로 채우고 `rebuys += 1`. `endCondition=hands`에서 마지막 핸드가 끝난 직후에는 리바이를 묻지 않는다.
- 칩이 남아 있어도 앤티/블라인드보다 적으면 그대로 참가하고 강제 베팅에서 올인된다(리바이는 0일 때만).
- 리바이는 실제 재화와 무관한 방 내부 칩 보충이다.
### 5.7 블라인드/앤티 상승
- `blindUpEvery = N > 0`이면 N핸드가 끝날 때마다 `baseUnit`을 2배로 올린다(레벨 표시). 상한은 `startingChips / 5`.
### 5.8 자리 비움
- 같은 플레이어가 연속 2번 시간 초과되면 `away`로 표시하고, 이후 그 사람의 차례는 제한 시간 5초로 줄인다(자동 행동은 8.5와 같음). 다시 직접 행동하면 해제된다.
- 방을 완전히 나간 플레이어(`left`)는 남은 칩을 몰수하지 않고 그대로 두되, 매 핸드 강제 베팅 후 자동 다이한다. 칩이 0이 되면 탈락. 결과 순위에는 남는다.
## 6. 승패와 점수 계산
### 6.1 국제 족보 (홀덤·오마하·5카드 드로우, 무늬 비교 없음)
높은 순서:
1. 스트레이트 플러시(로열 = A-K-Q-J-10 스트레이트 플러시 포함): 가장 높은 카드로 비교. A-2-3-4-5는 가장 낮은 스트레이트 플러시(5가 탑).
2. 포카드: 포카드 숫자 → 키커 1장.
3. 풀하우스: 트리플 숫자 → 페어 숫자.
4. 플러시: 5장을 높은 순으로 차례대로 비교.
5. 스트레이트: 가장 높은 카드. A-2-3-4-5(휠)가 가장 낮고 10-J-Q-K-A가 가장 높다. K-A-2-3-4 같은 순환은 불인정.
6. 트리플: 트리플 숫자 → 키커 2장.
7. 투페어: 높은 페어 → 낮은 페어 → 키커.
8. 원페어: 페어 → 키커 3장.
9. 하이카드: 5장 차례대로.
5장이 완전히 같은 서열이면 동점 → 해당 팟을 균등 분할. 나머지 칩(홀수 칩)은 딜러 다음 좌석부터 시계 방향으로 가장 가까운 승자에게 1칩씩.
예(홀덤): 보드 K♠ K♦ 7♣ 4♥ 2♠, A: A♣ 3♦, B: Q♥ J♥ → 둘 다 K 원페어, 키커 A > Q → A 승. 보드 9-9-8-8-A면 개인 카드가 모두 A 미만인 두 사람은 보드 플레이로 분할.
### 6.2 한국식 족보 (7포커·하이로우 하이, 무늬 서열 사용)
무늬 서열: ♠ > ◆ > ♥ > ♣.
숫자 서열: A > K > Q > J > 10 > ... > 2.
높은 순서:
1. 로열 스트레이트 플러시(로티플): 같은 무늬 A-K-Q-J-10.
2. 백 스트레이트 플러시: 같은 무늬 A-2-3-4-5.
3. 스트레이트 플러시: 같은 무늬 연속 5장(K탑 ~ 6탑).
4. 포카드.
5. 풀하우스.
6. 플러시.
7. 마운틴: A-K-Q-J-10 스트레이트.
8. 백 스트레이트: A-2-3-4-5 스트레이트.
9. 스트레이트: 그 밖의 연속 5장(K탑 > Q탑 > ... > 6탑).
10. 트리플.
11. 투페어.
12. 원페어.
13. 탑(노페어).
같은 족보끼리의 비교("족보 결정 카드"의 숫자 → 무늬):
| 족보 | 1차 비교 | 동점 시 |
|---|---|---|
| 로티플/백스티플/스티플 | 서열(로티플 > 백스티플 > K탑 > ... > 6탑) | 무늬 |
| 포카드 | 포카드 숫자(같을 수 없음) | - |
| 풀하우스 | 트리플 숫자(같을 수 없음) | - |
| 플러시 | 5장을 높은 숫자부터 차례로 | 플러시 무늬 |
| 마운틴/백/스트레이트 | 서열(마운틴 > 백 > K탑 > ... > 6탑) | 가장 높은 카드의 무늬(백 스트레이트는 A의 무늬, 마운틴도 A의 무늬) |
| 트리플 | 트리플 숫자(같을 수 없음) | - |
| 투페어 | 높은 페어 숫자 → 낮은 페어 숫자 | 높은 페어 중 좋은 무늬 |
| 원페어 | 페어 숫자 | 페어 두 장 중 좋은 무늬 |
| 탑 | 5장을 높은 숫자부터 차례로 | 가장 높은 카드의 무늬 |
- 투페어·원페어는 키커를 보지 않는다(무늬로 바로 결정). 한 덱이고 공용 카드가 없으므로 위 규칙으로 항상 승자가 하나로 정해진다.
- 7장 중 5장 선택: 21개 조합을 모두 평가해 위 비교기로 최대인 것을 쓴다(스리페어면 위 두 페어가 자동 선택).
- 예1: A 7♠7♣ (원페어 7), B 7◆7♥ (원페어 7) → A의 ♠가 B의 최고 무늬 ◆보다 높으므로 A 승.
- 예2: A 마운틴(A♣ 탑), B 백 스트레이트(A♠) → 마운틴이 서열상 높으므로 A 승(무늬는 같은 서열일 때만 봄).
- 예3: A 투페어 K-5, B 투페어 K-9 → 낮은 페어 9 > 5로 B 승.
### 6.3 하이로우 로우 판정
`hiLoLow=korean`(기본, 한게임 방식 "최고 로우 A-2-3-4-6"):
- A는 1로 본다. 7장 중 서로 다른 숫자 5장을 고르되, 그 5장이 스트레이트(A-2-3-4-5 포함)나 플러시가 되면 안 된다.
- 가능한 조합 중 가장 높은 카드부터 차례로 비교해 낮은 쪽이 좋다. 최고 로우는 A-2-3-4-6("6탑").
- 모든 숫자가 같으면 가장 높은 카드의 무늬로 결정(♠ 우선).
- 조건을 만족하는 5장이 없으면(서로 다른 숫자가 5개 미만 등) 로우 패가 없다.
`hiLoLow=aceToFive`(옵션): 스트레이트·플러시를 무시하고 서로 다른 숫자 5장만 보면 된다. 최고 로우 A-2-3-4-5.
`hiLoQualifier=eight`: 로우의 가장 높은 카드가 8 이하여야 로우 자격이 있다. `none`이면 K탑까지 인정.
예: 7장 A♠ 2♦ 3♥ 4♣ 5♠ 9♦ K♥ (`korean`) → A-2-3-4-5는 백 스트레이트라 불가, 가능한 최선은 A-2-3-4-9("9탑", 같은 무늬 5장 아님). `aceToFive`면 A-2-3-4-5.
### 6.4 바둑이 족보 (로우 바둑이)
- A는 1(가장 낮음), K가 가장 높다. 무늬는 순위가 없다.
- 4장 중 "숫자도 무늬도 서로 겹치지 않는" 가장 큰 부분집합의 크기 k로 등급을 정한다.
- k=4: 메이드(예: A♠ 4♣ 5♦ 6♥ = "6 메이드").
- k=3: 베이스(예: A♠ 4♣ 5♦ 5♥ = "5 베이스").
- k=2: 투베이스.
- k=1: 노베이스(네 장이 모두 같은 무늬 또는 같은 숫자).
- k가 큰 쪽이 무조건 이긴다(메이드 > 베이스 > 투베이스 > 노베이스).
- 같은 k끼리는 그 k장을 높은 숫자부터 차례로 비교해 낮은 쪽이 이긴다. 같은 k의 후보 부분집합이 여럿이면 가장 낮은 것을 쓴다.
- 이름 붙은 메이드: 골프 A-2-3-4(최강), 세컨드 A-2-3-5, 써드 A-2-4-5.
- 모든 숫자가 같으면 무승부 → 분할(홀수 칩은 6.1과 같은 방식).
- 예: A 2♠ 3♦ 7♥ 8♣ (8 메이드) vs B A♠ 2♦ 3♥ 3♣ (3 베이스) → A 승(메이드 > 베이스). C 2♥ 4♣ 7♦ 8♠ (8 메이드) vs A → 8=8, 7=7, 다음 4 > 3 → A 승.
### 6.5 인디언 포커 판정
- 1장 모드: 숫자가 높은 사람이 이긴다. `d20`은 10 > 9 > ... > 2 > A(1). `d52`는 A > K > ... > 2. 무늬는 보지 않는다.
- 2장 모드: 페어(같은 숫자 2장) > 노페어. 페어끼리는 숫자, 노페어끼리는 높은 카드 → 낮은 카드 순으로 비교.
- 최고 패가 여럿이면:
- `indianTie=carry`(기본, 지니어스 방식): 해당 팟을 다음 핸드 판돈으로 이월(`carryOver`). 다음 핸드 승자가 함께 가져간다. 게임의 마지막 핸드에서 이월되면 동점자끼리 분할.
- `split`: 동점자끼리 분할.
### 6.6 팟 분배 순서와 하이로우 분배
- 팟은 사이드 팟부터(자격자가 적은 팟부터) 메인 팟 순으로 각각 독립적으로 판정한다.
- 하이로우 각 팟(자격자 E):
1. 스윙 선언자 각각에 대해: E 안에서 하이 경쟁자(하이·스윙 선언자) 전원보다 하이가 엄격히 높고, 로우 경쟁자(로우·스윙 선언자) 전원보다 로우가 엄격히 좋으면 그 팟 전부를 가져간다. 로우 패가 없거나(6.3) 한쪽이라도 지면 그 스윙 선언자는 이 팟에서 완전히 제외된다.
2. 남은 자격자로: 하이 쪽 승자 = 하이 선언자 중 최고 하이, 로우 쪽 승자 = 로우 패 자격이 있는 로우 선언자 중 최고 로우.
3. 양쪽 다 승자가 있으면 팟을 반으로 나눠 하이 쪽에 `ceil(팟/2)`, 로우 쪽에 `floor(팟/2)`.
4. 한쪽만 승자가 있으면 그쪽이 전부 가져간다.
5. 아무도 없으면(예: 스윙 2명 모두 실패) 그 팟의 자격자 전원이 하이 족보로 다시 겨룬다.
- 무늬 서열 덕분에 하이·로우 모두 동점이 생기지 않는다.
- 예: A 하이(풀하우스), B 로우(7탑), C 스윙(플러시 + 8탑) → C는 하이에서 A에게 지므로 제외. A가 반, B가 반.
### 6.7 게임 종료와 점수
- `endCondition=hands`: `handLimit` 핸드가 끝나면 종료. 점수 = `최종 칩 - startingChips × rebuys`(리바이로 받은 칩을 뺀 순이익). 점수 높은 순으로 순위. 점수가 같으면 공동 순위.
- `endCondition=lastStanding`: 칩을 가진 참가자가 1명만 남으면 종료(리바이 가능한 `busted` 플레이어가 결정을 기다리는 중이면 아직 종료 아님). 1위 = 마지막 생존자, 이후는 늦게 탈락한 순. 같은 핸드에서 여럿이 탈락하면 그 핸드 시작 시 칩이 많았던 사람이 높은 순위, 그것도 같으면 공동 순위. 안전장치: 300핸드에 도달하면 `hands` 방식 점수로 강제 종료.
- 공통: 참가자 중 접속 중이고 칩이 있는 사람이 1명 이하가 되면(나머지 모두 `left`) 즉시 종료.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 하우스 룰 | 옵션 | 기본 |
|---|---|---|
| 풀 베팅 허용(하프 게임/풀 게임) | `koreanMaxRaise` | 하프까지 |
| 하이로우 로우에 스트레이트·플러시 인정 여부(A-2-3-4-5 최고 로우 방식) | `hiLoLow` | 불인정(한게임식) |
| 하이로우 8탑 자격 | `hiLoQualifier` | 없음 |
| 5카드 드로우 교환 3장(A 있으면 4장) 제한 | `drawLimit` | 제한 없음 |
| 승자가 다음 선 | `dealerRule` | 회전 |
| 인디언 10 다이 벌칙 | `indianFoldPenalty` | 켬 |
| 인디언 동점 이월 | `indianTie` | 이월 |
| 인디언 덱 카드 기억 | `indianReshuffle` | 덱 소진 시 섞기 |
| 리바이 | `rebuy`, `rebuyLimit` | 무제한 |
| 블라인드 상승 | `blindUpEvery` | 없음 |
| "491/591 룰"(4구/5구 첫 베팅은 하프·다이만 허용) | 미지원(향후) | - |
| 맥스 베팅 상한, 하이 바둑이, 오마하 하이로우, 잭스 오어 베터(오프닝 조건) | 미지원(향후) | - |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
// 서버만 보는 전체 상태
type PlayerId = string;
type Suit = 'S' | 'D' | 'H' | 'C'; // ♠ ◆ ♥ ♣ (한국식 서열 S > D > H > C)
type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; // 11=J 12=Q 13=K 14=A, 1은 인디언 d20의 A
interface Card { id: number; rank: Rank; suit: Suit } // id는 덱 안에서 고유(0..51 또는 0..19)
type PokerMode = 'holdem' | 'omaha' | 'sevenPoker' | 'sevenHiLo' | 'badugi' | 'fiveDraw' | 'indian';
type Betting = 'korean' | 'noLimit' | 'potLimit';
interface PokerOptions {
mode: PokerMode;
betting: Betting | 'auto';
startingChips: 500 | 1000 | 2000 | 5000;
baseUnit: 2 | 10 | 20 | 50;
koreanMaxRaise: 'half' | 'full';
endCondition: 'hands' | 'lastStanding';
handLimit: 10 | 20 | 30 | 50;
blindUpEvery: 0 | 5 | 10 | 15;
rebuy: 'off' | 'limited' | 'unlimited';
rebuyLimit: 1 | 2 | 3;
turnSeconds: 15 | 20 | 30 | 60;
dealerRule: 'rotate' | 'winner';
hints: boolean;
hiLoLow: 'korean' | 'aceToFive';
hiLoQualifier: 'none' | 'eight';
drawLimit: 'any' | 'three';
indianCards: 1 | 2;
indianDeck: 'd20' | 'd52';
indianFoldPenalty: boolean;
indianTie: 'carry' | 'split';
indianReshuffle: 'whenEmpty' | 'everyHand';
}
interface SeatState {
id: PlayerId;
seat: number;
stack: number;
rebuys: number;
status: 'playing' | 'busted' | 'eliminated' | 'left';
away: boolean;
timeoutsInRow: number;
eliminatedAtHand: number | null;
stackAtHandStart: number; // 같은 핸드 동시 탈락 순위용
}
interface HandPlayer {
id: PlayerId;
folded: boolean;
allIn: boolean;
hole: Card[]; // 비공개: 홀덤/오마하 개인 카드, 7포커 히든, 바둑이·드로우 손패, 인디언 2장 모드 손 카드
up: Card[]; // 공개: 7포커 오픈 카드(나온 순서)
forehead: Card | null; // 인디언 이마 카드(본인 제외 공개)
choice: Card[] | null; // 7포커 초이스 단계의 4장(초이스 끝나면 null)
choiceDone: boolean;
committedTotal: number; // 이번 핸드 총 기여(앤티·블라인드 포함)
committedRound: number; // 이번 라운드 기여(앤티 제외, 블라인드 포함)
actedThisRound: boolean;
raisedThisRound: boolean; // 한국식: 삥/레이즈 1회 사용
lockedFromRaise: boolean; // 한국식: 체크·콜 후 / NL: 불완전 레이즈로 재개 불가
drawPending: number[] | null; // 제출한 교환 카드 id(동시 단계)
drawCounts: number[]; // 공개: 교환 장수 기록(아침/점심/저녁)
declare: 'high' | 'low' | 'swing' | null;
ready: boolean; // handEnd "다음 판"
}
interface Pot { amount: number; eligible: PlayerId[] }
interface BettingRound {
street: number; // 모드별 라운드 번호(0부터)
order: PlayerId[]; // 이번 라운드 행동 순서(보스/첫 행동자부터)
toAct: PlayerId | null;
currentBet: number;
lastRaiseSize: number; // NL/PL 최소 레이즈
opened: boolean; // 한국식: 삥/레이즈 존재 여부
}
interface ShowdownEntry {
id: PlayerId;
cards: Card[]; // 공개되는 전체 카드
best: Card[]; // 사용한 5장(바둑이는 k장)
handName: string; // "풀하우스", "7 메이드" 등 한국어
lowName?: string;
}
interface Award { potIndex: number; to: PlayerId; amount: number; side?: 'high' | 'low' | 'scoop' }
interface HandState {
handNo: number;
phase: 'choice' | 'betting' | 'draw' | 'declare' | 'showdown' | 'handEnd';
street: number;
dealerSeat: number;
deck: Card[]; // 남은 덱(절대 클라이언트로 안 나감)
muck: Card[]; // 버린 카드(초이스 버림, 교환 버림, 번 카드, 다이한 패)
board: Card[]; // 공용 카드
players: Record<PlayerId, HandPlayer>;
seatOrder: PlayerId[]; // 이번 핸드 참가자, 딜러 다음 좌석부터
betting: BettingRound | null;
pots: Pot[]; // 확정된 팟(라운드 종료마다 재계산)
penalties: { from: PlayerId; amount: number }[]; // 인디언 10 벌칙
showdown: ShowdownEntry[] | null;
awards: Award[] | null;
deadlineAt: number | null;
}
interface PokerState {
options: PokerOptions;
betting: Betting; // auto 해석 결과
rng: RngState; // 시드 PRNG 상태
seats: SeatState[];
baseUnit: number; // 블라인드 상승 반영 현재값
hand: HandState | null;
handsPlayed: number;
carryOver: number; // 인디언 이월 판돈
indianDeck: Card[] | null; // 인디언 whenEmpty 덱(남은 카드)
indianUsed: Card[]; // 공개된 사용 카드
phase: 'playing' | 'rebuy' | 'finished';
rebuyPending: PlayerId[];
rebuyDeadlineAt: number | null;
lastWinner: PlayerId | null;
eliminationSeq: PlayerId[][]; // 탈락 순서(핸드 단위 묶음)
publicLog: PublicLogEntry[]; // 최근 100개, 비밀 정보 없음
}
```
엔진 인터페이스 메모: `minPlayers=2`, `maxPlayers=10`(정적 값). 모드별 상한은 `optionsSchema`의 `superRefine`과 별도 헬퍼 `playerRange(options)`로 로비가 확인한다(인터페이스에 선택 메서드로 추가 제안). `setup`에서도 인원 초과면 예외를 던진다.
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `choose` | `{ discardId, openId }` | 7포커/하이로우, `phase=choice`, 아직 선택 안 한 참가자(동시) | 두 id가 내 `choice` 4장에 있고 서로 다름. 이미 선택했으면 "이미 골랐어요" |
| `k.check` | - | 한국식, 내 차례 | `opened=false`, 다이/올인 아님 |
| `k.ping` | - | 한국식, 내 차례 | `opened=false`, `raisedThisRound=false`, 스택 > 0 |
| `k.call` | - | 한국식, 내 차례 | `toCall > 0` |
| `k.ddadang` | - | 한국식, 내 차례 | `toCall > 0`, 레이즈 가능(5.2) |
| `k.quarter` / `k.half` | - | 한국식, 내 차례 | 레이즈 가능, 낼 금액 > `toCall` |
| `k.full` | - | 한국식, 내 차례 | 위 + `koreanMaxRaise=full` |
| `k.die` | - | 한국식, 내 차례 | 항상 |
| `nl.fold` | - | NL/PL, 내 차례 | 항상(단 `toCall=0`이면 UI가 확인 창 표시) |
| `nl.check` | - | NL/PL, 내 차례 | `toCall=0` |
| `nl.call` | - | NL/PL, 내 차례 | `toCall > 0` (스택 부족이면 올인 콜) |
| `nl.raise` | `{ to: number }` | NL/PL, 내 차례 | 정수, `lockedFromRaise=false`, `to ≥ 최소 레이즈`(스택 전부면 예외 허용), `to ≤ committedRound + stack`, PL이면 `to ≤ 팟리밋 최대`. `currentBet=0`이면 "벳"으로 표시 |
| `nl.allIn` | - | NL/PL, 내 차례 | 스택 > 0. PL에서 스택이 팟리밋보다 크면 거부(최대 레이즈로 안내) |
| `draw` | `{ cardIds: number[] }` | 바둑이/드로우, `phase=draw`, 미제출 참가자(동시, 올인 포함) | 중복 없음, 모두 내 손패, 개수: 바둑이 0~4, 드로우 `any` 0~5 / `three` 0~3(A를 남기면 0~4) |
| `declare` | `{ side: 'high'\|'low'\|'swing' }` | 하이로우, `phase=declare`, 남은 참가자(동시) | 미선언 |
| `ready` | - | `phase=handEnd`, 참가자 | 아직 준비 안 함 |
| `rebuy` | `{ accept: boolean }` | `phase=rebuy`, `rebuyPending`에 있는 사람 | 남은 리바이 횟수 > 0 |
| `leave` | - | 아무 때나(서버가 방 퇴장 시 주입) | 이미 `left` 아님 |
공통 거부 사유 예: "지금은 내 차례가 아니에요", "이번 판에서 이미 다이했어요", "콜 금액이 있어서 체크할 수 없어요", "이번 라운드에서는 더 올릴 수 없어요(콜 또는 다이만 가능)", "최소 레이즈는 50이에요".
`apply` 순서: 칩 이동 → 행동 기록 → 라운드 종료 판정 → (종료 시) 팟 재계산, 다음 카드 분배 또는 다음 단계 → 다음 행동자와 `deadlineAt = now + turnSeconds*1000`.
### 8.3 공개/비공개 정보 (view)
공개(모든 시청자 동일): 좌석·닉네임·스택·상태, 딜러/선/보스 표시, 공용 카드, 7포커 오픈 카드, 각자의 비공개 카드 장수, 교환 장수, 이번 라운드 베팅액과 마지막 행동, 팟 목록, 현재 행동자와 남은 시간, 하이로우 선언(공개 시점 이후), 인디언 사용 카드 목록, 이월 판돈, 핸드 결과.
| 정보 | 본인 | 다른 플레이어 | 관전자(`null`) |
|---|---|---|---|
| 내 `hole`(히든·개인 카드) | 보임 | 장수만 | 장수만 |
| 7포커 초이스 4장 | 보임 | 장수만(4) | 장수만 |
| 초이스로 버린 카드 | 내 것만 "버린 카드"로 표시 | 안 보임 | 안 보임 |
| 하이로우 선언 | 내 선택만 | 전원 선택 완료 후 공개 | 같음 |
| 인디언 이마 카드 | 안 보임("?") | 보임 | 핸드 종료 전에는 안 보임 |
| 인디언 2장 모드 손 카드 | 보임 | 안 보임 | 안 보임 |
| 덱, 번 카드, 교환으로 버린 카드 | 안 보임 | 안 보임 | 안 보임 |
| 쇼다운 카드 | 쇼다운에 간 사람의 전체 카드 공개 | 같음 | 같음 |
| 다이한 사람 카드 | 내 것만 | 안 보임(인디언은 핸드 종료 후 공개) | 같음 |
- 관전자에게 인디언 이마 카드를 숨기는 이유: 플레이어가 다른 기기로 관전에 들어와 자기 이마 카드를 보는 부정행위를 막기 위해서다. 같은 이유로 관전자는 어떤 모드에서도 비공개 카드를 볼 수 없다("고스트" 방지).
- `view`는 `legalActions`(본인 차례일 때 가능한 행동 목록과 각 행동의 정확한 낼 금액, NL 레이즈 최소/최대)도 계산해 내려 준다. UI는 이 값만 사용한다.
- 이벤트(`GameEvent`)에도 비밀을 넣지 않는다. 카드 분배 이벤트는 `{ type: 'dealt', to, count, faceUp?: Card }`처럼 공개 카드만 값이 있고, 비공개 카드는 이후 `view`로만 전달된다.
- 카드 `id`는 숫자·무늬와 고정 대응하므로 비공개 카드 자리에는 `id`도 보내지 않고 `{ hidden: true }`만 보낸다. 클라이언트 애니메이션용 키는 view마다 새로 만든 임시 슬롯 번호를 쓴다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `setup`: 좌석 섞기, 첫 딜러 선택.
2. 핸드 시작: 52장(또는 인디언 덱) Fisher-Yates 섞기. 이후 분배는 덱 위에서 순서대로 꺼내므로 추가 난수 없음.
3. 바둑이/드로우 덱 부족 시 버린 카드 재섞기.
4. 인디언 `whenEmpty` 덱 소진 시 재섞기.
- 자동 행동, 팟 분배, 홀수 칩, 보스 결정은 난수를 쓰지 않는다(결정적).
- 테스트는 고정 시드로 덱 순서를 재현한다. 상태에 `rng`가 있으므로 같은 시드 + 같은 액션 열이면 같은 결과.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
`deadline(state)`:
- 베팅: `turnSeconds`(자리 비움이면 5초).
- 7포커 초이스·교환·선언: `turnSeconds + 10`초(동시 단계, 모두에게 같은 마감).
- `handEnd`: 4초. 리바이 결정: 15초.
`onTimeout(state, player)`:
| 단계 | 자동 행동 |
|---|---|
| 베팅(한국식) | 체크 가능하면 `k.check`, 아니면 `k.die` |
| 베팅(NL/PL) | `toCall=0`이면 `nl.check`, 아니면 `nl.fold` |
| 7포커 초이스 | 4장 중 숫자가 가장 낮은 카드(같으면 무늬가 낮은 것)를 버리고, 남은 3장 중 숫자가 가장 낮은 카드를 오픈(페어를 오픈하지 않는 단순 규칙) |
| 바둑이 교환 | 패를 바꾸지 않음(패스) |
| 드로우 교환 | 패스 |
| 하이로우 선언 | 하이 |
| `handEnd` | `ready` |
| 리바이 | `{ accept: false }` |
연결 끊김: 서버는 끊긴 플레이어의 차례가 오면 일반 마감까지 기다린 뒤 `onTimeout`을 적용한다(재접속하면 바로 직접 행동 가능). 5.8의 자리 비움 규칙으로 진행 속도를 유지한다.
### 8.6 종료 조건과 결과(GameResult)
종료 판정은 6.7. `result(state)`는 `phase=finished`일 때:
```ts
interface PokerResult /* extends GameResult */ {
ranking: {
playerId: PlayerId;
rank: number; // 1부터, 공동 순위 허용
score: number; // hands: 최종 칩 - startingChips × rebuys / lastStanding: 생존 순서 기반(1위=N)
finalChips: number;
rebuys: number;
eliminatedAtHand: number | null;
}[];
summary: string; // 예: "20판 종료. 1위 민수(+1,240칩)"
stats: {
handsPlayed: number;
biggestPot: { handNo: number; amount: number; winners: PlayerId[] };
bestHand: { playerId: PlayerId; handName: string; handNo: number } | null;
};
}
```
## 9. UI/UX
화면 배치(모바일 세로):
- 위쪽 2/3: 테이블. 상대 플레이어는 위·양옆 호 모양으로 배치(닉네임, 스택, 이번 라운드 베팅액, 오픈 카드 축소판, 상태 뱃지 "다이"/"올인"/"자리 비움"). 10명이면 2줄 축소 배치.
- 가운데: 판돈 큰 숫자, 사이드 팟은 작은 칩 더미와 금액, 공용 카드 5칸(홀덤·오마하).
- 아래 1/3: 내 카드 크게(카드 높이 최소 96px), 그 위에 현재 족보 이름(`hints`일 때 "지금: 투페어"), 맨 아래 행동 버튼 줄.
- PC: 테이블 원형, 오른쪽에 핸드 기록·족보표 패널.
행동 버튼(높이 56px 이상, 글자 18px 이상):
- 한국식: `다이` `체크` `삥` `콜 (+70)` `따당 (+160)` `쿼터 (+60)` `하프 (+120)` `풀 (+240)`. 불가능한 버튼은 회색으로 숨기지 않고 비활성 표시, 가능한 버튼만 색 강조. 금액은 `legalActions` 값을 그대로 표시.
- NL/PL: `폴드` `체크/콜 (+20)` `레이즈` → 레이즈 누르면 슬라이더 + 빠른 버튼(최소, 1/2팟, 팟, 올인) + `-`/`+` 버튼(기본 단위씩). 확인 버튼 한 번 더.
- 다이/폴드는 `toCall=0`일 때 "체크할 수 있는데 포기할까요?" 확인.
모드별 조작:
- 7포커 초이스: 4장 펼침 → 1단계 "버릴 카드를 누르세요"(빨간 X 표시) → 2단계 "보여 줄 카드를 누르세요"(위로 올라감) → `확인`. 되돌리기 가능.
- 교환(바둑이/드로우): 카드를 눌러 위로 올리면 교환 대상. 버튼 문구가 "패스" / "2장 바꾸기"로 바뀜. 바둑이 힌트: 겹치는 무늬·숫자 카드에 노란 테두리.
- 하이로우 선언: 큰 버튼 3개 `하이` `로우` `스윙`, 각 버튼 아래 내 하이/로우 패 이름.
- 인디언: 내 자리에 "?" 카드가 이마 위치에, 다른 사람 카드는 크게. 사용된 카드 패널(1~10 각 2칸에 체크).
- 쇼다운: 승자 카드 5장 위로 올라오며 족보 이름 크게, 칩이 승자에게 날아가는 애니메이션(0.6초). 사이드 팟은 하나씩 순서대로.
초보자 도움:
- "규칙 보기": 모드별 1쪽 요약 + 족보표(그림 예시). 한국식 베팅 용어 설명(삥/따당/하프).
- 첫 핸드에서 말풍선 튜토리얼(끌 수 있음). 내 차례에 진동/소리(설정에서 끄기), 남은 시간 원형 게이지, 5초 남으면 빨간색.
- 접근성: 무늬는 색 + 모양 + 글자(♠ 검정, ◆ 파랑, ♥ 빨강, ♣ 초록 4색 덱 옵션)로 구분, 큰 글씨 모드.
## 10. 테스트 체크리스트
- [ ] 한국식 5.2 예시: 앤티 40 → 삥 10 → 하프 40 → 따당 80 → 다이 → 콜 70 → 콜 40, 최종 팟 280이고 라운드 종료.
- [ ] 한국식: 콜 또는 체크한 플레이어가 같은 라운드에서 `k.half`를 보내면 거부("이번 라운드에서는 더 올릴 수 없어요").
- [ ] 한국식: 보스 체크 후 다음 사람 삥 가능, 이후 보스는 콜/다이만 가능.
- [ ] 한국식: 전원 체크 시 베팅 없이 다음 카드로 진행.
- [ ] 한국식 `koreanMaxRaise=half`에서 `k.full` 거부.
- [ ] NL: BB 10, A 30 레이즈 후 B의 `nl.raise {to:45}` 거부(최소 50), `{to:50}` 허용.
- [ ] NL 불완전 올인: A 벳 100, B 레이즈 300, C 올인 350(불완전), A는 콜/폴드만 가능하고 B도 다시 레이즈 불가.
- [ ] PL: 판돈 100, 상대 벳 20일 때 최대 `to=160`, 161 거부.
- [ ] 헤즈업 홀덤: 딜러가 SB이고 프리플랍 먼저 행동, 플랍부터는 BB가 먼저.
- [ ] 사이드 팟: A 100 올인 / B 300 올인 / C 500 → 메인 300(ABC), 사이드 400(BC), C에게 200 반환. A 최강이면 A 300, 나머지 400은 B·C 중 승자.
- [ ] 홀덤 분할: 보드 플레이 동점 시 균등 분할, 홀수 칩은 딜러 다음 좌석 쪽 승자.
- [ ] 오마하: 개인 ♠ 1장 + 공용 ♠ 4장은 플러시가 아님을 판정.
- [ ] 7포커 족보: 마운틴 > 백 스트레이트 > K탑 스트레이트, 백 스티플 > K탑 스티플 > 포카드.
- [ ] 7포커 무늬 동점: 7♠7♣ vs 7◆7♥ → 앞쪽 승. 투페어 K-5 vs K-9 → 뒤쪽 승(키커 무시).
- [ ] 7포커 보스: 오픈 카드 원페어가 탑보다 먼저 행동, 같은 숫자면 무늬로.
- [ ] 7포커 초이스 동시 진행: 한 명만 골랐을 때 다른 사람 화면에 그 사람의 오픈 카드가 아직 안 보임. 전원 완료 후 동시 공개.
- [ ] 하이로우 `korean`: A-2-3-4-5-9-K → 로우는 A-2-3-4-9. `aceToFive`면 A-2-3-4-5.
- [ ] 하이로우 스윙 실패: 스윙이 하이에서 지면 제외되고 하이 승자·로우 승자가 반분. 로우 선언자가 없으면 하이 승자가 전부.
- [ ] 하이로우 홀수 칩: 팟 101 → 하이 51, 로우 50.
- [ ] 바둑이: A♠2♦3♥3♣(3 베이스) < 2♠3♦7♥8♣(8 메이드). 2♥4♣7♦8♠ vs 2♠3♦7♥8♣ → 뒤쪽(3) 승. 같은 숫자면 분할.
- [ ] 바둑이 덱 부족: 6인 박스 반복 시 버린 카드 재섞기 후 정확한 장수 지급, 카드 중복 없음(52장 보존 불변식).
- [ ] 드로우 `three`: A 없이 4장 교환 거부, A를 남기고 4장 교환 허용.
- [ ] 인디언 1장: 본인 `view`의 자기 이마 카드가 `null`, 다른 플레이어 `view`에는 값이 있음, 관전자 `view`에는 핸드 종료 전 `null`.
- [ ] 인디언: 10을 단 채 다이 → 벌칙 10×기본 단위가 승자에게 이동. 동점 `carry` → 다음 핸드 승자가 이월 판돈 획득.
- [ ] 인디언 `whenEmpty`: 2인 20장 덱에서 10핸드 후 재섞기, 사용 카드 목록 초기화.
- [ ] 시간 초과: 한국식 베팅에서 `toCall>0`이면 자동 다이, `opened=false`면 자동 체크.
- [ ] 연결 끊김 2연속 시간 초과 → `away`, 이후 마감 5초. 재접속 후 직접 행동 시 해제.
- [ ] 리바이: `limited`/1회 사용 후 다시 0이 되면 탈락. `hands` 점수가 `칩 - 1000×리바이`로 계산됨.
- [ ] `lastStanding`: 같은 핸드에서 2명 탈락 시 핸드 시작 칩이 많던 사람이 더 높은 순위.
- [ ] 비밀 누출: 모든 단계에서 각 시청자의 `view`와 이벤트 JSON을 직렬화해 상대 비공개 카드의 숫자/무늬/id, 덱 내용, 초이스 버림 카드가 문자열에 없는지 검사(속성 기반 테스트, 무작위 시드 1,000판).
- [ ] 재현성: 같은 시드 + 같은 액션 열 → `apply` 결과 상태 해시 동일.
- [ ] 칩 보존 불변식: 매 `apply` 후 `Σ스택 + Σ팟 + Σ이번 라운드 기여 + 이월 판돈 = 초기 칩 합 + 리바이 합`.
## 11. 참고 자료
- 나무위키 "세븐 포커": https://namu.wiki/w/세븐%20포커 (한국식 초이스 룰, 4구 룰, 체크레이즈 금지, 무늬로 동점 처리)
- 나무위키 "섯다"(한국식 베팅 용어 정의 공통): https://namu.wiki/w/섯다
- 나무위키 "바둑이(플레잉 카드)": https://namu.wiki/w/바둑이(플레잉%20카드) (메이드/베이스/투베이스, 골프·세컨드·써드, 아침/점심/저녁, 무늬 비교 없음)
- 한게임 포커 7포커 베팅 방법: https://poker.hangame.com/gameguide/poker7/game_7poker2_1.html (삥·체크는 보스, 따당·하프·쿼터·풀 정의)
- 한게임 모바일 포커 규칙: https://m-poker.hangame.com/guide/rule (초이스 룰, 4구~7구 베팅, 하이로우 최고 로우 A-2-3-4-6, 패스/커트/박스)
- 한게임 하이로우 기본룰: https://poker.hangame.com/gameguide/highlow2/game_highlow1_1.html (선언 시점, 스윙은 양쪽 모두 이겨야 함)
- 넷마블 하이로우 가이드: http://c2.img.netmarble.kr/web/2007/html/gopo/po_guide/7cardhighlow/rule.html (로우에서 스트레이트·플러시 무시하는 변형 → `aceToFive` 옵션 근거)
- 홀덤마스터 "세븐포커 족보로 홀덤 치면 지는 이유": https://www.holdemmaster.com/blog/holdem-vs-7poker-hand-rankings (무늬 서열 ♠>◆>♥>♣, 키커 대신 무늬)
- 블루스택 한게임 포커 가이드: https://www.bluestacks.com/ko/blog/game-guides/hangame-poker/hp-rules-guide-ko.html
- Wikipedia "Texas hold 'em": https://en.wikipedia.org/wiki/Texas_hold_%27em (인원, 헤즈업 블라인드, 불완전 레이즈, 홀수 칩)
- Wikipedia "Omaha hold 'em": https://en.wikipedia.org/wiki/Omaha_hold_%27em
- Wikipedia "Five-card draw": https://en.wikipedia.org/wiki/Five-card_draw (3장, A 있으면 4장 교환 하우스 룰)
- Wikipedia "Badugi": https://en.wikipedia.org/wiki/Badugi (버린 카드 재섞기, 4회 베팅)
- 보드라이프 "더 지니어스 인디언 포커" 후기: https://boardlife.co.kr/bbs_detail.php?bbs_num=1503&tb=board_community (1~10 두 벌 20장, 10 다이 벌칙, 동점 이월)
출처 간 차이와 이 문서의 선택:
- 백 스트레이트 위치: 한게임 등 대부분 "마운틴 다음"으로 보지만 일부 글은 가장 낮은 스트레이트로 설명한다. 기본은 마운틴 다음(7포커 한국식).
- 하이로우 로우: 한게임은 A-2-3-4-6이 최고(스트레이트 불인정), 넷마블 가이드는 A-2-3-4-5(무시). 기본은 한게임식, 옵션으로 제공.
- 쿼터/하프/풀 금액 기준: 출처마다 "전체 판돈의 비율"이라고만 되어 있어, 콜 금액을 먼저 더한 판돈 기준으로 정의했다.
## 12. 메모 (상표·법적 주의 등)
- 가상 칩만 사용: 방 전용, 실제 돈·유료 재화·광고 보상과 연결하지 않는다. 칩 선물·양도·방 간 이월·누적 랭킹 금지. 결과 화면에서도 "칩"이라는 단어만 쓰고 원(₩) 표시나 환산 표현을 쓰지 않는다.
- 국내 법 검토 필요(확정 전 사용자 확인):
- 형법 제246조(도박): 재물이나 재산상 이익을 걸지 않으므로 해당하지 않도록 설계했지만, 사용자 간 외부 정산을 유도하는 기능(칩 수 공유 랭킹, 칩 거래)은 만들지 않는다.
- 게임산업진흥법: 게임물을 공개 배포하면 원칙적으로 등급분류 대상이다. 비영리 게임은 예외가 있으나 "청소년이용불가 등급 기준에 해당하는 내용"을 포함하면 예외에서 빠진다. 포커·고스톱류 웹보드 게임은 통상 청소년이용불가로 분류되므로, 포커를 공개 서비스에 넣기 전에 등급분류 필요 여부를 확인해야 한다.
- 대상 사용자에 어린이가 포함되므로, 포커·섯다 등 베팅형 게임은 기본 목록에서 분리(예: "어른용 카드 게임" 탭, 방장 확인 문구)하는 방안을 검토한다.
- 상표: "텍사스 홀덤", "7포커", "바둑이", "오마하", "인디언 포커"는 일반 게임 명칭으로 쓰인다. 다만 "한게임 포커", "피망 포커", "WSOP" 등 회사·대회 이름과 그 로고, 특정 사이트의 카드 디자인은 쓰지 않는다. 카드 그림(무늬·인물 카드)은 직접 그린 SVG를 사용한다.
- 표시 이름 후보: 메뉴 이름 "포커"(모드: 홀덤 / 7포커 / 하이로우 / 바둑이 / 인디언 포커 / 5카드 드로우 / 오마하). "인디언 포커"는 영어권에서 문화적으로 민감하게 볼 수 있으므로 대체 표시 이름 "이마 포커"를 옵션으로 준비하고 사용자가 고르게 한다.
- 문서 간 공유: 한국식 판돈 베팅(5.2), 사이드 팟(5.5), 리바이·탈락(5.6)은 `seotda`(섯다)와 같은 공용 모듈로 구현한다.

211
docs/games/quoridor.md Normal file
View File

@@ -0,0 +1,211 @@
# 벽 쌓기 길찾기 (쿼리도 방식) (`quoridor`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 (2인 또는 4인만, 3인 불가) · 예상 시간: 약 10~20분 · 난이도: 쉬움~보통
## 1. 개요
- 9×9 판에서 자기 말을 반대편 끝줄까지 먼저 보내면 이기는 게임. 매 차례 말을 움직이거나 벽을 세워 상대의 길을 돌아가게 만든다. 단, 누구의 길도 완전히 막아서는 안 된다. 규칙이 직관적이라 초등학생 보드게임 수업과 가족 게임으로 국내에서도 인기가 있다.
- 인원 근거: 원작(Gigamic, 1997, Mirko Marchesi 디자인)은 2인 또는 4인용. `minPlayers = 2`, `maxPlayers = 4`이며, setup에서 플레이어 수가 3이면 거부한다("2명 또는 4명이 필요해요").
- 완전 정보 게임. view에서 제외할 것은 RNG 상태뿐.
- 원작명은 상표이므로 표시 이름은 일반 명칭을 권장(12장).
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `wallsPerPlayer` | 1인당 벽 수 | 2인: 10, 4인: 5 고정 (원작). 하우스 룰로 2인 6~10 선택 가능 | 2인 10 / 4인 5 |
| `diagonalJumpTrigger` | 대각선 점프가 허용되는 조건 | `wallEdgePawn`(뒤가 벽·판 끝·다른 말이면 대각 허용) / `wallOnly`(벽일 때만, Gigamic 2인 규칙서 문구 그대로) | `wallEdgePawn` |
| `seatOrder` | 시작 위치·순서 | `random` / `joinOrder` | `random` |
| `continueForRanking` | 4인에서 1등이 나온 뒤에도 계속해서 2·3등을 가릴지 | `true` / `false` | `false` |
| `timeControl.kind` | 시간 방식 | `none` / `perMove` / `fischer` | `perMove` |
| `timeControl.perMoveSec` | 한 수당 제한 | 15 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 / 수당 추가 | 3+2, 5+3 | 5+3 |
| `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(최단 경로로 말 1칸 이동) / `lose` | `autoMove` |
| `idleLimitSec` | `none` 무응답 한도 | 120 / 300 | 300 |
| `undo` | 무르기 허용 횟수(다른 모든 플레이어 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안(남은 모든 플레이어 동의) | `true` / `false` | `true` |
| `showPath` | 내 최단 경로 표시(힌트) | `true` / `false` | `true` |
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
## 3. 구성물
- 9×9 칸(81칸) 판. 칸과 칸 사이에 홈(groove)이 있어 벽을 끼운다.
- 말 2개 또는 4개(색 구분).
- 벽 20개. 각 벽은 칸 2개 길이.
- 좌표: 열 a~i(왼→오), 행 1~9(아래→위). 내부 칸 `(c, r)` 0~8, `r=0`이 1행.
- 벽 앵커: 벽의 가운데는 항상 홈의 교차점(8×8 = 64곳)에 온다. 앵커 `(c, r)` (0~7)은 칸 `(c,r) (c+1,r) (c,r+1) (c+1,r+1)` 네 칸의 가운데 교차점.
- 가로벽 `H(c,r)`: `(c,r)↔(c,r+1)`, `(c+1,r)↔(c+1,r+1)` 이동을 막는다.
- 세로벽 `V(c,r)`: `(c,r)↔(c+1,r)`, `(c,r+1)↔(c+1,r+1)` 이동을 막는다.
## 4. 준비(셋업)
1. 플레이어 수 확인(2 또는 4). `seatOrder=random`이면 RNG로 자리 배정.
2. 시작 위치와 목표:
| 자리 | 시작 칸 | 목표 | 차례 순서 |
|---|---|---|---|
| 남(S) | e1 `(4,0)` | 9행 전체 (`r=8`) | 1 |
| 서(W, 4인) | a5 `(0,4)` | i열 전체 (`c=8`) | 2 |
| 북(N) | e9 `(4,8)` | 1행 전체 (`r=0`) | 2인: 2 / 4인: 3 |
| 동(E, 4인) | i5 `(8,4)` | a열 전체 (`c=0`) | 4 |
(4인 순서는 판을 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동.)
3. 벽 배분: 2인 10개씩, 4인 5개씩.
4. 남이 먼저 둔다.
## 5. 진행 규칙
### 5.1 턴
- 차례인 플레이어는 다음 중 하나만 한다: (A) 말 이동, (B) 남은 벽이 있으면 벽 1개 설치. 패스 없음(단 5.4 예외).
### 5.2 말 이동
- 기본: 상하좌우 인접 칸으로 1칸. 사이에 벽이 없고, 판 안이며, 다른 말이 없어야 한다. 대각선 1칸 이동은 기본 이동이 아니다.
- 직선 점프: 인접 칸(방향 d)에 다른 말 Q가 있고 나와 Q 사이에 벽이 없을 때, Q 너머 칸(같은 방향 d)이 판 안이고 Q와 그 칸 사이에 벽이 없고 비어 있으면 그 칸으로 뛰어넘는다.
- 대각 점프: 위 직선 점프가 불가능한 경우(`wallEdgePawn`: Q 뒤가 벽·판 끝·다른 말 / `wallOnly`: Q 뒤가 벽일 때만), Q의 좌우(방향 d에 수직인 두 방향) 인접 칸 중 Q와 그 칸 사이에 벽이 없고 판 안이며 비어 있는 칸으로 이동할 수 있다.
- 한 번에 말 1개만 뛰어넘는다(4인에서 말 2개가 일렬이어도 2개를 넘는 점프 불가).
- 직선 점프가 가능하면 대각 점프는 불가.
### 5.3 벽 설치
- 합법 조건(모두 만족):
1. 남은 벽이 1개 이상.
2. 앵커 `(c, r)`가 0~7 범위(벽은 판 밖으로 나갈 수 없음, 항상 정확히 2칸을 막음).
3. 기존 벽과 겹치지 않음: `H(c,r)`는 `H(c-1,r)`, `H(c,r)`, `H(c+1,r)`, `V(c,r)`와 충돌. `V(c,r)`는 `V(c,r-1)`, `V(c,r)`, `V(c,r+1)`, `H(c,r)`와 충돌(같은 교차점 십자 교차 금지).
4. 설치 후에도 게임에 남아 있는 모든 플레이어(설치한 본인 포함)가 각자 목표 줄까지 갈 수 있는 경로가 하나 이상 존재. 경로 탐색 시 말은 장애물로 보지 않고 벽만 본다.
- 경로 검사(BFS):
```ts
function hasPath(walls: WallSet, start: Cell, goal: (c: Cell) => boolean): boolean {
const seen = new Set<number>([idx(start)]);
const queue: Cell[] = [start];
while (queue.length) {
const cur = queue.shift()!;
if (goal(cur)) return true;
for (const d of [N, S, E, W]) {
const nxt = step(cur, d);
if (!onBoard(nxt) || blocked(walls, cur, d) || seen.has(idx(nxt))) continue;
seen.add(idx(nxt)); queue.push(nxt);
}
}
return false;
}
// 벽 후보 합법성 = 조건 1~3 && 모든 활성 플레이어 p에 대해 hasPath(walls ∪ {후보}, pos(p), goal(p))
```
- 설치한 벽은 이동·제거할 수 없다.
### 5.4 움직일 수 없는 경우
- 말 이동이 하나도 불가능하고(다른 말과 벽에 둘러싸임) 벽도 없으면 자동 패스(이벤트 `forcedPass`). 원작 규칙서에 명시되지 않은 극히 드문 상황에 대한 서비스 규칙.
## 6. 승패와 점수 계산
- 자기 목표 줄의 아무 칸에 말이 도착하는 즉시 승리(1등). 기본(`continueForRanking=false`)은 그 즉시 게임 종료.
- 4인 결과 순위: 1등 = 도착자. 나머지는 종료 시점의 최단 경로 길이(BFS 거리, 말 무시)가 짧은 순으로 2~4등, 같으면 공동 순위. 기권·실격자는 최하위(기권 순서가 늦을수록 높은 순위).
- `continueForRanking=true`(4인): 도착한 말은 판에서 제거하고(그 칸은 비게 됨, 벽은 남음) 남은 플레이어로 계속, 마지막 1명이 남으면 종료. 도착 순서대로 1~3등, 남은 1명 4등.
- 2인: 도착자 승리, 상대 패배.
- 기권: 2인은 상대 승. 4인은 기권자의 말을 판에서 제거, 남은 벽은 소멸, 설치한 벽은 유지. 남은 인원이 1명이면 그 사람 1등.
- 무승부: 남은 모든 플레이어 동의 시. 무승부면 남은 플레이어 모두 공동 1등.
- 점수 개념 없음(결과에 각자 남은 거리·남은 벽 수 기록).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 대각 점프 조건: 원작 2인 규칙서는 "점프할 말 뒤에 벽이 있으면 좌우로" 라고만 적혀 있어 판 끝·다른 말의 경우 해석이 갈린다. 대부분의 온라인 구현과 영문 위키는 판 끝·다른 말도 포함하므로 기본값은 `wallEdgePawn`.
- 2인 벽 개수 줄이기(빠른 게임): 하우스 룰.
- 3인 플레이는 원작에 없으므로 미지원.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Seat = 'S' | 'W' | 'N' | 'E';
type Cell = { c: number; r: number };
type Wall = { c: number; r: number; o: 'h' | 'v' };
interface QuoridorState {
options: QuoridorOptions;
seats: { seat: Seat; playerId: PlayerId; pos: Cell | null; // null = 제거(기권/도착 후 제외)
wallsLeft: number; status: 'playing' | 'finished' | 'resigned' | 'timedOut';
finishOrder: number | null }[];
turnIndex: number; // seats 배열 인덱스
walls: Wall[];
wallIndex: { h: boolean[]; v: boolean[] }; // 8x8 앵커별 점유 (빠른 충돌 검사)
moves: ({ kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
| { kind: 'wall'; seat: Seat; wall: Wall }
| { kind: 'pass'; seat: Seat })[];
phase: 'playing' | 'finished';
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[] } | null };
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
result: QuoridorResult | null;
rng: RngState;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `move` | `{ to: Cell }` | 차례인 플레이어 | `to`가 5.2 규칙으로 계산한 이동 가능 칸 목록에 포함. 사유 예: "벽이 가로막고 있어요", "대각선으로는 상대 말을 넘을 때만 갈 수 있어요" |
| `placeWall` | `{ c, r, o }` | 차례인 플레이어 | 5.3 조건 1~4. 사유 예: "남은 벽이 없어요", "다른 벽과 겹쳐요", "누군가의 길을 완전히 막을 수 없어요" |
| `requestUndo` | `{}` | 직전 행동을 한 플레이어 | 횟수 남음, 대기 없음. 2인: 본인 직전 행동 1개(상대가 이미 행동했으면 2개). 4인: 본인이 방금 행동했고 다음 사람이 아직 행동하지 않았을 때만, 1개 |
| `respondUndo` | `{ accept }` | 요청자 외 활성 플레이어 전원 | 1명이라도 거절 → 취소. 전원 수락 → `moves` 재생으로 복원(벽 개수 포함), 시계 유지 |
| `offerDraw` / `respondDraw` / `cancelOffer` | | 활성 플레이어 | 전원 수락 시 무승부. 다음 사람이 행동하면 자동 취소 |
| `resign` | `{}` | 활성 플레이어 | 6장 처리 |
| `timeout` | `{}` | 시스템 | 8.5 |
- 이동 가능 칸 계산 `legalPawnMoves(state, seat)`: 4방향에 대해 5.2 규칙을 적용해 중복 없는 칸 목록 반환.
- 행동 후: 도착 판정 → 종료 또는 순위 기록 → 다음 활성 플레이어로 차례 이동(제거된 자리는 건너뜀) → 그 플레이어가 이동·벽 모두 불가하면 자동 패스.
- 이벤트: `pawnMoved`, `wallPlaced`, `forcedPass`, `playerFinished`, `playerResigned`, `gameEnded`.
### 8.3 공개/비공개 정보 (view)
- 공통 공개: 말 위치, 모든 벽, 각자 남은 벽 수, 차례, 기보, 시계, `deadline`, 대기 요청(누가 수락했는지 포함), 각 플레이어 최단 거리(관전 재미 요소, 공개 정보에서 계산 가능하므로 숨길 이유 없음), 결과.
- 차례인 플레이어에게만: `legalPawnMoves`, `legalWalls`(최대 128개 후보 중 합법 앵커 목록 — 클라이언트가 BFS를 구현하지 않아도 미리보기 가능), `showPath`면 `myShortestPath`.
- 제외: `rng`.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `seatOrder=random`의 자리 배정.
2. `autoMove`에서 최단 경로의 첫 칸 후보가 여럿일 때 선택.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[seat]`, `none` → `turnStartedAt + idleLimitSec*1000`, 종료 → `null`.
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- `perMove + autoMove`: 이동 가능 칸 중 "목표까지 BFS 거리(말 무시)가 가장 짧아지는 칸"으로 이동(점프 포함). 동률은 RNG. 이동 불가면 자동 패스. streak 3회면 시간패.
- 그 외: 시간패. 2인은 상대 승. 4인은 기권과 같은 처리(말 제거, `timedOut`).
- 연결 끊김: 끊긴 플레이어 차례에 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout`. 4인에서 끊긴 플레이어가 무르기·무승부 응답자일 때는 "응답 없음 = 거절"로 30초 후 자동 취소(서버 타이머가 `respondUndo{accept:false}` 대신 요청 자체를 만료시키는 이벤트 처리).
### 8.6 종료 조건과 결과(GameResult)
```ts
interface QuoridorResult {
ranking: { playerId: PlayerId; rank: number }[]; // 공동 순위 허용
reason: 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed';
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number; wallsLeft: number;
status: 'finished' | 'playing' | 'resigned' | 'timedOut' }[];
moveCount: number;
summaryKo: string; // "남(철수) 승리 — 23수 만에 도착"
}
```
## 9. UI/UX
- 모바일 세로: 정사각 판이 화면 폭 100%(폰 360px에서 칸 약 36px + 홈). 상단에 상대(들) 정보와 남은 벽(막대 아이콘), 하단에 내 정보와 모드 전환 큰 버튼 2개 "말 움직이기" / "벽 세우기"(각 48px 이상).
- 말 이동 모드: 이동 가능 칸이 밝게 표시, 탭으로 이동.
- 벽 세우기 모드: 홈의 교차점 근처를 탭하면 벽 미리보기(반투명), 가로/세로 전환 버튼(또는 같은 곳 다시 탭으로 회전), 놓을 수 없으면 빨간 미리보기와 이유 표시, "확인" 버튼으로 설치. 작은 화면에서 홈을 직접 터치하기 어렵기 때문에 "교차점 가장 가까운 곳 스냅 + 확인" 2단계 필수.
- 힌트: 내 최단 경로를 점선으로, 벽 미리보기 중에는 "이 벽을 놓으면 상대 길이 +4칸" 같은 변화량 표시(초보에게 벽의 효과를 알려줌, 옵션).
- 4인: 각 말 색과 목표 줄을 판 가장자리 색 띠로 표시.
- 규칙 보기: 점프·대각 점프 그림, "길을 완전히 막으면 안 돼요" 그림.
## 10. 테스트 체크리스트
- [ ] 2인/4인 시작 위치·목표·차례 순서, 3인 시작 거부.
- [ ] 기본 이동: 벽·판 끝·다른 말 때문에 막힘.
- [ ] 직선 점프 성공, 점프 뒤 칸이 벽/판 끝/말일 때 대각 점프 2곳, 그중 한쪽이 벽으로 막히면 1곳만.
- [ ] `wallOnly`에서 판 끝 뒤 대각 점프 불가.
- [ ] 직선 점프 가능할 때 대각 점프 거부, 말 2개 연속 점프 거부.
- [ ] 벽 겹침: 같은 방향 반 칸 겹침, 같은 앵커 십자 교차 거부. 서로 다른 앵커의 T자 접촉은 허용.
- [ ] 판 밖 앵커(0~7 범위 밖) 거부, 남은 벽 0개일 때 거부.
- [ ] 경로 차단: 상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부, 4인에서 제3자의 길을 막는 벽 거부, 말 때문에만 막히는 경우는 허용.
- [ ] 목표 줄 도착 즉시 승리(점프로 도착 포함).
- [ ] 4인 기권: 말 제거, 벽 유지, 이후 경로 검사에서 제외, 1명 남으면 종료.
- [ ] `continueForRanking`: 도착자 제거 후 계속, 순위 기록.
- [ ] 4인 결과 순위가 남은 거리로 정렬되고 동률 공동 순위.
- [ ] `autoMove`: 최단 거리 감소 칸으로 이동, 같은 시드 동일 결과, 3연속 시간패.
- [ ] 4인 무르기: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가.
- [ ] 자동 패스: 말·벽 모두 불가한 인위적 국면.
- [ ] 정보 누출: 관전자·비차례 view에 `legalWalls`/`myShortestPath` 없음, `rng` 없음.
- [ ] 성능: 벽 후보 128개 × BFS 4회 계산이 1ms 수준인지(합법 벽 목록 생성).
## 11. 참고 자료
- Wikipedia "Quoridor": https://en.wikipedia.org/wiki/Quoridor (9×9, 2인 10개·4인 5개 벽, 점프와 대각 점프 조건: 판 끝·세 번째 말·벽, 길 차단 금지, 4인 시작 위치 e1·a5·e9·i5)
- Gigamic 공식 규칙서(Quoridor rules PDF, gigamic.com 제품 페이지): 2인 규칙의 대각 점프 문구("벽이 있으면 좌우로"), 벽은 2칸 길이·홈에 설치
- BoardGameGeek "Quoridor": https://boardgamegeek.com/boardgame/624/quoridor (출판사·인원·플레이 시간)
## 12. 메모 (상표·법적 주의 등)
- "Quoridor"(쿼리도/쿼리더)는 Gigamic의 등록 상표다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 이름·로고·판 디자인은 사용하지 말 것. 서비스 표시 이름 후보: "벽 쌓기 길찾기", "길막기", "미로 탈출 보드". 최종 이름은 사용자 결정 사항. 규칙 보기에서 "쿼리도와 비슷한 규칙" 같은 비교 언급 여부도 사용자와 확인.
- 게임 id `quoridor`는 내부용이며 UI에 노출하지 않는다(필요하면 `wall-maze` 등으로 변경).
- 금전·베팅 요소 없음.

320
docs/games/rummikub.md Normal file
View File

@@ -0,0 +1,320 @@
# 숫자 타일 게임 - 루미큐브 방식 (`rummikub`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통
## 1. 개요
- 1~13 숫자가 적힌 4색 타일로 "그룹"(같은 숫자, 다른 색)과 "런"(같은 색, 연속 숫자)을 만들어 내려놓는다. 자기 받침대(랙)의 타일을 가장 먼저 다 쓰면 이긴다.
- 핵심 재미는 테이블 타일 재배치(manipulation)다. 이미 놓인 세트를 쪼개고 합치고 바꿔서 내 타일을 끼워 넣을 수 있다. 한국에서는 가족 보드게임 판매량 상위권이고, 노년층 두뇌 게임으로도 인기가 많다.
- 인원 근거: 공식 규칙서 기본판 106타일은 2~4명이다. 5~6명은 160타일 확장판(XP)용이라 범위에서 뺀다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `initialMeldPoints` | 첫 등록에 필요한 최소 점수 | 0(없음) / 20 / 30 / 50 | 30 |
| `turnSeconds` | 턴 제한 시간(공식 1분) | 60 / 90 / 120 / 180 | 60 |
| `timeoutValidDraft` | 시간 초과 때 초안이 유효한 경우 처리 | `commit`(자동 확정) / `revert`(공식대로 되돌리고 1장 뽑기) | `commit` |
| `invalidTimeoutPenalty` | 시간 초과 때 초안이 무효한 경우 뽑는 장수(공식 3) | 1 / 3 | 3 |
| `invalidCommitPenalty` | "완료"를 눌렀는데 무효일 때 처리 | `reject`(거부만, 계속 수정) / `penalty3`(되돌리고 3장 뽑고 턴 종료) | `reject` |
| `jokerPenalty` | 랙에 남은 조커 감점 | 30(공식) / 50(대회) | 30 |
| `initialMeldAllowExtend` | 첫 등록 턴에 기존 테이블 세트에도 붙이기 허용 | on / off(공식) | off |
| `showDraftToOthers` | 내가 고민 중인 초안을 다른 사람에게 실시간으로 보이기 | on / off | off |
| `gamesPerMatch` | 한 매치의 판 수(공식은 인원수만큼 판 = 1라운드) | 1 / 인원수 / 2 / 3 / 4 | 1 |
| `hints` | "낼 수 있는 조합 찾기" 힌트 버튼 | on / off | on |
## 3. 구성물
- 숫자 타일 104개: 4색(검정 K, 빨강 R, 파랑 B, 주황 O) × 숫자 1~13 × 각 2개.
- 조커 2개. 조커는 어떤 색, 어떤 숫자든 대신할 수 있다.
- 합계 106개. 타일 ID 규칙 예: `R7a`, `R7b`, `JK1`, `JK2`.
- 타일 점수: 숫자 타일은 적힌 숫자, 조커는 테이블에서 대신하는 타일의 숫자. 게임 끝에 랙에 남은 조커는 `jokerPenalty`(30점)다.
## 4. 준비(셋업)
1. 106개를 시드 RNG로 섞는다(뒷면 상태의 "풀").
2. 선 플레이어 정하기(공식): 각자 1개씩 뽑아 숫자가 가장 큰 사람이 선이다. 조커를 뽑거나 최고 숫자가 같으면 그 사람들만 다시 뽑는다. 뽑은 타일은 풀에 돌려놓고 다시 섞는다. 화면에서는 뽑기 애니메이션으로 보여 주고, 엔진에서는 RNG로 같은 절차를 재현한다.
3. 각자 14개를 받는다.
4. 남은 타일이 풀이 된다(2인 78개, 3인 64개, 4인 50개).
5. 진행은 선부터 시계 방향(왼쪽)으로 한다.
## 5. 진행 규칙
### 5.1 세트(조합) 정의
- **그룹**: 같은 숫자, 서로 다른 색 3~4개. 예: 빨강7, 파랑7, 검정7. 같은 색이 두 개 들어가면 무효다.
- **런**: 같은 색, 연속 숫자 3개 이상(최대 13개). 예: 파랑3, 파랑4, 파랑5, 파랑6.
- 1은 항상 가장 작은 수다. 13 다음에 1이 올 수 없다(12-13-1 불가).
- 조커는 세트에 필요한 아무 타일이나 대신한다. 한 세트에 조커 2개도 쓸 수 있다. 단 세트에 진짜 타일이 최소 1개 있어야 한다(조커 2개만으로는 3개 세트가 안 되므로 자연히 성립).
### 5.2 턴에 할 수 있는 일
내 차례에는 둘 중 하나를 한다.
1. **내려놓기(완료)**: 랙에서 타일을 1개 이상 테이블에 내고, 턴이 끝날 때 테이블 전체가 유효한 세트로만 이루어져 있어야 한다.
2. **뽑기**: 풀에서 1개를 가져오고 턴이 끝난다. 낼 수 있어도 일부러 뽑을 수 있다. 방금 뽑은 타일은 이번 턴에 낼 수 없다(뽑으면 턴이 끝나므로 자연히 성립).
- 풀이 비어 있으면 "뽑기" 대신 "넘기기"가 된다.
### 5.3 첫 등록(initial meld)
- 아직 첫 등록을 하지 않은 플레이어는 자기 랙의 타일만으로 하나 이상의 새 세트를 만들어 내야 한다. 그 합이 `initialMeldPoints`(30) 이상이어야 한다.
- 조커는 첫 등록에 쓸 수 있고, 대신하는 타일의 숫자로 계산한다.
- 첫 등록 턴에는 테이블의 기존 세트를 건드리거나 거기에 붙일 수 없다(공식). `initialMeldAllowExtend = on`이면 30점을 랙 타일만의 새 세트로 채운 뒤라면 같은 턴에 기존 세트에 붙이기와 재배치도 허용한다.
- 예: 랙 [빨강10, 빨강11, 빨강12] = 33점이면 가능하다. [파랑1, 파랑2, 파랑3] + [검정5, 빨강5, 주황5] = 6 + 15 = 21점이면 불가능하다. [조커, 검정9, 검정10]을 8-9-10으로 쓰면 27점이라 불가능하고, 9-10-11로 쓰면 30점이라 가능하다(5.5 조커 위치 해석 참고).
### 5.4 테이블 재배치(첫 등록을 한 뒤부터)
- 테이블의 어떤 세트든 쪼개고, 합치고, 타일을 옮기고, 끝에서 떼어 내고, 그룹의 4번째 타일을 빼 오는 등 마음대로 바꿀 수 있다.
- 조건
- 턴이 끝날 때 테이블의 모든 타일이 유효한 세트에 속해 있어야 한다(남는 낱개 타일 없음).
- 테이블에 있던 타일은 랙으로 가져갈 수 없다.
- 이번 턴에 랙에서 최소 1개를 내야 한다(재배치만 하고 끝낼 수 없음).
- 공식 규칙서 예시
- 파랑4-5-6에 파랑3 붙이기.
- 8 그룹(3색)에 남은 색 8 붙이기.
- 4가 4색인 그룹에서 파랑4를 빼서 랙의 파랑3, 파랑5, 파랑6과 런 만들기.
- 빨강4-5-6-7-8 런을 랙의 빨강6으로 4-5-6 / 6-7-8로 쪼개기.
### 5.5 조커 규칙
- 조커는 테이블에 놓인 뒤, 같은 역할의 타일로 바꿔 넣으면 회수할 수 있다.
- 런 안의 조커: 그 자리의 색·숫자 타일로 바꾼다.
- 3개짜리 그룹의 조커: 빠진 두 색 중 아무 색으로 바꿔도 된다.
- 회수한 조커는 같은 턴에 테이블에 다시 써야 하고, 랙으로 가져갈 수 없다. 이번 턴에 랙에서 최소 1개를 내야 하는 조건도 그대로다.
- 첫 등록 전에는 조커를 회수할 수 없다(첫 등록 전에는 재배치 자체가 불가).
- 엔진 구현 원칙: 조커 회수를 따로 액션으로 두지 않는다. "턴 종료 시점의 테이블 전체가 유효하고, 테이블 타일 집합 ⊇ 턴 시작 시점 테이블 타일 집합"만 검사하면 위 조건이 모두 자동으로 지켜진다. 조커가 다른 세트로 옮겨 가도 되고, 런 끝 조커를 떼어 새 세트에 써도 된다(공식 예시 3·4번과 같음).
- 단순화 메모: 공식 문구는 "회수한 조커는 새 세트를 만드는 데 써야 한다"이지만, 조커를 기존 세트로 옮기는 것도 허용한다. 엄격히 하려면 "턴 시작 때 테이블에 있던 조커는 턴 시작 때와 같은 세트 구성원 집합에 머물 수 없다" 같은 검사가 필요하지만, 실제 대국에서 차이가 거의 없어 기본은 생략한다.
- **조커 값 해석**(점수·표시용): 서버가 각 세트를 정규화할 때 조커의 색·숫자를 정한다.
- 런: 클라이언트가 보낸 순서 그대로 유효하면 그 순서를 따른다. 아니면 정렬한 뒤 빈칸을 조커로 채우고, 남는 조커는 위쪽 끝(13 방향)에 먼저 붙이고 불가능하면 아래쪽 끝에 붙인다.
- 그룹: 숫자는 그룹의 숫자이고 색은 "빠진 색 중 하나"로 표시한다(정해지지 않음).
- 런과 그룹 둘 다 될 수 있는 세트(예: [빨강5, 조커, 조커])는 클라이언트의 `as` 힌트를 따르고, 힌트가 없으면 런으로 해석한다.
### 5.6 초안(draft)과 되돌리기
- 내 턴 동안 테이블을 바꾸는 것은 서버에 있는 "초안"을 바꾸는 것이다. 초안은 턴을 확정하기 전까지 실제 테이블이 아니다.
- "되돌리기"는 초안을 바로 이전 단계로, "처음으로"는 턴 시작 상태로 돌린다. 서버가 초안 기록(최대 100단계)을 보관하므로 재접속해도 유지된다.
- 초안 중간 상태는 무효해도 된다(낱개 타일, 2개짜리 세트 등). 검증은 "완료"를 누를 때와 시간 초과 때만 한다.
### 5.7 시간 제한과 벌칙(공식 + 옵션)
- 턴 제한은 `turnSeconds`(공식 1분)다.
- 시간 초과 때
- 초안이 턴 시작과 같으면 1개를 뽑고 턴이 끝난다(풀이 비었으면 넘기기).
- 초안이 유효하고 랙 타일이 1개 이상 들어 있으면 `timeoutValidDraft = commit`일 때 자동으로 확정한다. `revert`면 되돌리고 1개를 뽑는다.
- 초안이 무효하면 테이블을 턴 시작 상태로 되돌리고, 낸 타일은 랙으로 돌아오며, `invalidTimeoutPenalty`(공식 3)개를 뽑고 턴이 끝난다.
- "완료"를 눌렀는데 무효하면 `invalidCommitPenalty = reject`(기본)일 때 거부만 하고, 무효한 세트를 빨간색으로 표시한 뒤 계속 고칠 수 있게 한다. 타이머는 계속 간다.
### 5.8 풀이 다 떨어졌을 때(공식)
- 풀이 비어도 누군가 랙을 다 비우거나 더 이상 아무도 낼 수 없을 때까지 계속한다.
- 엔진 판정: 풀이 빈 상태에서 남아 있는 모든 플레이어가 연속으로 한 번씩 "넘기기"(또는 시간 초과로 내려놓지 못함)를 하면 게임이 끝난다.
## 6. 승패와 점수 계산
- **랙을 비운 경우(공식)**
- 나머지 플레이어는 각자 랙 타일 숫자 합을 음수로 받는다(조커는 `jokerPenalty`).
- 승자는 그 합의 절대값을 양수로 받는다. 한 판의 점수 합은 항상 0이다.
- 예: 4인에서 A가 끝냄. B 랙 [3, 12] = -15, C [조커] = -30, D [1, 1, 13] = -15. A = +60.
- **풀이 비고 아무도 못 내서 끝난 경우(공식)**
- 랙 합이 가장 작은 사람이 승자다.
- 나머지는 (자기 랙 합 - 승자 랙 합)을 음수로 받고, 승자는 그 합의 절대값을 받는다.
- 예: 랙 합 A=5, B=12, C=30. A 승리. B = -(12-5) = -7, C = -(30-5) = -25, A = +32.
- 랙 합이 같은 최저자가 여럿이면 랙 타일 수가 적은 사람이 승자다. 그래도 같으면 공동 승자이고, 다른 사람의 음수 합을 공동 승자 수로 나누어 내림한 값을 나눠 갖는다(이 경우 판 합이 0이 아닐 수 있음, 결과 표시용).
- **여러 판(`gamesPerMatch` > 1)**: 판별 점수를 더한다. 공식 최종 승자 결정은 "판을 가장 많이 이긴 사람"이고, 동률이면 총점이 높은 사람이다.
- 순위: 1위는 승자, 나머지는 그 판 점수가 높은(감점이 적은) 순이다. 같으면 공동 순위다.
- 이 점수는 방 안 결과 표시용이고, 돈이나 칩이 아니다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 첫 등록 점수 변경(`initialMeldPoints`). 어린이용 20점이나 0점(없음), 고수용 50점.
- 조커 감점 50점(대회 룰, `jokerPenalty`).
- 첫 등록 턴 추가 재배치 허용(`initialMeldAllowExtend`).
- 시간 초과 때 유효 초안 자동 확정(`timeoutValidDraft`).
- 넉넉한 턴 시간(90~180초). 노년층과 어린이 방에 권장한다.
- 지원하지 않는 변형(문서화만): 13-1 이어 붙이기, 조커 회수를 첫 등록 전에도 허용, 5~6인 확장(160타일).
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type TileColor = 'K' | 'R' | 'B' | 'O';
type Tile =
| { id: string; kind: 'num'; color: TileColor; n: number } // n: 1..13
| { id: string; kind: 'joker' };
interface TableSet {
id: string; // 세트 ID(애니메이션 연속성용, 서버가 발급)
tileIds: string[]; // 정규화된 순서(런은 오름차순)
kind: 'run' | 'group';
jokerAs: Record<string, { color: TileColor | null; n: number }>; // 조커 해석(표시·점수용)
}
interface DraftSet { tileIds: string[]; as?: 'run' | 'group' }
interface RummiState {
options: RummiOptions;
rng: RngState;
seats: PlayerId[];
tiles: Record<string, Tile>; // 전체 타일 정의(불변)
pool: string[]; // 비공개, 섞인 순서
racks: Record<PlayerId, string[]>; // 비공개
table: TableSet[]; // 공개, 확정된 테이블
hasMelded: Record<PlayerId, boolean>; // 첫 등록 여부(공개)
current: PlayerId;
turnStartedAt: number;
draft: { // 현재 턴 초안
sets: DraftSet[];
history: DraftSet[][]; // 되돌리기 스택(최대 100)
} | null;
consecutivePassesWithEmptyPool: number;
phase: 'playing' | 'finished';
endReason: 'rackEmpty' | 'stalemate' | null;
winnerIds: PlayerId[];
gameScores: Record<PlayerId, number>; // 이번 판 점수
match: { gameNo: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number> };
seq: number;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `setDraft` | `{ sets: DraftSet[] }` | `current`, playing | 초안 보존 검사만 한다(아래 C1~C3). 세트 유효성은 보지 않는다. 초당 10회 제한(서버 레벨). 현재 초안을 history에 넣는다 |
| `undoDraft` | `{}` | `current` | history가 비어 있지 않음 |
| `resetDraft` | `{}` | `current` | 언제나 가능(초안을 턴 시작 테이블로) |
| `commit` | `{}` | `current` | 아래 "턴 확정 검증" 통과. 실패하면 `invalidCommitPenalty`에 따름 |
| `draw` | `{}` | `current` | 초안이 턴 시작과 같거나, 다르면 초안을 버리고 뽑는다(UI에서 확인 대화상자). 풀이 비었으면 `pass`와 같다 |
| `pass` | `{}` | `current` | 풀이 비었을 때만 |
초안 보존 검사(`setDraft`)
- C1: 초안의 모든 타일 ID는 (턴 시작 테이블 타일 ∪ 내 랙 타일)에 속하고, 중복이 없다.
- C2: 턴 시작 테이블의 모든 타일이 초안에 들어 있다(테이블 → 랙 이동 금지).
- C3: 첫 등록 전(`hasMelded=false`)이고 `initialMeldAllowExtend=off`이면, 턴 시작 테이블의 세트들이 구성 그대로(같은 타일 집합) 초안에 있다.
턴 확정 검증(`commit`, 시간 초과 판정 공용)
```ts
function validateTurn(s: RummiState, p: PlayerId, draft: DraftSet[]): Result {
// 1) 보존: C1, C2 (위와 동일)
// 2) 랙에서 1개 이상 냄
const startTable = new Set(s.table.flatMap(t => t.tileIds));
const fromRack = draft.flatMap(d => d.tileIds).filter(id => !startTable.has(id));
if (fromRack.length === 0) return fail('랙에서 타일을 1개 이상 내야 해요');
// 3) 모든 세트가 유효(normalizeSet이 null이면 무효)
const normalized = draft.map(d => normalizeSet(d, s.tiles));
if (normalized.some(n => n === null)) return fail('올바르지 않은 조합이 있어요', { invalidSetIdx });
// 4) 첫 등록 조건
if (!s.hasMelded[p]) {
// 기존 세트는 그대로여야 하고(C3), 새 세트는 랙 타일로만 구성
const newSets = normalized.filter(n => n.tileIds.every(id => !startTable.has(id)));
const pts = sum(newSets.map(setPoints)); // 조커는 jokerAs.n
if (pts < s.options.initialMeldPoints) return fail(`첫 등록은 ${s.options.initialMeldPoints}점 이상이어야 해요 (지금 ${pts}점)`);
// initialMeldAllowExtend=off 이면: 기존 세트에 랙 타일이 섞인 세트가 없어야 함
}
return ok();
}
function normalizeSet(d: DraftSet, tiles): TableSet | null {
const ts = d.tileIds.map(id => tiles[id]);
if (ts.length < 3) return null;
const nums = ts.filter(t => t.kind === 'num');
const jokers = ts.length - nums.length;
const asGroup = () => {
if (ts.length > 4) return null;
if (new Set(nums.map(t => t.n)).size !== 1) return null;
if (new Set(nums.map(t => t.color)).size !== nums.length) return null; // 색 중복 금지
return /* kind:'group', jokerAs: {n, color:null} */;
};
const asRun = () => {
if (ts.length > 13) return null;
if (new Set(nums.map(t => t.color)).size !== 1) return null;
// (a) 클라이언트 순서 그대로 검사: 첫 숫자 타일의 index i, 값 v → start = v - i
// 모든 숫자 타일 k에 대해 n == start + k, 1 <= start, start+len-1 <= 13 이면 그 순서 채택
// (b) 아니면 정렬: 숫자 중복이면 null. span = max-min+1. gaps = span - nums.length
// gaps > jokers 면 null. extra = jokers - gaps.
// 위쪽으로 min(extra, 13-max)개, 나머지는 아래쪽(min-1 이상 남아야 함). 불가능하면 null
return /* kind:'run', 오름차순 tileIds, jokerAs 각 위치 값 */;
};
const g = asGroup(), r = asRun();
if (g && r) return d.as === 'group' ? g : r;
return g ?? r;
}
```
- 계산량: 테이블은 최대 106타일이고 세트 하나마다 O(k log k)이므로 매 검증이 1ms 미만이다.
`commit` 성공 시 적용
- `table = normalized`(세트 ID는 기존 세트와 타일이 가장 많이 겹치는 것을 이어받아 애니메이션을 자연스럽게 한다).
- 랙에서 `fromRack`을 뺀다. 첫 등록이면 `hasMelded = true`로 바꾼다. `consecutivePassesWithEmptyPool = 0`.
- 랙이 비면 게임 종료(`rackEmpty`). 아니면 다음 플레이어로 넘긴다.
`reason` 예: "첫 등록은 30점 이상이어야 해요 (지금 21점)", "첫 등록 전에는 테이블의 타일을 옮길 수 없어요", "테이블의 타일은 내 받침대로 가져올 수 없어요", "빨간 표시된 조합을 고쳐 주세요".
### 8.3 공개/비공개 정보 (view)
- 모두 공개(관전자 포함): 확정 테이블(`table`), 각 플레이어 랙 타일 수, 첫 등록 여부, 풀 남은 수, 현재 턴, 마감 시각, 매치 점수.
- 본인만: 자기 랙 타일 전체.
- 초안
- 현재 턴 플레이어는 자기 초안 전체와 각 세트의 유효 여부(실시간 검증 결과: 초록/빨강), 현재 첫 등록 점수 합계를 본다.
- 다른 사람과 관전자는 `showDraftToOthers = off`(기본)이면 "생각 중" 표시와 초안 변경 횟수만 받는다. `on`이면 초안 테이블 전체를 받는다. 이 경우 랙에서 꺼낸 타일이 보이는데, 실제 게임에서도 테이블에 올린 타일은 모두가 보므로 허용한다. 되돌려도 이미 본 정보는 남는다는 점을 옵션 설명에 적는다.
- 절대 보내지 않는 것: 풀 순서, 남의 랙, RNG 상태. 뽑기 이벤트는 본인에게만 타일 내용을 주고, 다른 사람에게는 "1개 뽑음"만 준다. 시간 초과 벌칙으로 되돌아간 타일 목록도 본인에게만 준다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `setup`: 선 정하기 시뮬레이션(뽑기와 재뽑기), 106타일 섞기, 분배.
- 매치의 다음 판 `setup`: 새로 섞고 선을 다시 정한다(공식).
- 그 외 진행 중에는 RNG를 쓰지 않는다(뽑기는 섞인 풀의 앞에서부터).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline = turnStartedAt + turnSeconds*1000`.
- `onTimeout(state, p)`는 내부 액션 `timeout`을 돌려준다. `apply`에서 5.7의 규칙대로 처리한다.
- 초안 변경 없음 → 1개 뽑기 또는 넘기기.
- 유효 초안 → `commit` 또는 되돌리고 1개 뽑기.
- 무효 초안 → 되돌리고 `invalidTimeoutPenalty`개 뽑기(풀에 남은 만큼만).
- 연결이 끊겨도 초안은 서버에 남는다. 다시 들어오면 그대로 이어서 한다. 타이머는 멈추지 않는다.
- 끊긴 플레이어가 계속 돌아오지 않으면(서버 정책: 연속 3턴 타임아웃) 이후 그 플레이어의 턴은 즉시 `draw`로 처리한다. 엔진은 `deadline`을 짧게 돌려주기만 하면 된다.
### 8.6 종료 조건과 결과(GameResult)
- 누군가 `commit`으로 랙을 비움 → `rackEmpty`.
- 풀이 빈 상태에서 연속 넘기기 수가 진행 인원 수에 도달 → `stalemate`.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; score: number; rackValue: number; tilesLeft: number }[];
winner: PlayerId[];
endReason: 'rackEmpty' | 'stalemate';
match?: { gameNo: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number>; finished: boolean };
summary: string; // "영희 님이 타일을 모두 내려놓았어요! (+60점)"
}
```
## 9. UI/UX
- 모바일 세로 화면
- 위: 상대 정보(이름, 남은 타일 수, 첫 등록 표시, 타이머 링).
- 가운데: 테이블. 세트를 줄 단위로 놓고 핀치 줌과 스크롤을 지원한다. 세트 사이에 "새 세트" 빈 칸이 있다.
- 아래: 내 랙 2줄. 타일 최소 44×60px이고 탭 영역은 48px 이상.
- 하단 고정 버튼: [정렬: 색별 | 숫자별] [되돌리기] [처음으로] [뽑기 / 완료]
- PC: 테이블이 넓고 랙은 아래 1~2줄이다.
- 조작
- 드래그 앤 드롭: 랙 ↔ 테이블, 테이블 세트 ↔ 세트, 세트 끝·중간에 끼우기, 세트 바깥에 놓으면 새 세트.
- 탭 두 번 방식: 타일을 탭해서 선택하고 놓을 곳을 탭한다. 드래그가 어려운 노년층을 위한 방식이고, 둘 다 지원한다.
- 여러 개 선택: 길게 눌러 여러 타일을 고르고 한꺼번에 옮긴다.
- 자동 정렬
- "색별": 색(검정·빨강·파랑·주황) 다음 숫자 순.
- "숫자별": 숫자 다음 색 순. 조커는 맨 끝.
- 랙 배치 순서는 클라이언트 로컬 상태이고, 서버는 순서를 저장하지 않는다.
- 실시간 검증 표시: 초안의 각 세트에 초록 테두리(유효) 또는 빨강 테두리(무효)와 짧은 이유("같은 색이 두 개예요")를 보여 준다. 첫 등록 전에는 "첫 등록: 21 / 30점" 진행 막대를 보여 준다.
- "완료" 버튼은 초안이 유효할 때만 강조색으로 바뀐다(무효일 때도 누를 수는 있고, 누르면 이유를 보여 준다).
- 힌트(`hints`): "도와줘" 버튼을 누르면 랙에서 바로 낼 수 있는 새 세트 1개를 하이라이트한다. 재배치 탐색은 하지 않는다(계산량 제한). 클라이언트에서 자기 랙 정보만으로 계산하므로 서버 비공개 정보가 필요 없다.
- 애니메이션: 타일 이동 200ms, 세트 확정 때 반짝임, 다른 사람의 확정 때 바뀐 세트를 1초 동안 강조해서 무엇이 바뀌었는지 보이게 한다.
- 색 구분: 숫자 아래에 색별 모양(검정 ●, 빨강 ♥, 파랑 ◆, 주황 ★)을 넣어 색약 사용자도 구별할 수 있게 한다.
- 초보 도움말: 그룹과 런 그림 예시, 재배치 예시 애니메이션 4종(공식 규칙서의 예와 같은 상황).
## 10. 테스트 체크리스트
- [ ] 셋업: 106타일(숫자 104 + 조커 2), 각자 14개, 풀 크기(2인 78 / 3인 64 / 4인 50). 같은 시드면 같은 결과다.
- [ ] 세트 판정: [빨강12, 빨강13, 빨강1]은 무효. [파랑5, 빨강5, 파랑5]는 무효(색 중복). 5개짜리 그룹은 무효. [빨강1, 빨강2]는 무효(2개).
- [ ] 조커 해석: [조커, 검정9, 검정10]을 이 순서로 보내면 8-9-10(27점)이라 첫 등록이 거부된다. [검정9, 검정10, 조커]로 보내면 9-10-11(30점)이라 허용된다.
- [ ] [빨강12, 빨강13, 조커]는 위쪽으로 붙일 수 없으므로 11-12-13으로 해석된다(유효).
- [ ] 첫 등록 전 플레이어가 테이블 세트에 랙 타일을 붙이면 거부된다. 테이블 세트를 쪼개는 초안은 `setDraft`(C3)에서 거부된다.
- [ ] 재배치: 테이블 [빨강4..8] + 랙 빨강6 → [빨강4,5,6] [빨강6,7,8] 확정이 성공한다.
- [ ] 조커 회수: 테이블 [파랑5, 조커, 파랑7] + 랙 [파랑6, 검정3, 빨강3] → [파랑5,6,7] [검정3, 빨강3, 조커] 성공. 조커가 테이블에서 사라지는(랙으로 가는) 초안은 C2로 거부된다.
- [ ] 랙 타일 없이 재배치만 하고 `commit`하면 "랙에서 타일을 1개 이상 내야 해요"로 거부된다.
- [ ] 시간 초과: 무효 초안 상태에서 마감이 지나면 테이블이 턴 시작 상태로 복구되고 랙도 원래대로 돌아온 뒤 3개를 뽑는다. 풀에 2개만 있으면 2개만 뽑는다.
- [ ] 시간 초과: 유효 초안이면(`commit` 옵션) 자동 확정된다. 변경이 없으면 1개를 뽑는다.
- [ ] 되돌리기: `setDraft` 5회 후 `undoDraft` 2회 → 3번째 초안 상태. `resetDraft` → 턴 시작 상태. 재접속해도 초안이 유지된다.
- [ ] 점수: 4인 예시(B -15, C 조커 -30, D -15, A +60). 교착 종료 예시(A +32, B -7, C -25).
- [ ] 교착 종료: 풀이 빈 3인 게임에서 3명이 연속 넘기면 종료된다. 중간에 누가 `commit`하면 카운트가 0으로 초기화된다.
- [ ] 정보 유출: 관전자와 상대 view에 남의 랙 타일 ID·숫자, 풀 내용이 없다. `showDraftToOthers=off`이면 초안 타일도 없다. 뽑기 이벤트에 타일 내용이 본인에게만 있다.
- [ ] 동시성: 같은 `commit` 패킷이 두 번 오면 두 번째는 "차례가 아니에요"로 거부된다. 턴이 넘어간 뒤 도착한 이전 턴 `setDraft`도 거부된다.
## 11. 참고 자료
- Rummikub 공식 영문 규칙서(2600-English): https://rummikub.com/wp-content/uploads/2019/12/2600-English-1.pdf (106타일, 14개 분배, 첫 등록 30점, 조커 회수, 1분 제한, 미완성 시 3개 벌칙, 풀 소진 처리, 점수 계산, 조커 30점)
- Wikipedia "Rummikub": https://en.wikipedia.org/wiki/Rummikub
- 나무위키 "루미큐브": https://namu.wiki/w/루미큐브 (조커 감점 30점 공식 / 50점 대회 룰, 2~4명)
## 12. 메모 (상표·법적 주의 등)
- "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다.
- 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기".
- 문서와 코드 ID(`rummikub`)는 내부용이다. 외부 노출이 걱정되면 `number-tiles` 같은 중립 ID로 바꾸는 것을 검토한다.
- 승리 시 "루미큐브!" 외침 대신 "다 냈다!" 같은 일반 문구를 쓴다.
- 타일 디자인(둥근 숫자, 웃는 얼굴 조커 등 원작 고유 그래픽)을 따라 하지 않는다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 규칙서 문장과 그림은 보호 대상이므로 직접 작성한다.
- 돈, 칩을 쓰지 않는다. 점수는 방 안 결과 표시용이다.

515
docs/games/seotda.md Normal file
View File

@@ -0,0 +1,515 @@
# 섯다 (`seotda`)
> 마일스톤: M3 · 인원: 최소 2 ~ 최대 10명 (3장 섯다는 최대 6명) · 예상 시간: 약 15~25분 (20판 기준) · 난이도: 쉬움~보통
## 1. 개요
- 화투 20장(1~10월 각 2장)으로 각자 2장의 조합(족보)을 겨루는 한국 전통 베팅 카드 게임이다. 명절·모임에서 고스톱과 함께 가장 널리 알려진 화투 놀이이고, "광땡", "장땡", "땡잡이", "구사" 같은 용어는 일상어처럼 쓰인다.
- 칩은 방 안에서만 쓰는 가상 칩이다. 돈·유료 재화·환전·양도·방 간 이월이 없다(12장).
- 인원 근거: 전통 규칙에 고정된 인원 상한은 없고, 덱 20장이 물리적 상한을 정한다. 2장 섯다는 1인 2장이므로 최대 10명, 3장 섯다는 1인 3장이므로 최대 6명(18장). 최소 2명. 실제로는 4~6명이 가장 흔하다.
- 베팅 방식(삥/콜/따당/하프/체크/다이)과 사이드 팟·리바이는 포커 문서(`poker`)의 한국식 판돈 베팅 공용 모듈을 그대로 쓴다. 이 문서에도 필요한 정의를 모두 다시 적는다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `cardMode` | 2장 섯다 / 3장 섯다(3장 받아 2장 선택) | `two` / `three` | `two` |
| `dealMode` | 2장 섯다 분배 방식 | `twoRounds`(1장 받고 베팅, 1장 더 받고 베팅) / `oneRound`(2장 한 번에 받고 베팅 1회) | `twoRounds` |
| `startingChips` | 시작 칩 | 500 / 1000 / 2000 / 5000 | 1000 |
| `baseUnit` | 기본 단위 = 학교(참가비, 앤티) = 삥 금액 | 2 / 10 / 20 / 50 | 10 |
| `maxRaise` | 허용하는 최대 레이즈 | `half`(하프까지) / `full`(풀까지) | `half` |
| `endCondition` | 게임 종료 방식 | `hands`(정해진 판 수) / `lastStanding`(최후 1인) | `hands` |
| `handLimit` | `hands`일 때 총 판 수(재경기는 판 수에 포함하지 않음) | 10 / 20 / 30 / 50 | 20 |
| `schoolUpEvery` | N판마다 학교 금액 2배(0=안 올림) | 0 / 5 / 10 / 15 | 0 |
| `rebuy` | 리바이(칩 다시 받기) | `off` / `limited` / `unlimited` | `unlimited` |
| `rebuyLimit` | `limited`일 때 1인당 횟수 | 1 / 2 / 3 | 1 |
| `turnSeconds` | 행동 제한 시간(초) | 15 / 20 / 30 / 60 | 20 |
| `dealerRule` | 다음 판의 선 | `winner`(직전 판 승자) / `rotate`(시계 방향) | `winner` |
| `gwangTtaeng` | 광땡 종류 | `all`(38·18·13광땡) / `only38`(38광땡만) | `all` |
| `ttaengjabi` | 땡잡이(3·7) | `off` / `strict`(3월 광 + 7월 열끗) / `loose`(3월 아무 패 + 7월 아무 패) | `strict` |
| `amhaeng` | 암행어사(4월 열끗 + 7월 열끗) | 켬 / 끔 | 켬 |
| `gusa` | 구사 재경기(4·9, "49파토") | 켬 / 끔 | 켬 |
| `mungGusa` | 멍텅구리구사(4월 열끗 + 9월 열끗) 재경기 범위 | `off`(일반 구사로 취급) / `upTo8`(8땡 이하 재경기) / `upTo9`(9땡 이하) / `upToJang`(장땡 이하) | `upTo8` |
| `tieRule` | 같은 족보 동점 처리 | `rematch`(동점자끼리 재경기) / `split`(나눠 가짐) | `rematch` |
| `rejoinCost` | 구사 재경기 때 다이한 사람의 재참가 | `none`(불가) / `half`(현재 판돈의 절반을 내고 참가) / `full`(현재 판돈만큼 내고 참가) | `none` |
| `hints` | 내 족보 이름 표시, 족보표 강조 | 켬 / 끔 | 켬 |
옵션 검증: `cardMode=three`이면 참가자 6명 이하, `dealMode`는 무시(3장 섯다 고유 흐름). `baseUnit × 20 ≤ startingChips`. `gusa=off`이면 `mungGusa`도 무시(4·9는 모두 3끗).
## 3. 구성물
화투 20장. 각 월의 "특수 패" 1장 + "일반 패" 1장:
| 월 | 꽃(그림 소재) | 특수 패 | 일반 패 |
|---|---|---|---|
| 1 | 송학(소나무·학) | 광 | 띠 |
| 2 | 매조(매화·꾀꼬리) | 열끗 | 띠 |
| 3 | 벚꽃 | 광 | 띠 |
| 4 | 흑싸리(등나무·두견새) | 열끗 | 띠 |
| 5 | 난초(창포·다리) | 열끗 | 띠 |
| 6 | 모란(나비) | 열끗 | 띠 |
| 7 | 홍싸리(싸리·멧돼지) | 열끗 | 띠 |
| 8 | 공산(억새·보름달) | 광 | 일반(기러기 그림) |
| 9 | 국진(국화·술잔) | 열끗 | 띠 |
| 10 | 단풍(사슴) | 열끗 | 띠 |
- 규칙상 의미가 있는 특수 패는 1·3·8월 광, 4·7·9월 열끗뿐이다. 나머지 열끗·띠·일반 패는 숫자(월)로만 쓰인다.
- 가상 칩, 선 표시, 판돈 영역.
- 카드 그림은 직접 그린 단순 SVG(9장, 12장 참고).
## 4. 준비(셋업)
1. 참가자를 좌석에 앉히고 좌석 순서를 `rng`로 섞는다.
2. 모든 플레이어 칩 = `startingChips`.
3. 첫 판의 선을 `rng`로 고른다.
4. 첫 판을 시작한다.
## 5. 진행 규칙
### 5.1 한 판의 흐름 (2장 섯다, `dealMode=twoRounds`)
1. 학교: 칩이 있는 참가자 전원이 `baseUnit`을 판돈에 낸다(칩이 모자라면 전부 내고 올인).
2. 기리(패 떼기): 실제 섞기는 서버 `rng`가 한다. 화면에서는 선의 오른쪽 사람이 패를 떼는 짧은 연출만 보여 준다(규칙 영향 없음).
3. 선부터 시계 방향으로 1장씩(비공개) 나눠 준다.
4. 1차 베팅(선부터 시계 방향, 5.4).
5. 남은 사람에게 1장씩 더 나눠 준다(비공개).
6. 2차 베팅(선이 다이했으면 선 다음 좌석의 남은 사람부터).
7. 2명 이상 남으면 쇼다운: 남은 사람의 패를 동시에 공개하고 6장 규칙으로 판정.
8. 도중에 1명만 남으면 그 사람이 판돈을 가져가고 패는 공개하지 않는다.
9. 판 종료 결과를 4초간 보여 준 뒤 다음 판(리바이·탈락·종료 확인 포함).
`dealMode=oneRound`: 3~6 대신 2장을 한 번에 나눠 주고 베팅 1회 후 쇼다운.
### 5.2 3장 섯다 (`cardMode=three`)
1. 학교 → 2장씩 나눠 줌(비공개) → 1차 베팅.
2. 남은 사람에게 1장 더(비공개, 손에 3장).
3. 선택 단계(동시 진행): 각자 3장 중 2장을 고른다. 고르지 않은 1장은 뒷면으로 버리며 끝까지 아무에게도 공개하지 않는다.
4. 2차 베팅 → 쇼다운(고른 2장으로 판정).
- 선택은 2차 베팅 전에 확정되므로 마지막 베팅은 확정된 패로 한다.
### 5.3 선(先)
- 선 = 그 판의 카드를 받는 첫 사람이자 각 베팅 라운드의 첫 행동자.
- 다음 판의 선: `winner`면 직전 판에서 판돈(메인 팟)을 가져간 사람. 나눠 가졌으면 그중 직전 선에서 시계 방향으로 가장 가까운 사람. `rotate`면 직전 선 다음 좌석의 참가자.
- 재경기의 선: 직전 선이 재경기 참가자면 그대로, 아니면 직전 선 다음 좌석부터 시계 방향으로 첫 재경기 참가자.
### 5.4 베팅 (한국식 판돈 베팅, 포커 문서 5.2와 동일한 공용 모듈)
용어: `pot` = 현재 판돈 전체(학교 포함), `currentBet` = 이번 라운드 최고 베팅액, `toCall` = `currentBet - 내 이번 라운드 베팅액`, `opened` = 이번 라운드에 누군가 칩을 걸었는가.
| 행동 | 조건 | 낼 금액 |
|---|---|---|
| 체크 | `opened=false` | 0 (돈을 걸지 않고 넘김) |
| 삥 | `opened=false` | `baseUnit` (기본 단위만큼) |
| 콜 | `toCall > 0` | `toCall` (앞사람만큼 맞춤) |
| 따당 | `toCall > 0`, 레이즈 가능 | `2 × toCall` (앞사람이 건 금액의 2배) |
| 쿼터 | 레이즈 가능 | `toCall + floor((pot + toCall) / 4)` |
| 하프 | 레이즈 가능 | `toCall + floor((pot + toCall) / 2)` |
| 풀 | 레이즈 가능, `maxRaise=full` | `toCall + (pot + toCall)` |
| 다이 | 자기 차례 언제나 | 0, 이번 판 포기(낸 돈은 판돈에 남음) |
- 레이즈 가능 조건: (1) 이번 라운드에서 아직 체크·콜·삥·레이즈를 하지 않았다(한 라운드에 1인 1회, 체크·콜 후 레이즈 금지), (2) 낼 금액이 `toCall`보다 크다. `toCall`을 뺀 추가분이 `baseUnit`보다 작으면 `baseUnit`으로 올린다.
- 첫 행동자가 체크하면 다음 사람도 체크/삥/쿼터/하프(/풀)/다이 중 고를 수 있다. 전원 체크하면 다음 단계로 넘어간다.
- 라운드 종료: 다이·올인하지 않은 모든 사람이 이번 라운드에 한 번 이상 행동했고 모두 `currentBet`을 맞췄을 때.
- 올인: 낼 금액이 스택 이상이면 전부 내고 올인. `toCall`보다 적게 올인하면 사이드 팟이 생긴다. 올인한 사람은 더 행동하지 않고 남은 카드는 받는다.
- 사이드 팟 계산은 포커 문서 5.5와 같다: 올인 금액 레벨별로 팟을 나누고, 각 팟의 자격자는 그 레벨까지 낸 다이하지 않은 사람. 아무도 맞추지 못한 초과분은 돌려준다.
예(3명, 학교 10, 기본 단위 10): 학교 후 판돈 30. 선 A 삥 10(판돈 40) → B 하프: 콜 10 + (50의 절반 25) = 35(판돈 75) → C 따당: `toCall=35` → 70(판돈 145) → A는 이미 삥을 했으므로 콜 60 또는 다이 → A 콜(판돈 205) → B 콜 35(판돈 240). 세 사람 모두 70을 맞춰 종료. 검산: 30 + 70 × 3 = 240.
### 5.5 재경기(구사·동점)
재경기가 결정되면(6.3) 그 판의 판돈은 그대로 두고 새 판을 진행한다.
1. 참가자:
- 구사 재경기: 쇼다운에 남아 있던 사람 전원(구사를 든 사람 포함). `rejoinCost`가 `none`이 아니면 그 판에서 다이한 사람(탈락자 제외)에게 재참가 여부를 묻는다(동시 진행, 15초, 시간 초과는 불참). 재참가하면 `half`=현재 판돈의 절반(내림), `full`=현재 판돈만큼을 판돈에 낸다. 칩이 모자라면 재참가할 수 없다.
- 동점 재경기(`tieRule=rematch`): 동점으로 최고패를 가진 사람만. 진 사람은 빠진다.
2. 덱 20장을 새로 섞고, 학교는 다시 내지 않는다. 카드 분배·베팅·쇼다운은 5.1/5.2와 같다(새 베팅은 기존 판돈 위에 더해진다).
3. 이전 판에서 올인한 사람은 계속 올인 상태로 카드만 받는다.
4. 사이드 팟이 있었던 경우: 각 팟을 그 팟의 자격자끼리 따로 판정한다. 재경기가 필요 없는 팟(자격자끼리 승자가 하나로 정해진 팟)은 재경기 전에 바로 지급하고, 재경기가 필요한 팟만 남긴다. 재경기 참가자는 남은 팟들의 재경기 참가자 합집합이며, 재경기 후 각 남은 팟을 다시 그 팟 자격자(재참가자 포함)끼리 판정한다.
5. 같은 판에서 재경기가 연속 3번 일어나면 4번째 재경기 대신 그 팟을 재경기 참가자끼리 균등 분할한다(무한 반복 방지).
6. 재경기는 `handLimit`의 판 수에 포함하지 않는다(원래 판의 연장).
### 5.6 칩이 떨어졌을 때
- 판(재경기 포함)이 완전히 끝난 뒤 칩 0인 사람은 `busted`.
- `rebuy=off`: 탈락(관전 화면, 결과 순위에는 남음).
- `rebuy=limited/unlimited`: "칩 다시 받기 / 그만하기"(15초, 시간 초과는 그만하기). 받으면 `startingChips`로 채움. `hands` 방식에서 마지막 판 직후에는 묻지 않는다.
- 칩이 학교 금액보다 적으면 가진 만큼 내고 올인 상태로 참가한다.
### 5.7 학교 상승과 자리 비움
- `schoolUpEvery = N > 0`이면 N판마다 `baseUnit` 2배(상한 `startingChips / 5`).
- 연속 2번 시간 초과하면 `away` 표시, 이후 그 사람의 제한 시간 5초. 직접 행동하면 해제.
- 방을 나간 사람(`left`)은 매 판 학교를 낸 뒤 자동 다이. 칩이 0이 되면 탈락.
## 6. 승패와 점수 계산
### 6.1 족보 (높은 순)
끗 계산: 두 장의 월을 더한 값의 일의 자리. 예: 7+8=15 → 5끗, 9+10=19 → 9끗(갑오), 2+8=10 → 0끗(망통).
| 순위 | 이름 | 구성 | 비교값(구현용) |
|---|---|---|---|
| 1 | 38광땡 | 3월 광 + 8월 광 | 1000 |
| 2 | 18광땡 | 1월 광 + 8월 광 (`gwangTtaeng=all`) | 990 |
| 3 | 13광땡 | 1월 광 + 3월 광 (`gwangTtaeng=all`) | 980 |
| 4 | 장땡(10땡) | 10월 + 10월 | 910 |
| 5 | 9땡 ~ 2땡 | 같은 월 두 장 | 909 ~ 902 |
| 6 | 삥땡(1땡) | 1월 + 1월 | 901 |
| 7 | 알리 | 1월 + 2월 | 806 |
| 8 | 독사 | 1월 + 4월 | 805 |
| 9 | 구삥 | 1월 + 9월 | 804 |
| 10 | 장삥 | 1월 + 10월 | 803 |
| 11 | 장사 | 4월 + 10월 | 802 |
| 12 | 세륙 | 4월 + 6월 | 801 |
| 13 | 갑오(9끗) | 끗 9 | 9 |
| 14 | 8끗 ~ 1끗 | 끗 8 ~ 1 | 8 ~ 1 |
| 15 | 망통(0끗) | 끗 0 | 0 |
- 위 표에서 먼저 맞는 줄을 쓴다(예: 1월 광 + 3월 광은 `all`이면 13광땡, `only38`이면 일반 조합으로 1+3=4끗).
- 광 조합이 아니면 광땡이 아니다: 1월 띠 + 8월 광 = 9끗(갑오), 3월 띠 + 8월 광 = 1끗.
- 같은 값 = 동점(무늬 같은 추가 비교 없음). 땡과 광땡은 같은 패가 두 사람에게 갈 수 없으므로 동점이 생기지 않고, 알리~세륙과 끗은 동점이 생길 수 있다(예: 1월 광+2월 띠 vs 1월 띠+2월 열끗 → 둘 다 알리).
### 6.2 특수패 (옵션)
특수패는 위 표의 "일반값"을 가지면서, 특정 상대를 만났을 때만 특별한 효과를 낸다.
| 특수패 | 구성 | 효과 | 일반값(효과가 없을 때) |
|---|---|---|---|
| 땡잡이 | `strict`: 3월 광 + 7월 열끗 / `loose`: 3월 + 7월 아무 패 | 쇼다운 최고 일반패가 1땡~9땡이면 승리(장땡·광땡에는 무효) | 망통(0) |
| 암행어사 | 4월 열끗 + 7월 열끗 | 쇼다운 최고 일반패가 13광땡 또는 18광땡이면 승리(38광땡에는 무효) | 1끗 |
| 구사 | 4월 + 9월(멍텅구리구사 조합 제외) | 다른 사람의 최고 일반패가 알리 이하(값 ≤ 806)이고 구사 쪽이 이기지 못하면 재경기 | 3끗 |
| 멍텅구리구사 | 4월 열끗 + 9월 열끗 | 다른 사람의 최고 일반패가 `upTo8`=8땡 이하(≤ 908) / `upTo9`=9땡 이하(≤ 909) / `upToJang`=장땡 이하(≤ 910)이고 멍구사 쪽이 이기지 못하면 재경기. 광땡은 언제나 이김 | 3끗 |
- 땡잡이(`strict`)와 암행어사는 7월 열끗을 함께 쓰므로 한 판에 동시에 나올 수 없다. `loose` 땡잡이는 한 판에 2명까지 나올 수 있다.
- 옵션이 꺼진 특수패는 일반값만 갖는다.
### 6.3 쇼다운 판정 알고리즘
입력: 쇼다운 참가자 집합 S(2명 이상), 각자의 2장.
1. 각자의 일반값 v(p)를 6.1로 구한다(특수패도 일반값).
2. `Vmax = max v(p)`.
3. 잠정 승자 W:
- 땡잡이가 켜져 있고 S에 땡잡이가 있고 `901 ≤ Vmax ≤ 909`이면 W = 땡잡이를 든 사람(들).
- 아니면, 암행어사가 켜져 있고 S에 암행어사가 있고 `Vmax ∈ {980, 990}`이면 W = 암행어사를 든 사람.
- 아니면 W = `v(p) = Vmax`인 사람들.
4. 구사 재경기 검사(`gusa` 켬): 구사 또는 멍텅구리구사를 든 사람 h 중 W에 속하지 않은 사람이 있고, `max{ v(q) : q ∈ S, q ≠ h }`가 h의 기준값 이하이면 → 구사 재경기(5.5). 기준값: 구사 806, 멍텅구리구사는 `mungGusa`에 따라 908/909/910(`off`면 일반 구사로 보아 806).
5. 재경기가 아니고 W가 1명이면 그 사람이 승리.
6. W가 2명 이상이면 `tieRule`: `rematch`면 W끼리 재경기, `split`이면 균등 분할(나머지 칩은 선에서 시계 방향으로 가장 가까운 승자부터 1칩씩).
이 판정은 팟마다 그 팟의 자격자(S ∩ 자격자)로 따로 수행한다(5.5의 4).
설계 결정(사용자 확인 필요): 원 규칙은 "구사는 상대 최고패가 알리 이하면 재경기"이지만, 구사(3끗)가 그대로 이기는 경우(상대가 모두 2끗 이하)까지 재경기하면 구사를 든 쪽이 손해다. 그래서 구사 쪽이 이미 이기는 경우는 재경기 없이 승리로 처리한다(4단계의 "W에 속하지 않은").
### 6.4 판정 예시
1. A 38광땡, B 암행어사 → A 승(암행어사는 38광땡에 무효, B는 1끗).
2. A 13광땡, B 암행어사, C 장땡 → 일반값 A=980, B=1, C=910, 최고 일반값 980(13광땡) → 암행어사 발동 → B 승.
3. A 9땡, B 땡잡이(3광+7열끗), C 알리 → 최고 일반값 909(9땡) → 땡잡이 발동 → B 승.
4. A 장땡, B 땡잡이 → 최고 일반값 910 → 무효 → A 승.
5. A 땡잡이, B 알리 → 최고 일반값 806 → 무효, A는 망통 → B 승.
6. A 구사(4월 띠 + 9월 열끗), B 알리 → W={B}, A는 W 밖, 상대 최고 806 ≤ 806 → 구사 재경기.
7. A 구사, B 1땡 → 901 > 806 → B 승.
8. A 구사, B 2끗, C 망통 → W={A}(3끗 최고) → 재경기 없이 A 승.
9. A 멍텅구리구사(4열끗+9열끗), B 8땡 → `upTo8`: 908 ≤ 908 → 재경기. B가 9땡이면 B 승. `upToJang`이면 장땡까지 재경기.
10. A 5끗(2월+3월), B 5끗(6월+9월) → 동점 → `rematch`면 A·B 재경기, `split`이면 반씩.
11. A 구사, B 5땡, C 땡잡이 → 일반값 A=3, B=905, C=0, 최고 905 → W={C}(땡잡이). 구사 검사: A 기준 806, 상대 최고 905 > 806 → 재경기 아님 → C 승. A가 멍텅구리구사(`upTo8`)였다면 905 ≤ 908 → 재경기.
12. 3장 섯다: 손패 1월 광, 8월 광, 4월 열끗 → 조합 (1광+8광)=18광땡, (1광+4열끗)=독사, (8광+4열끗)=2끗 → 18광땡 선택이 최선.
### 6.5 판돈 지급
- 승자(들)가 해당 팟을 가져간다. 다이한 사람·패배자의 낸 칩은 돌려주지 않는다.
- 사이드 팟은 자격자가 적은 팟부터 판정해 지급하고 마지막에 메인 팟.
### 6.6 게임 종료와 점수
- `endCondition=hands`: `handLimit`판(재경기 제외)이 끝나면 종료. 점수 = `최종 칩 - startingChips × 리바이 횟수`. 점수 높은 순 순위, 같으면 공동 순위.
- `endCondition=lastStanding`: 칩을 가진 사람이 1명 남으면 종료(리바이 결정 대기 중이면 대기). 순위 = 늦게 탈락한 순, 같은 판 동시 탈락은 그 판 시작 칩이 많은 쪽이 위. 300판 도달 시 `hands` 방식 점수로 강제 종료.
- 접속 중이고 칩이 있는 사람이 1명 이하가 되면 즉시 종료.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 하우스 룰 | 옵션 | 기본 |
|---|---|---|
| 1장씩 2번 받기 / 2장 한 번에 | `dealMode` | 1장씩 2번 |
| 3장 받아 2장 고르기 | `cardMode=three` | 2장 |
| 광땡 종류(38만 / 38·18·13) | `gwangTtaeng` | 모두 |
| 땡잡이 엄격(3광+7열끗) / 느슨(3·7 아무 패) / 없음 | `ttaengjabi` | 엄격 |
| 암행어사 | `amhaeng` | 켬 |
| 구사 재경기("49파토") | `gusa` | 켬 |
| 멍텅구리구사 범위(8땡/9땡/장땡까지) | `mungGusa` | 8땡까지 |
| 동점 재경기 / 나눔 | `tieRule` | 재경기 |
| 구사 재경기 때 다이한 사람 재참가 비용 | `rejoinCost` | 재참가 불가 |
| 선 = 이긴 사람 / 회전 | `dealerRule` | 이긴 사람 |
| 풀 베팅 허용 | `maxRaise` | 하프까지 |
| 땡값(땡·광땡으로 이기면 다른 사람에게 추가로 받기), 구사 선택권(구사 쥔 사람이 재경기 여부 결정), 사구 파토 시 판돈 반환 | 미지원(향후) | - |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
// 서버만 보는 전체 상태
type PlayerId = string;
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
interface HwatuCard {
id: number; // 0..19 = (month-1)*2 + (special ? 0 : 1)
month: Month;
kind: 'gwang' | 'yeol' | 'normal'; // 1·3·8월 특수=gwang, 그 외 월 특수=yeol, 나머지 normal
}
interface SeotdaOptions {
cardMode: 'two' | 'three';
dealMode: 'twoRounds' | 'oneRound';
startingChips: 500 | 1000 | 2000 | 5000;
baseUnit: 2 | 10 | 20 | 50;
maxRaise: 'half' | 'full';
endCondition: 'hands' | 'lastStanding';
handLimit: 10 | 20 | 30 | 50;
schoolUpEvery: 0 | 5 | 10 | 15;
rebuy: 'off' | 'limited' | 'unlimited';
rebuyLimit: 1 | 2 | 3;
turnSeconds: 15 | 20 | 30 | 60;
dealerRule: 'winner' | 'rotate';
gwangTtaeng: 'all' | 'only38';
ttaengjabi: 'off' | 'strict' | 'loose';
amhaeng: boolean;
gusa: boolean;
mungGusa: 'off' | 'upTo8' | 'upTo9' | 'upToJang';
tieRule: 'rematch' | 'split';
rejoinCost: 'none' | 'half' | 'full';
hints: boolean;
}
interface SeatState {
id: PlayerId;
seat: number;
stack: number;
rebuys: number;
status: 'playing' | 'busted' | 'eliminated' | 'left';
away: boolean;
timeoutsInRow: number;
eliminatedAtHand: number | null;
stackAtHandStart: number;
}
interface RoundPlayer {
id: PlayerId;
hand: HwatuCard[]; // 받은 카드(2 또는 3장)
chosen: [number, number] | null; // 3장 섯다 선택한 두 카드 id
discarded: HwatuCard | null; // 3장 섯다 버린 카드(영구 비공개)
folded: boolean;
allIn: boolean;
committedTotal: number; // 이번 판(재경기 포함) 총 기여
committedRound: number;
actedThisRound: boolean;
raisedThisRound: boolean;
lockedFromRaise: boolean; // 체크·콜 후 레이즈 금지
ready: boolean;
}
interface Pot { amount: number; eligible: PlayerId[] }
interface BettingRound {
street: number; // 0 = 1차, 1 = 2차
order: PlayerId[]; // 선(또는 선 다음 남은 사람)부터
toAct: PlayerId | null;
currentBet: number;
opened: boolean;
}
interface ShowdownInfo {
hands: { id: PlayerId; cards: HwatuCard[]; name: string; value: number; special: SpecialKind | null }[];
outcome:
| { type: 'win'; potIndex: number; winners: PlayerId[]; reason: 'normal' | 'ttaengjabi' | 'amhaeng' }
| { type: 'split'; potIndex: number; winners: PlayerId[] }
| { type: 'rematch'; potIndex: number; reason: 'gusa' | 'mungGusa' | 'tie'; participants: PlayerId[] };
}
type SpecialKind = 'ttaengjabi' | 'amhaeng' | 'gusa' | 'mungGusa';
interface SeotdaHand {
handNo: number; // 재경기는 같은 handNo
rematchNo: number; // 0 = 본 판, 1..3 = 재경기 차수
phase: 'dealing' | 'betting' | 'choose' | 'rejoin' | 'showdown' | 'handEnd';
dealerSeat: number; // 선
deck: HwatuCard[]; // 남은 덱(비공개)
players: Record<PlayerId, RoundPlayer>; // 이번 (재)경기 참가자
participants: PlayerId[]; // 이번 (재)경기 참가 순서(선부터)
betting: BettingRound | null;
carriedPots: Pot[]; // 재경기로 넘어온 팟(자격자 고정)
pots: Pot[]; // 이번 (재)경기 베팅으로 생긴 팟
rejoinPending: PlayerId[];
showdown: ShowdownInfo[] | null;
deadlineAt: number | null;
}
interface SeotdaState {
options: SeotdaOptions;
rng: RngState;
seats: SeatState[];
baseUnit: number; // 학교 상승 반영
hand: SeotdaHand | null;
handsPlayed: number;
lastWinner: PlayerId | null;
phase: 'playing' | 'rebuy' | 'finished';
rebuyPending: PlayerId[];
rebuyDeadlineAt: number | null;
eliminationSeq: PlayerId[][];
publicLog: PublicLogEntry[]; // 비밀 정보 없음
}
```
엔진 메모: `minPlayers=2`, `maxPlayers=10`(정적). `cardMode=three`의 6명 상한은 `optionsSchema.superRefine`과 `playerRange(options)` 헬퍼로 확인한다. 족보 계산 `evalSeotda(a, b, options) → { value, name, special }`과 판정 `resolve(S, options)`는 순수 함수로 분리해 표 기반 단위 테스트(20장에서 나올 수 있는 190개 조합 전부)를 한다.
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `check` | - | `phase=betting`, 내 차례 | `opened=false` |
| `ping` | - | 내 차례 | `opened=false`, `raisedThisRound=false`, 스택 > 0 |
| `call` | - | 내 차례 | `toCall > 0` |
| `ddadang` | - | 내 차례 | `toCall > 0`, 레이즈 가능 |
| `quarter` / `half` | - | 내 차례 | 레이즈 가능, 낼 금액 > `toCall` |
| `full` | - | 내 차례 | 위 + `maxRaise=full` |
| `die` | - | 내 차례 | 항상 |
| `choose` | `{ cardIds: [number, number] }` | `phase=choose`(3장 섯다), 남은 참가자(동시, 올인 포함) | 두 id가 내 손패에 있고 서로 다름, 아직 미선택 |
| `rejoin` | `{ accept: boolean }` | `phase=rejoin`, `rejoinPending`에 있는 사람 | `accept=true`면 스택 ≥ 재참가 비용 |
| `ready` | - | `phase=handEnd` | 미준비 |
| `rebuy` | `{ accept: boolean }` | `phase=rebuy`, 대상자 | 남은 리바이 횟수 > 0 |
| `leave` | - | 서버가 퇴장 시 주입 | - |
거부 사유 예: "지금은 내 차례가 아니에요", "누가 이미 걸어서 체크할 수 없어요", "이번 라운드에는 더 올릴 수 없어요(콜 또는 다이)", "칩이 모자라 재참가할 수 없어요", "카드 두 장을 골라 주세요".
### 8.3 공개/비공개 정보 (view)
| 정보 | 본인 | 다른 플레이어 | 관전자(`null`) |
|---|---|---|---|
| 내 손패 | 보임 | 장수만 | 장수만 |
| 3장 섯다 선택 결과 | 보임 | 선택 완료 여부만 | 같음 |
| 3장 섯다 버린 카드 | 내 것만 | 영구 비공개 | 영구 비공개 |
| 덱 | 안 보임 | 안 보임 | 안 보임 |
| 쇼다운 패 | 쇼다운 참가자의 2장(선택한 2장) 공개 | 같음 | 같음 |
| 다이한 사람 패 | 내 것만 | 영구 비공개 | 영구 비공개 |
| 다른 사람의 족보 이름 | - | 쇼다운 후에만 | 쇼다운 후에만 |
- 공개: 좌석·스택·상태, 선 표시, 판돈과 사이드 팟, 이번 라운드 베팅액과 마지막 행동, 현재 행동자와 마감 시각, 재경기 여부와 사유, 재경기 차수.
- 비공개 카드 자리는 `{ hidden: true }`만 보낸다. 카드 `id`는 월·종류와 고정 대응하므로 비공개 카드의 `id`도 보내지 않는다.
- `view`는 본인 차례일 때 `legalActions`(가능 행동과 정확한 낼 금액)와, `hints` 켬일 때 본인 족보 이름(예: "알리", "5끗", "구사 - 상대가 알리 이하면 재경기")을 내려 준다.
- 이벤트에도 비공개 카드 값을 넣지 않는다(`{ type: 'dealt', to, count }`). 쇼다운 공개 이벤트에만 카드 값을 넣는다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
1. `setup`: 좌석 섞기, 첫 선.
2. 매 판·매 재경기 시작: 20장 Fisher-Yates 섞기. 분배는 덱 위에서 순서대로.
- 판정, 분할 나머지 칩, 자동 선택은 결정적(난수 없음). 같은 시드 + 같은 액션 열 → 같은 결과.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
`deadline`: 베팅 `turnSeconds`(자리 비움 5초), 3장 선택 `turnSeconds + 10`초(동시), 재참가 15초, `handEnd` 4초, 리바이 15초.
| 단계 | `onTimeout` 자동 행동 |
|---|---|
| 베팅 | `opened=false`면 `check`, 아니면 `die` |
| 3장 선택 | 일반값이 가장 높은 2장 조합. 같으면 특수패(땡잡이·암행어사·구사) 조합, 그것도 같으면 카드 id 합이 작은 조합 |
| 재참가 | `{ accept: false }` |
| `handEnd` | `ready` |
| 리바이 | `{ accept: false }` |
연결이 끊긴 사람의 차례는 일반 마감까지 기다린 뒤 자동 행동한다. 재접속하면 즉시 직접 행동할 수 있다.
### 8.6 종료 조건과 결과(GameResult)
종료 판정은 6.6.
```ts
interface SeotdaResult /* extends GameResult */ {
ranking: {
playerId: PlayerId;
rank: number; // 공동 순위 허용
score: number; // hands: 최종 칩 - startingChips × rebuys / lastStanding: 생존 순서(1위=N)
finalChips: number;
rebuys: number;
eliminatedAtHand: number | null;
}[];
summary: string; // 예: "20판 종료. 1위 영희(+860칩), 최고 족보 38광땡"
stats: {
handsPlayed: number;
rematches: number;
bestHand: { playerId: PlayerId; name: string; handNo: number } | null;
biggestPot: { handNo: number; amount: number; winners: PlayerId[] };
};
}
```
## 9. UI/UX
화면 배치(모바일 세로):
- 위: 상대 플레이어 원형 배치(닉네임, 스택, 이번 라운드 베팅, 카드 뒷면 장수, "다이"/"올인"/"자리 비움" 뱃지, 선 표시).
- 가운데: 판돈 큰 숫자, 재경기 중이면 "재경기 2회차 - 구사" 띠 배너.
- 아래: 내 카드 2~3장을 크게(카드 높이 최소 120px), 그 위에 내 족보 이름 큰 글씨(`hints`), 맨 아래 행동 버튼.
- PC: 오른쪽에 족보표 상시 표시, 판 기록.
조작:
- 버튼(높이 56px 이상): `다이` `체크` `삥` `콜 (+60)` `따당 (+140)` `쿼터` `하프 (+75)` `풀`. 가능한 버튼만 색 강조, 불가능한 버튼은 흐리게(위치 고정으로 오조작 방지). 금액은 서버 `legalActions` 값.
- 3장 섯다 선택: 카드 3장을 누르면 2장이 위로 올라오고, 고른 조합의 족보 이름을 미리 보여 준다. "추천 조합" 버튼(`hints`).
- 쇼다운: 남은 사람 패가 동시에 뒤집히고 족보 이름 표시, 특수패 발동 시 "땡잡이!" "암행어사 출두!" 큰 글자 연출(1초), 재경기 시 "구사 재경기" 연출.
- 족보표 버튼: 6.1 표를 그림 카드와 함께 표시, 내 현재 족보 줄 강조, 특수패는 켜진 옵션만 표시.
카드 그림(자체 제작 SVG):
- 화투 고유 이미지(시판 화투 인쇄 디자인, 특정 게임사 화투 그림)를 복제하지 않는다. 직접 그린 단순 SVG를 쓴다.
- 구성: 세로 2:3 카드, 왼쪽 위에 큰 월 숫자(1~10, 고대비), 월별 배경색과 단순화한 식물 아이콘(소나무, 매화, 벚꽃, 등나무, 창포, 모란, 싸리, 억새와 달, 국화, 단풍), 특수 패 표시:
- 광: 빨간 원 안에 "광" 글자.
- 열끗: 동물 실루엣(꾀꼬리, 두견새, 다리, 나비, 멧돼지, 술잔, 사슴) + 작은 "열" 뱃지. 4·7·9월 열끗은 특수패 재료이므로 뱃지를 더 눈에 띄게.
- 일반 패: 띠(가로 리본) 또는 잎 무늬.
- 색만으로 구분하지 않고 글자(월 숫자, "광", "열")를 반드시 함께 넣는다. 큰 글씨 모드에서는 월 숫자만 크게 보이는 단순 카드로 전환.
초보자 도움:
- "규칙 보기": 진행 순서, 끗 계산법, 족보표, 특수패 설명, 베팅 용어(학교·삥·따당·하프·다이).
- 첫 판 말풍선 튜토리얼. 내 차례 진동/소리, 남은 시간 게이지.
- 힌트: "지금 패는 5끗이에요. 중간 정도 패예요." 같은 쉬운 문장(강도 3단계: 높음/중간/낮음 색).
## 10. 테스트 체크리스트
- [ ] 족보 전수: 20장에서 나오는 190개 2장 조합의 이름·값이 6.1/6.2 표와 일치(옵션 조합 `gwangTtaeng`, `ttaengjabi`, `amhaeng`, `gusa`, `mungGusa`별 스냅샷).
- [ ] 1월 광 + 3월 광: `all`이면 13광땡, `only38`이면 4끗. 1월 띠 + 8월 광은 갑오.
- [ ] 6.4 예시 1~12가 모두 기대 결과와 일치.
- [ ] 땡잡이 `strict`: 3월 띠 + 7월 열끗은 일반 망통이며 9땡을 잡지 못함. `loose`면 잡음.
- [ ] `loose` 땡잡이 2명 + 5땡 → 땡잡이 두 사람 동점 처리(`tieRule`).
- [ ] 구사 재경기: 판돈 유지, 학교 재징수 없음, 참가자는 쇼다운 생존자만(`rejoinCost=none`).
- [ ] 구사 재경기 `rejoinCost=half`: 다이한 사람이 판돈 절반(내림)을 내고 참가, 칩이 모자라면 거부, 시간 초과 시 불참.
- [ ] 동점 재경기 3연속 후 4번째에는 균등 분할, 나머지 칩은 선에서 가까운 승자.
- [ ] 사이드 팟 + 재경기: A 올인(메인만), 메인에서 A·B 동점, 사이드(B·C)에서 B 단독 승 → 사이드는 즉시 B에게, 메인만 A·B 재경기.
- [ ] 베팅: 5.4 예시(학교 30 → 삥 → 하프 35 → 따당 70 → 콜 → 콜) 최종 판돈 240.
- [ ] 체크·콜 후 같은 라운드 레이즈 거부, `maxRaise=half`에서 `full` 거부.
- [ ] 2차 베팅 첫 행동자: 선이 1차에서 다이했으면 선 다음 좌석의 남은 사람.
- [ ] `dealMode=oneRound`: 베팅 1회 후 바로 쇼다운.
- [ ] 3장 섯다: 선택 전 다른 사람 `view`에 선택 결과 없음, 버린 카드는 쇼다운 후에도 어떤 `view`·이벤트에도 없음.
- [ ] 3장 섯다 시간 초과 자동 선택: 1광·8광·4열끗 → 18광땡 조합 선택.
- [ ] 시간 초과: `opened=false`면 체크, 아니면 다이. 2연속 → `away`, 마감 5초.
- [ ] 연결 끊김 상태로 선 차례 → 마감 후 자동 체크/다이, 재접속 후 정상 행동.
- [ ] 비밀 누출: 매 단계 모든 시청자(플레이어·관전자) `view`와 이벤트 JSON에 남의 비공개 카드의 월·종류·id가 없음(무작위 시드 1,000판 속성 테스트).
- [ ] 다이로 끝난 판: 승자 패도 공개되지 않음.
- [ ] 리바이·탈락·점수: `hands` 점수 = 칩 - 시작 칩 × 리바이, `lastStanding` 동시 탈락 순위.
- [ ] 칩 보존 불변식: `Σ스택 + Σ팟 + Σ이번 라운드 기여 = 초기 칩 합 + 리바이 합` (재경기 중에도).
- [ ] 재현성: 같은 시드 + 같은 액션 열 → 같은 상태 해시.
## 11. 참고 자료
- 나무위키 "섯다": https://namu.wiki/w/섯다 (20장 구성, 족보 순서, 땡잡이 = 3월 광 + 7월 열끗, 암행어사 = 4월 열끗 + 7월 열끗로 13·18광땡만 잡음, 구사는 상대 최고패가 알리 이하일 때 판돈을 둔 채 재경기, 멍텅구리구사는 8땡까지 재경기·광땡은 불가·지역에 따라 장땡 차이, 다이한 사람이 구사 재경기에 참가하려면 판돈만큼 지불, 같은 족보는 판돈을 둔 채 재대결, 3장 섯다, 베팅 용어 정의)
- 한게임 포커 7포커 베팅 방법(공용 한국식 베팅 용어 정의): https://poker.hangame.com/gameguide/poker7/game_7poker2_1.html
- 포커 문서 `docs/games/poker.md` 5.2(한국식 베팅), 5.5(사이드 팟), 5.6(리바이).
출처 간 차이와 이 문서의 선택:
- 멍텅구리구사 범위: "8땡까지"가 기본 서술이고 지역에 따라 장땡까지 무승부로 보기도 한다 → `mungGusa` 옵션, 기본 8땡.
- 재경기 재참가 비용: 나무위키는 "판돈만큼"이라 하지만 지역마다 다르다 → 기본은 재참가 불가, 옵션으로 절반/전액.
- 구사가 이미 이기는 경우의 재경기 여부: 6.3 설계 결정 참고.
## 12. 메모 (상표·법적 주의 등)
- 가상 칩만 사용한다. 방 전용이며 돈·유료 재화·광고 보상과 연결하지 않는다. 칩 거래·선물·누적 랭킹을 만들지 않는다. 결과 화면에 원(₩) 같은 화폐 표현을 쓰지 않는다.
- 법적 검토 필요(사용자 확인):
- 형법 제246조(도박): 재물을 걸지 않는 구조라 해당하지 않도록 설계했지만, 외부 정산을 부추기는 기능은 넣지 않는다.
- 게임산업진흥법 등급분류: 공개 배포하는 게임물은 원칙적으로 등급분류 대상이고, 비영리 예외는 청소년이용불가 기준에 해당하는 내용을 포함하면 적용되지 않는다. 섯다·고스톱·포커 같은 웹보드 게임은 통상 청소년이용불가로 분류되므로 서비스 전 확인이 필요하다.
- 어린이 사용자가 있으므로 섯다는 기본 목록과 분리하거나(예: "어른용" 탭) 방장 확인 문구를 두는 방안을 검토한다.
- 화투 그림: 화투(하나후다) 전통 도안 자체는 오래된 디자인이지만, 시판 화투나 다른 게임의 그림은 각 제작사의 저작물일 수 있다. 이미지를 가져오지 말고 9장의 지침대로 직접 단순화한 SVG를 그린다.
- 명칭: "섯다"는 일반 놀이 이름이라 상표 문제가 적다. 특정 서비스 이름(예: "한게임 섯다", "피망 섯다")은 쓰지 않는다. 표시 이름 후보: "섯다"(기본), 어린이 노출을 줄이고 싶다면 "화투 족보 대결".

303
docs/games/splendor.md Normal file
View File

@@ -0,0 +1,303 @@
# 보석 상인 (`splendor`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30분 · 난이도: 보통
## 1. 개요
- 르네상스 시대 보석 상인이 되어 보석 토큰을 모으고, 그 토큰으로 개발 카드(광산·운송·상점)를 사서 영구 보석 할인(보너스)과 명성 점수를 쌓는다. 특정 보너스를 갖추면 귀족이 방문해 추가 점수를 준다. 누군가 15점 이상이 되면 그 라운드를 마치고 최고 점수가 이긴다.
- 원작은 Marc André의 "Splendor"(Space Cowboys, 2014, 한국어판 코리아보드게임즈 "스플렌더"). 한국 보드게임 카페 입문 게임 상위권으로 인지도가 높다. 이 사이트에서는 일반 명칭 "보석 상인"으로 제공한다(12장).
- 인원 근거: 원작 2~4명.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `targetPoints` | 종료 조건 점수 | 15(원작), 10(짧은 판) | 15 |
| `allowFewerTokens` | 남은 색이 3종류 미만일 때 "서로 다른 색 가져오기"로 2개 또는 1개만 가져오기 허용 | `true`, `false` | `true` |
| `turnSeconds` | 한 차례 제한 시간 | 30, 60, 90, `null` | 60 |
| `showHints` | 지금 살 수 있는 카드 표시, 귀족까지 남은 보너스 표시 | `true`, `false` | `true` |
## 3. 구성물
- 보석 토큰 40개: 다이아몬드(흰, W) 7, 사파이어(파랑, U) 7, 에메랄드(초록, G) 7, 루비(빨강, R) 7, 오닉스(검정, K) 7, 황금(조커, 금) 5.
- 개발 카드 90장: 1단계 40장, 2단계 30장, 3단계 20장. 각 카드는 보너스 색 1개(영구 할인 1), 명성 점수 0~5, 비용(색별 토큰 수).
- 귀족 타일 10장: 각 3점, 요구 보너스 조건.
### 3.1 개발 카드 전체 목록
- 출처 검증: 원작 기본판 카드 구성(1/2/3단계 40/30/20장)을 공개 구현체 두 곳(bouk/splendimax의 CSV, seal256/splendor의 cards.csv)에서 받아 프로그램으로 대조해 90장 전부 일치함을 확인했다. 카드 ID는 우리가 붙인 내부 ID다(`<단계><보너스 색><번호>`, 색 코드 W·U·G·R·K).
- 표기: 비용 칸의 `·`은 0. "비용 합"은 참고용.
- 단계별 분포: 1단계 각 색 8장(0점 35장, 1점 5장), 2단계 각 색 6장(1점 10, 2점 15, 3점 5), 3단계 각 색 4장(3점 5, 4점 10, 5점 5).
#### 1단계 카드 (40장)
| ID | 보너스 | 점수 | 흰(W) | 파(U) | 초(G) | 빨(R) | 검(K) | 비용 합 |
|---|---|---|---|---|---|---|---|---|
| 1W01 | 흰 | 0 | · | 1 | 1 | 1 | 1 | 4 |
| 1W02 | 흰 | 0 | · | 1 | 2 | 1 | 1 | 5 |
| 1W03 | 흰 | 0 | · | 2 | 2 | · | 1 | 5 |
| 1W04 | 흰 | 0 | 3 | 1 | · | · | 1 | 5 |
| 1W05 | 흰 | 0 | · | · | · | 2 | 1 | 3 |
| 1W06 | 흰 | 0 | · | 2 | · | · | 2 | 4 |
| 1W07 | 흰 | 0 | · | 3 | · | · | · | 3 |
| 1W08 | 흰 | 1 | · | · | 4 | · | · | 4 |
| 1U01 | 파 | 0 | 1 | · | 1 | 1 | 1 | 4 |
| 1U02 | 파 | 0 | 1 | · | 1 | 2 | 1 | 5 |
| 1U03 | 파 | 0 | 1 | · | 2 | 2 | · | 5 |
| 1U04 | 파 | 0 | · | 1 | 3 | 1 | · | 5 |
| 1U05 | 파 | 0 | 1 | · | · | · | 2 | 3 |
| 1U06 | 파 | 0 | · | · | 2 | · | 2 | 4 |
| 1U07 | 파 | 0 | · | · | · | · | 3 | 3 |
| 1U08 | 파 | 1 | · | · | · | 4 | · | 4 |
| 1G01 | 초 | 0 | 1 | 1 | · | 1 | 1 | 4 |
| 1G02 | 초 | 0 | 1 | 1 | · | 1 | 2 | 5 |
| 1G03 | 초 | 0 | · | 1 | · | 2 | 2 | 5 |
| 1G04 | 초 | 0 | 1 | 3 | 1 | · | · | 5 |
| 1G05 | 초 | 0 | 2 | 1 | · | · | · | 3 |
| 1G06 | 초 | 0 | · | 2 | · | 2 | · | 4 |
| 1G07 | 초 | 0 | · | · | · | 3 | · | 3 |
| 1G08 | 초 | 1 | · | · | · | · | 4 | 4 |
| 1R01 | 빨 | 0 | 1 | 1 | 1 | · | 1 | 4 |
| 1R02 | 빨 | 0 | 2 | 1 | 1 | · | 1 | 5 |
| 1R03 | 빨 | 0 | 2 | · | 1 | · | 2 | 5 |
| 1R04 | 빨 | 0 | 1 | · | · | 1 | 3 | 5 |
| 1R05 | 빨 | 0 | · | 2 | 1 | · | · | 3 |
| 1R06 | 빨 | 0 | 2 | · | · | 2 | · | 4 |
| 1R07 | 빨 | 0 | 3 | · | · | · | · | 3 |
| 1R08 | 빨 | 1 | 4 | · | · | · | · | 4 |
| 1K01 | 검 | 0 | 1 | 1 | 1 | 1 | · | 4 |
| 1K02 | 검 | 0 | 1 | 2 | 1 | 1 | · | 5 |
| 1K03 | 검 | 0 | 2 | 2 | · | 1 | · | 5 |
| 1K04 | 검 | 0 | · | · | 1 | 3 | 1 | 5 |
| 1K05 | 검 | 0 | · | · | 2 | 1 | · | 3 |
| 1K06 | 검 | 0 | 2 | · | 2 | · | · | 4 |
| 1K07 | 검 | 0 | · | · | 3 | · | · | 3 |
| 1K08 | 검 | 1 | · | 4 | · | · | · | 4 |
#### 2단계 카드 (30장)
| ID | 보너스 | 점수 | 흰(W) | 파(U) | 초(G) | 빨(R) | 검(K) | 비용 합 |
|---|---|---|---|---|---|---|---|---|
| 2W01 | 흰 | 1 | · | · | 3 | 2 | 2 | 7 |
| 2W02 | 흰 | 1 | 2 | 3 | · | 3 | · | 8 |
| 2W03 | 흰 | 2 | · | · | 1 | 4 | 2 | 7 |
| 2W04 | 흰 | 2 | · | · | · | 5 | 3 | 8 |
| 2W05 | 흰 | 2 | · | · | · | 5 | · | 5 |
| 2W06 | 흰 | 3 | 6 | · | · | · | · | 6 |
| 2U01 | 파 | 1 | · | 2 | 2 | 3 | · | 7 |
| 2U02 | 파 | 1 | · | 2 | 3 | · | 3 | 8 |
| 2U03 | 파 | 2 | 5 | 3 | · | · | · | 8 |
| 2U04 | 파 | 2 | 2 | · | · | 1 | 4 | 7 |
| 2U05 | 파 | 2 | · | 5 | · | · | · | 5 |
| 2U06 | 파 | 3 | · | 6 | · | · | · | 6 |
| 2G01 | 초 | 1 | 3 | · | 2 | 3 | · | 8 |
| 2G02 | 초 | 1 | 2 | 3 | · | · | 2 | 7 |
| 2G03 | 초 | 2 | 4 | 2 | · | · | 1 | 7 |
| 2G04 | 초 | 2 | · | 5 | 3 | · | · | 8 |
| 2G05 | 초 | 2 | · | · | 5 | · | · | 5 |
| 2G06 | 초 | 3 | · | · | 6 | · | · | 6 |
| 2R01 | 빨 | 1 | 2 | · | · | 2 | 3 | 7 |
| 2R02 | 빨 | 1 | · | 3 | · | 2 | 3 | 8 |
| 2R03 | 빨 | 2 | 1 | 4 | 2 | · | · | 7 |
| 2R04 | 빨 | 2 | 3 | · | · | · | 5 | 8 |
| 2R05 | 빨 | 2 | · | · | · | · | 5 | 5 |
| 2R06 | 빨 | 3 | · | · | · | 6 | · | 6 |
| 2K01 | 검 | 1 | 3 | 2 | 2 | · | · | 7 |
| 2K02 | 검 | 1 | 3 | · | 3 | · | 2 | 8 |
| 2K03 | 검 | 2 | · | 1 | 4 | 2 | · | 7 |
| 2K04 | 검 | 2 | · | · | 5 | 3 | · | 8 |
| 2K05 | 검 | 2 | 5 | · | · | · | · | 5 |
| 2K06 | 검 | 3 | · | · | · | · | 6 | 6 |
#### 3단계 카드 (20장)
| ID | 보너스 | 점수 | 흰(W) | 파(U) | 초(G) | 빨(R) | 검(K) | 비용 합 |
|---|---|---|---|---|---|---|---|---|
| 3W01 | 흰 | 3 | · | 3 | 3 | 5 | 3 | 14 |
| 3W02 | 흰 | 4 | · | · | · | · | 7 | 7 |
| 3W03 | 흰 | 4 | 3 | · | · | 3 | 6 | 12 |
| 3W04 | 흰 | 5 | 3 | · | · | · | 7 | 10 |
| 3U01 | 파 | 3 | 3 | · | 3 | 3 | 5 | 14 |
| 3U02 | 파 | 4 | 7 | · | · | · | · | 7 |
| 3U03 | 파 | 4 | 6 | 3 | · | · | 3 | 12 |
| 3U04 | 파 | 5 | 7 | 3 | · | · | · | 10 |
| 3G01 | 초 | 3 | 5 | 3 | · | 3 | 3 | 14 |
| 3G02 | 초 | 4 | · | 7 | · | · | · | 7 |
| 3G03 | 초 | 4 | 3 | 6 | 3 | · | · | 12 |
| 3G04 | 초 | 5 | · | 7 | 3 | · | · | 10 |
| 3R01 | 빨 | 3 | 3 | 5 | 3 | · | 3 | 14 |
| 3R02 | 빨 | 4 | · | · | 7 | · | · | 7 |
| 3R03 | 빨 | 4 | · | 3 | 6 | 3 | · | 12 |
| 3R04 | 빨 | 5 | · | · | 7 | 3 | · | 10 |
| 3K01 | 검 | 3 | 3 | 3 | 5 | 3 | · | 14 |
| 3K02 | 검 | 4 | · | · | · | 7 | · | 7 |
| 3K03 | 검 | 4 | · | · | 3 | 6 | 3 | 12 |
| 3K04 | 검 | 5 | · | · | · | 7 | 3 | 10 |
### 3.2 귀족 타일 10장 (모두 3점)
| ID | 흰(W) | 파(U) | 초(G) | 빨(R) | 검(K) |
|---|---|---|---|---|---|
| N01 | · | · | 4 | 4 | · |
| N02 | · | 4 | 4 | · | · |
| N03 | 4 | 4 | · | · | · |
| N04 | 4 | · | · | · | 4 |
| N05 | · | · | · | 4 | 4 |
| N06 | · | 3 | 3 | 3 | · |
| N07 | 3 | 3 | 3 | · | · |
| N08 | 3 | 3 | · | · | 3 |
| N09 | 3 | · | · | 3 | 3 |
| N10 | · | · | 3 | 3 | 3 |
- 귀족 요구 조건은 "보너스(산 개발 카드)" 기준이며 토큰은 세지 않는다. 귀족 이름·초상은 쓰지 않고 자체 일러스트("귀족 1"~"귀족 10" 또는 창작 이름)를 쓴다.
## 4. 준비(셋업)
1. 인원별 보석 토큰(황금 제외) 색마다: 2명 4개, 3명 5개, 4명 7개. 황금은 인원과 관계없이 5개.
2. 귀족: 10장을 섞어 (인원 + 1)장 공개. 나머지는 게임에서 제외(비공개, 이후 사용 안 함).
3. 개발 카드: 단계별로 섞어 3개 더미. 각 단계에서 4장씩 공개(공개 카드 12장).
4. 차례 순서: RNG로 섞음(원작: 가장 어린 사람이 선). 선 플레이어를 기록한다(종료 판정에 필요).
## 5. 진행 규칙
- 차례마다 아래 4가지 행동 중 정확히 하나를 한다.
1. 서로 다른 색 보석 3개 가져오기: 황금이 아닌 서로 다른 3색에서 1개씩. 각 색 공급처에 1개 이상 있어야 한다. 공급처에 남은 색이 3종류 미만이고 `allowFewerTokens=true`면, 남은 색 수만큼(2개 또는 1개) 서로 다른 색으로 가져올 수 있다. 3색 이상 남아 있으면 반드시 3개.
2. 같은 색 보석 2개 가져오기: 가져가기 전 그 색이 공급처에 4개 이상 있을 때만. 황금은 불가.
3. 개발 카드 1장 예약: 손에 예약 카드가 3장 미만일 때만. 공개된 12장 중 하나 또는 1/2/3단계 더미 맨 위 카드(보지 않고)를 가져와 손에 둔다. 공급처에 황금이 있으면 1개 받는다(없어도 예약은 가능). 공개 카드를 예약했으면 같은 단계 더미에서 1장 보충(더미가 비면 빈칸 유지).
4. 개발 카드 1장 구매: 공개된 카드 또는 내 예약 카드. 색마다 지불액 = max(0, 비용 − 내 그 색 보너스). 부족분은 황금 1개당 아무 색 1개로 대체. 지불한 토큰(황금 포함)은 공급처로 돌아간다. 공개 카드를 샀으면 보충.
- 황금은 그 색 토큰이 있어도 원하는 만큼 대신 써도 된다(원작 규칙상 조커는 언제든 대체 가능). 지불 내역을 지정하지 않으면 서버가 "색 토큰 먼저, 부족분만 황금"으로 자동 계산한다.
- 보너스만으로 비용을 다 덮으면 무료로 살 수 있다.
- 토큰 한도: 차례 끝에 토큰(황금 포함)이 10개를 넘으면 10개가 되도록 원하는 토큰을 골라 반납한다. 방금 가져온 토큰을 반납해도 된다.
- 귀족 방문: 차례 끝(반납 후)에 내 보너스가 어떤 귀족의 요구 조건을 모두 만족하면 그 귀족이 온다(3점). 한 차례에 최대 1명. 여러 명 만족하면 하나를 고른다. 거부할 수 없고, 행동으로 치지 않는다. 나머지는 다음 차례 이후 다시 판정.
- 행동 불가: 위 4가지 중 어느 것도 할 수 없으면(공급처 토큰이 모두 없거나 조건 불충족, 예약 3장이며 살 수 있는 카드 없음 등) 그 차례는 넘긴다(`PASS`). 할 수 있는 행동이 하나라도 있으면 넘길 수 없다.
- 개발 카드는 산 뒤 되팔거나 버릴 수 없다. 예약 카드는 버릴 수 없다.
### 5.1 예시
- 보너스 W2 U0 G1 R0 K1, 토큰 W0 U2 G1 R0 K0 금1. 카드 2W04(비용 R5 K3)를 사려면: R 5 − 0 = 5, K 3 − 1 = 2 → 필요 7개, 가진 R·K 토큰 0, 황금 1 → 살 수 없음.
- 같은 보너스, 카드 1U05(비용 W1 K2): W 1 − 2 → 0, K 2 − 1 = 1 → K 토큰이 없으므로 황금 1개 사용. 구매 후 황금 0.
- 토큰 9개 상태에서 3개 가져오기 → 12개 → 2개 반납.
## 6. 승패와 점수 계산
- 명성 점수 = 산 개발 카드 점수 합 + 귀족 수 × 3.
- 종료: 어떤 플레이어가 차례를 마쳤을 때(귀족 방문까지 포함) `targetPoints` 이상이면, 그 라운드를 끝까지 진행해 모두가 같은 횟수의 차례를 갖게 한다(선 플레이어 바로 앞 사람의 차례까지). 그 사이 다른 사람도 점수를 더 얻을 수 있다.
- 순위: 점수 높은 순 → 동점이면 산 개발 카드 수가 적은 사람이 위(원작 규칙) → 그래도 같으면 공동 순위.
- 예: A 16점(카드 14장), B 16점(카드 12장), C 15점 → 1위 B, 2위 A, 3위 C.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 3개 미만 가져오기(`allowFewerTokens`): 원작 규칙서는 명시하지 않지만 일반적으로 통용되는 해석(공식 디지털판도 허용). 엄격하게 하려면 끈다.
- 짧은 판(`targetPoints=10`): 어린이·입문용.
- 지원하지 않는 변형(문서화만): 확장(도시, 동방 무역로 등), 예약 카드 공개 규칙(원작에서도 공개 카드 예약은 이미 모두가 본 카드라 공개, 더미 예약만 비공개).
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Color = 'W' | 'U' | 'G' | 'R' | 'K';
type TokenColor = Color | 'gold';
type Gems = Record<Color, number>;
type Tokens = Record<TokenColor, number>;
interface DevCard { id: string; tier: 1 | 2 | 3; bonus: Color; points: number; cost: Gems }
interface Noble { id: string; points: 3; req: Gems }
interface SplendorState {
options: SplendorOptions;
rng: RngState;
order: PlayerId[]; // 차례 순서, order[0] = 선
current: number;
phase: 'action' | 'discard' | 'noble' | 'finished';
endTriggered: boolean; // 누군가 목표 점수 도달 → 라운드 끝까지
supply: Tokens;
decks: Record<1 | 2 | 3, string[]>; // 비공개, 0번 = 맨 위
board: Record<1 | 2 | 3, (string | null)[]>; // 공개 4칸, 빈칸 null
nobles: string[]; // 공개된 귀족(남은 것)
players: Record<PlayerId, {
tokens: Tokens;
cards: string[]; // 산 카드
reserved: { cardId: string; blind: boolean }[]; // blind = 더미에서 예약
nobles: string[];
}>;
pendingNobleChoices: string[] | null; // phase='noble'일 때 고를 수 있는 귀족
turnsTaken: Record<PlayerId, number>;
turnSeq: number;
deadlineAt: number | null;
}
// 카드·귀족 정의는 상수 테이블(3.1, 3.2)로 두고 State에는 ID만 저장
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `TAKE_DIFFERENT` | `{ colors: Color[] }` | 현재 차례, `phase='action'` | 중복 없음, 황금 아님, 각 색 공급 ≥ 1. 길이 3. 단 `allowFewerTokens`이고 공급 > 0인 색 수 n < 3이면 길이 = n. 위반: "서로 다른 색 3개를 골라 주세요." |
| `TAKE_SAME` | `{ color: Color }` | 현재 차례, `action` | 가져가기 전 `supply[color] ≥ 4`. 위반: "같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요." |
| `RESERVE` | `{ from: { tier; slot: 0..3 } \| { tier; deck: true } }` | 현재 차례, `action` | 예약 < 3. 공개 칸에 카드 있음 / 더미에 카드 있음. 위반: "예약은 3장까지만 할 수 있어요." |
| `BUY` | `{ from: { tier; slot } \| { reservedIndex }; payment?: Tokens }` | 현재 차례, `action` | 카드 존재. `payment` 지정 시: 각 색 지불 ≤ 보유, 색 토큰 + 황금으로 필요액을 정확히 충족(초과 지불 불가). 미지정 시 자동 계산이 가능해야 함. 위반: "보석이 부족해요." |
| `PASS` | 없음 | 현재 차례, `action` | 위 네 행동이 모두 불가능할 때만. 위반: "할 수 있는 행동이 있어요." |
| `DISCARD` | `{ tokens: Tokens }` | 현재 차례, `phase='discard'` | 반납 후 합계 정확히 10, 각 반납 ≤ 보유 |
| `CHOOSE_NOBLE` | `{ nobleId }` | 현재 차례, `phase='noble'` | `pendingNobleChoices`에 포함 |
| `CONCEDE` | 없음 | 참가자 | 보유 토큰은 공급처로 반납, 예약 카드는 게임에서 제외, 산 카드·점수는 결과용으로 유지(순위는 최하위). 이후 차례에서 제외. 남은 사람이 1명이면 종료 |
- 차례 마무리 순서: 행동 → (토큰 > 10이면 `discard`) → (만족 귀족 1명이면 자동 방문, 2명 이상이면 `noble`) → 종료 판정 → 다음 차례.
- 이벤트: `tookTokens`, `reserved{tier, cardId|null(비공개)}`, `bought{cardId, paid}`, `refilled{tier, slot, cardId}`, `discarded`, `nobleVisited`, `endTriggered`, `passed`, `gameOver`.
### 8.3 공개/비공개 정보 (view)
- 모두 공개: 공급처 토큰, 공개 카드 12칸, 각 더미 남은 장 수, 공개 귀족, 각 플레이어의 토큰·산 카드(보너스)·귀족·점수, 예약 카드 수.
- 예약 카드:
- 공개 칸에서 예약한 카드(`blind=false`): 모두에게 공개(원작에서도 모두 본 카드).
- 더미에서 예약한 카드(`blind=true`): 본인에게만 내용 공개. 다른 플레이어·관전자에게는 `{ hidden: true, tier }`만. 그 카드를 사는 순간 공개(이벤트에 cardId 포함).
- 이벤트도 같은 필터를 거친다: 다른 사람에게 보내는 `reserved` 이벤트에는 블라인드 카드 ID를 넣지 않는다.
- 비공개: 더미 순서와 내용, 제외된 귀족, `rng`.
- 종료 후 결과 화면에서는 모든 예약 카드 공개.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 셋업: 차례 순서 섞기, 귀족 10장 섞어 (인원+1)장 선택, 단계별 더미 섞기(Fisher–Yates). 이후 게임 중 RNG 사용 없음(보충은 더미 맨 위에서).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 차례 시작 시 `now + turnSeconds`. `discard`/`noble` 단계는 15초 추가 부여.
- `onTimeout`(결정적):
- `action`: (1) 살 수 있는 카드 중 점수 최대 → 동점이면 지불 토큰 수 최소 → 공개 카드(1→3단계, 왼쪽 칸 우선) 다음 예약 카드 순. (2) 없으면 `TAKE_DIFFERENT`: 공급이 많은 색 3개(동률이면 W,U,G,R,K 순). (3) 불가하면 `TAKE_SAME`(공급 많은 색). (4) 불가하면 1단계 공개 카드 첫 칸 예약. (5) 모두 불가면 `PASS`.
- `discard`: 보유 수가 많은 색부터 1개씩 반납(동률이면 K,R,G,U,W 순, 황금은 마지막).
- `noble`: 후보 중 ID가 작은 것.
- 끊긴 사람: 10초 유예 후 자동 행동. 3번 연속이면 "자리 비움".
### 8.6 종료 조건과 결과(GameResult)
- `endTriggered`이고 방금 차례를 마친 사람이 `order`의 마지막(선 바로 앞)이면 종료.
```ts
interface SplendorResult {
ranking: { rank: number; player: PlayerId; points: number; cards: number; nobles: number; bonuses: Gems }[];
winners: PlayerId[];
revealedReserved: Record<PlayerId, string[]>;
summary: string; // 예: "현우 승리! 16점 (카드 12장, 귀족 2)"
}
```
## 9. UI/UX
- 모바일 세로: 맨 위 귀족 줄(작은 타일, 요구 보너스 아이콘), 그 아래 3단계 → 1단계 순 카드 3줄(줄마다 더미 + 공개 4장, 가로 폭에 맞춘 카드 최소 72px 너비), 그 아래 공급처 토큰 6개(원형 56px), 맨 아래 내 영역(보너스·토큰·예약 카드·점수). 다른 플레이어는 상단 탭/요약 칩으로, 누르면 펼침.
- 조작: 토큰을 차례로 누르면 선택 바구니에 담기고 "가져오기" 버튼 활성(같은 색 두 번 누르면 2개 가져오기로 자동 전환, 불가하면 흔들림 + 이유 표시). 카드를 누르면 "구매 / 예약" 큰 버튼 두 개가 뜨고, 구매 시 자동 지불 내역 미리보기(황금 사용 수 표시), "지불 방법 바꾸기"로 수동 조정.
- 합법 수 하이라이트: 지금 살 수 있는 카드에 초록 테두리, 황금을 써야 살 수 있으면 금색 테두리. 귀족 타일에 "남은 보너스 2" 표시.
- 애니메이션: 토큰이 공급처에서 내 영역으로 날아감, 카드 구매 시 카드가 내 보너스 줄로, 귀족 방문 시 왕관 효과.
- 초보자 도움말: 첫 판에 "보석 → 카드 → 할인 → 더 비싼 카드 → 귀족" 흐름도를 한 장으로 안내.
- 색약 대비: 보석마다 색과 모양(다이아몬드·물방울·네모·하트·원 등)과 글자(W/U/G/R/K 대신 "흰·파·초·빨·검")를 함께 표시.
## 10. 테스트 체크리스트
- [ ] 셋업: 2/3/4명에서 색별 토큰 4/5/7, 황금 5, 귀족 3/4/5장, 공개 카드 12장, 더미 36/26/16장.
- [ ] 카드 데이터: 90장, 단계별 40/30/20, 색별 8/6/4, 점수 분포가 3.1과 일치(테이블 스냅샷 테스트).
- [ ] `TAKE_SAME`: 공급 4개 → 허용, 3개 → 거부.
- [ ] `TAKE_DIFFERENT`: 같은 색 중복 거부, 황금 포함 거부. 공급에 2색만 남고 `allowFewerTokens=true` → 2개 허용, 3색 남았는데 2개 → 거부.
- [ ] 예약: 3장 보유 시 거부. 황금 0개일 때 예약 성공(황금 없이). 더미 예약 후 다른 사람 View에 카드 내용 없음.
- [ ] 구매: 5.1 예시 두 개 재현. 보너스로 비용 전부 충당 → 토큰 0개 지불. 수동 `payment`로 색 토큰이 있는데 황금을 쓰는 것 허용, 초과 지불 거부.
- [ ] 토큰 한도: 9개 + 3개 → `discard` 단계, 반납 후 정확히 10이 아니면 거부.
- [ ] 귀족: 한 차례에 2명 조건 충족 → `noble` 단계에서 1명만, 다음 차례 끝에 나머지 자동 방문(조건 여전히 충족 시).
- [ ] 종료: 2번째 플레이어가 15점 도달 → 3·4번째까지 진행 후 종료. 선 바로 앞 사람이 도달하면 즉시 종료.
- [ ] 동점: 점수 같으면 카드 수 적은 쪽 승, 그것도 같으면 공동.
- [ ] `PASS`: 할 수 있는 행동이 있으면 거부, 전혀 없을 때만 허용.
- [ ] 더미 소진: 공개 칸이 비어도 진행, 빈 더미 예약 거부.
- [ ] 시간 초과: 살 수 있는 카드가 있으면 자동 구매, 없으면 토큰 3개 가져오기.
- [ ] 연결 끊김 중 `discard`·`noble` 단계도 자동 처리되어 게임이 멈추지 않음.
- [ ] 정보 유출: 관전자·상대 View/이벤트에 블라인드 예약 카드 ID, 더미 내용, 제외 귀족, `rng`가 없음(직렬화 검사).
- [ ] 리플레이 결정성.
## 11. 참고 자료
- 원작 규칙서(영문, Space Cowboys): https://cdn.1j1ju.com/medias/7f/91/ba-splendor-rulebook.pdf
- Wikipedia "Splendor (game)": https://en.wikipedia.org/wiki/Splendor_(game)
- BoardGameGeek "Splendor": https://boardgamegeek.com/boardgame/148228/splendor
- 카드 데이터 대조용 공개 구현체: https://github.com/bouk/splendimax/blob/master/Splendor%20Cards.csv , https://github.com/seal256/splendor/blob/master/assets/cards.csv (귀족 목록: 같은 저장소 pysplendor/splendor.py의 NOBLES)
- 나무위키 "스플렌더": https://namu.wiki/w/스플렌더
## 12. 메모 (상표·법적 주의 등)
- "Splendor"와 "스플렌더"는 Space Cowboys(Asmodee)의 상표다. 화면·URL·광고 문구에 쓰지 말고 표시 이름은 "보석 상인"(후보: "보석 거상", "젬 마켓")으로 한다. 게임 규칙 자체는 저작권 보호 대상이 아니지만, 원작 카드 일러스트·귀족 초상·로고·규칙서 문장은 쓰지 않는다.
- 카드 구성 수치(비용·점수 표)는 게임 규칙의 일부인 사실 데이터로 보는 것이 일반적이나, 원작과 완전히 같은 수치를 쓰는 것이 부담되면 같은 구조(단계별 장 수, 점수 분포, 비용 합 범위)를 유지한 자체 카드 목록으로 바꿀 수 있도록 카드 테이블을 데이터 파일로 분리해 둔다. 이 결정은 프로젝트 오너 확인 필요.
- 돈이나 포인트를 걸지 않는다.

195
docs/games/yacht.md Normal file
View File

@@ -0,0 +1,195 @@
# 요트 다이스 (`yacht`)
> 마일스톤: M4 · 인원: 최소 1 ~ 최대 8명 · 예상 시간: 1인당 약 5~8분(4명 약 20~30분) · 난이도: 쉬움
## 1. 개요
- 주사위 5개를 한 차례에 최대 3번 굴리며(원하는 주사위는 고정), 12개 족보 칸 중 하나에 점수를 기록한다. 12라운드 후 총점이 가장 높은 사람이 이긴다.
- 한국에서는 닌텐도 스위치 "세계의 아소비 대전 51"(영문 Clubhouse Games: 51 Worldwide Games)에 수록된 "요트"로 크게 알려졌고, 인터넷 방송과 웹 게임으로 "요트 다이스"라는 이름이 널리 쓰인다. 이 문서의 기본 규칙은 그 버전을 따른다. 미국식 상용 변형(야찌 방식 13칸)은 옵션으로 둔다.
- 인원 근거: 요트는 전통적으로 인원 제한이 없는 주사위 게임이며 혼자서 점수 도전도 가능하다(최소 1명). 상한은 원작에 없으므로 점수표 가독성과 대기 시간을 고려해 8명으로 정한다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `scoring` | 점수 규칙 | `yacht51`(12칸, 51 수록판), `yahtzee`(13칸, 미국식 변형) | `yacht51` |
| `fullHouseAllowsYacht` | `yacht51`에서 5개가 모두 같은 눈을 풀하우스로 인정할지 | `true`, `false` | `true` |
| `yahtzeeJoker` | `yahtzee`에서 추가 요트(5개 같은 눈) 처리 | `forced`(공식 강제 조커), `free`(자유 선택 조커) | `forced` |
| `turnSeconds` | 한 차례 제한 시간 | 30, 60, 90, `null` | 60 |
| `showPreview` | 아직 안 채운 칸에 "지금 넣으면 몇 점"을 미리 표시 | `true`, `false` | `true` |
## 3. 구성물
- 육면체 주사위 5개(공용).
- 플레이어별 점수표(아래 6장 칸 구성).
## 4. 준비(셋업)
1. 좌석 순서를 RNG로 섞어 차례 순서를 정한다(방장이 "입장 순서 유지"를 선택하면 섞지 않음 — UI 설정, 엔진 옵션 아님. 기본은 섞음).
2. 모든 칸 비움, 라운드 1, 첫 플레이어 차례, `rollsUsed = 0`, 고정(hold) 없음.
## 5. 진행 규칙
- 게임은 칸 수만큼의 라운드(`yacht51` 12라운드, `yahtzee` 13라운드)로 진행된다. 한 라운드에 모든 플레이어가 차례 순서대로 한 번씩 차례를 가진다.
- 한 차례:
1. 첫 굴림: 주사위 5개를 모두 굴린다(`ROLL`). 첫 굴림 전에는 고정할 수 없고 점수를 기록할 수 없다.
2. 두 번째·세 번째 굴림(선택): 원하는 주사위를 고정(`SET_HOLD`)하고 나머지만 다시 굴린다. 고정은 굴림마다 자유롭게 바꿀 수 있다(이전에 고정한 주사위를 풀어서 다시 굴려도 됨). 5개 모두 고정한 상태로는 굴릴 수 없다.
3. 기록: 1~3번 굴린 뒤 언제든 비어 있는 칸 하나를 골라 현재 주사위 5개로 점수를 기록한다(`SCORE`). 조건을 만족하지 않는 칸에 넣으면 0점. 3번 굴린 뒤에는 반드시 기록해야 한다.
4. 기록하면 차례가 끝나고 다음 사람으로 넘어간다.
- 한 번 기록한 칸은 바꿀 수 없다. 모든 칸이 차면 그 플레이어는 끝.
## 6. 승패와 점수 계산
### 6.1 `yacht51` (기본, 12칸)
| 칸 | 조건 | 점수 | 최대 |
|---|---|---|---|
| 에이스(1) | 없음 | 눈 1인 주사위 눈의 합 | 5 |
| 듀스(2) | 없음 | 눈 2인 주사위 눈의 합 | 10 |
| 트레이(3) | 없음 | 눈 3의 합 | 15 |
| 포(4) | 없음 | 눈 4의 합 | 20 |
| 파이브(5) | 없음 | 눈 5의 합 | 25 |
| 식스(6) | 없음 | 눈 6의 합 | 30 |
| 상단 보너스 | 위 6칸 합계가 63 이상 | 35 (자동) | 35 |
| 초이스 | 없음 | 주사위 5개 눈의 합 | 30 |
| 포 카인드 | 같은 눈이 4개 이상 | 주사위 5개 눈의 합 | 30 |
| 풀 하우스 | 같은 눈 3개 + 다른 같은 눈 2개 (`fullHouseAllowsYacht=true`면 5개 모두 같은 눈도 인정) | 주사위 5개 눈의 합 | 30 (인정 안 하면 28) |
| 스몰 스트레이트 | 연속된 4개 눈 포함(1-2-3-4, 2-3-4-5, 3-4-5-6) | 15 | 15 |
| 라지 스트레이트 | 연속된 5개 눈(1-2-3-4-5, 2-3-4-5-6) | 30 | 30 |
| 요트 | 5개 모두 같은 눈 | 50 | 50 |
- 총점 = 상단 6칸 합 + 상단 보너스 + 하단 6칸 합. 이론상 최대 325점(풀하우스에 요트 인정 시), 323점(불인정 시).
- 상단 보너스는 63점(각 눈 3개씩 = 3+6+9+12+15+18) 기준이며, 상단 칸이 다 차기 전이라도 63에 도달하는 순간 확정 표시해도 된다(점수는 동일).
- 라지 스트레이트는 스몰 스트레이트 조건도 만족한다(스몰 칸에 넣으면 15점).
- 요트는 포 카인드 조건도 만족한다(포 카인드 칸에 넣으면 5개 합).
예시: 주사위 [6,6,6,6,2]
- 식스 24, 듀스 2, 초이스 26, 포 카인드 26, 풀 하우스 0, 스트레이트 0, 요트 0.
예시: [3,3,5,5,5] → 풀 하우스 21, 초이스 21, 트레이 6, 파이브 15.
예시: [4,4,4,4,4] → 요트 50, 포 카인드 20, 풀 하우스 20(`true`일 때) 또는 0, 포 20.
### 6.2 `yahtzee` (옵션, 13칸)
| 칸 | 조건 | 점수 |
|---|---|---|
| 1~6 | 없음 | 해당 눈의 합 |
| 상단 보너스 | 상단 합 63 이상 | 35 |
| 쓰리 카인드 | 같은 눈 3개 이상 | 5개 합 |
| 포 카인드 | 같은 눈 4개 이상 | 5개 합 |
| 풀 하우스 | 3개 + 2개(서로 다른 눈) | 25 |
| 스몰 스트레이트 | 연속 4개 포함 | 30 |
| 라지 스트레이트 | 연속 5개 | 40 |
| 요트(5개 같은 눈) | 5개 모두 같음 | 50 |
| 찬스 | 없음 | 5개 합 |
- 요트 보너스: 요트 칸에 이미 50점이 기록된 상태에서 다시 5개 같은 눈이 나와 어딘가에 기록하면 +100(횟수 제한 없음). 요트 칸이 0점이면 보너스 없음.
- 조커 규칙(요트 칸이 이미 채워진 상태(50 또는 0)에서 5개 같은 눈을 기록할 때):
- `forced`(공식): (1) 그 눈에 해당하는 상단 칸이 비어 있으면 반드시 거기에 기록. (2) 상단 칸이 이미 차 있으면 비어 있는 하단 칸 아무 곳에나 기록할 수 있고, 이때 풀 하우스 25, 스몰 30, 라지 40을 조건 무관하게 인정(쓰리/포 카인드·찬스는 5개 합). (3) 하단도 모두 차 있으면 비어 있는 상단 칸 하나에 0점.
- `free`: 아무 빈 칸이나 고를 수 있다. 단 조커(풀 하우스·스트레이트 고정 점수 인정)는 해당 눈의 상단 칸이 이미 차 있을 때만 적용.
- 총점 = 상단 합 + 보너스 35 + 하단 합 + 요트 보너스 합계.
### 6.3 순위와 동점
- 총점 높은 순. 동점이면 공동 순위(1등 동점이면 공동 우승). 원작에 별도 동점 규칙이 없으므로 추가 판정은 하지 않는다.
- 혼자 하는 경우 결과는 "총점"과 "개인 최고 기록 대비"만 표시(기록 저장은 SQLite에 선택적으로).
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 풀 하우스에 요트 인정 여부(`fullHouseAllowsYacht`): 51 수록판 설명 기준으로 기본 인정. 전통 요트 규칙(풀 하우스는 서로 다른 두 눈)을 원하면 끈다.
- 전통 요트 규칙의 포 카인드는 "같은 4개의 합"만 세는 경우가 있다(최대 24점). 수요가 있으면 `fourKindScore: 'allFive' | 'fourOnly'` 옵션으로 추가할 수 있다(현재 범위 밖).
- 야찌 방식 13칸과 조커 규칙: `scoring = yahtzee`, `yahtzeeJoker`.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Category =
| 'ones' | 'twos' | 'threes' | 'fours' | 'fives' | 'sixes'
| 'choice' | 'threeKind' | 'fourKind' | 'fullHouse'
| 'smallStraight' | 'largeStraight' | 'yacht';
// yacht51 사용 칸: threeKind 제외 12칸 / yahtzee 사용 칸: 13칸 (choice = 찬스)
interface YachtState {
options: YachtOptions;
rng: RngState;
order: PlayerId[]; // 차례 순서
current: number; // order 인덱스
round: number; // 1부터
totalRounds: 12 | 13;
dice: [number, number, number, number, number] | null; // 첫 굴림 전 null
held: [boolean, boolean, boolean, boolean, boolean];
rollsUsed: 0 | 1 | 2 | 3;
sheets: Record<PlayerId, {
scores: Partial<Record<Category, number>>;
yahtzeeBonus: number; // yahtzee 모드 요트 보너스 누적(100 단위)
}>;
left: PlayerId[]; // 기권·퇴장한 사람(남은 칸은 0점 처리)
phase: 'playing' | 'finished';
turnSeq: number;
deadlineAt: number | null;
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `ROLL` | 없음 | 현재 차례 사람, `rollsUsed < 3` | `rollsUsed = 0`이면 고정 무시(모두 굴림). 그 외 고정 안 된 주사위가 1개 이상. 위반: "이번 차례에는 더 굴릴 수 없어요." / "굴릴 주사위를 하나 이상 풀어 주세요." |
| `SET_HOLD` | `{ held: boolean[5] }` | 현재 차례 사람, `1 ≤ rollsUsed < 3` | 길이 5. `rollsUsed = 3`이면 의미 없으므로 거부. (실시간으로 다른 사람 화면에도 고정 상태가 보이도록 별도 액션) |
| `SCORE` | `{ category: Category }` | 현재 차례 사람, `rollsUsed ≥ 1` | 현재 모드에서 쓰는 칸이고 비어 있음. `yahtzee` + `forced` 조커 상황이면 6.2의 강제 순서를 만족하는 칸만 허용("이 요트는 ○ 칸에 먼저 넣어야 해요."). |
| `CONCEDE` | 없음 | 참가자 누구나 | 남은 칸 모두 0점 확정, 차례 순서에서 제외 |
- 점수 계산은 순수 함수 `scoreFor(dice, category, sheet, options) → { points, joker, yahtzeeBonus }`로 분리해 클라이언트 미리보기와 서버가 같은 코드를 공유한다.
- 이벤트: `rolled{dice,held}`, `holdChanged`, `scored{player,category,points,bonus}`, `upperBonus{player}`, `turnChanged`, `roundChanged`, `gameOver`.
### 8.3 공개/비공개 정보 (view)
- 숨겨진 정보 없음. 모든 참가자·관전자에게 같은 View: 주사위, 고정 상태, 남은 굴림 수, 모든 점수표, 현재 차례, 남은 시간.
- 현재 차례 사람(및 `showPreview=true`면 모두)에게 `preview: Partial<Record<Category, number>>`(빈 칸에 지금 넣을 때 점수)를 넣는다.
- `rng` 상태는 View에 넣지 않는다.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 셋업: 차례 순서 섞기(Fisher–Yates).
- `ROLL`: 고정되지 않은 주사위마다 `1 + floor(rng() * 6)`, 인덱스 0→4 순서로 호출(리플레이 일관성).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 차례 시작 시 `now + turnSeconds`(차례 전체에 대한 시간, 굴릴 때마다 초기화하지 않음).
- `onTimeout`:
- `rollsUsed = 0` → `ROLL` (그 다음 즉시 다시 onTimeout이 호출되어 기록).
- `rollsUsed ≥ 1` → `SCORE`: 허용된 빈 칸 중 점수가 가장 높은 칸. 동점이면 6.1/6.2 표의 위쪽 칸 우선. 모든 칸이 0점이면 희생 순서 `ones → yacht → largeStraight → smallStraight → fullHouse → fourKind → threeKind → twos → threes → fours → fives → sixes → choice` 중 첫 빈 칸.
- 끊긴 사람: 차례가 오면 10초 유예 후 위 자동 행동. 3번 연속 자동이면 "자리 비움" 표시.
### 8.6 종료 조건과 결과(GameResult)
- 마지막 라운드의 마지막 사람이 기록하면 종료(기권자는 건너뜀). 모든 사람이 기권하면 즉시 종료.
```ts
interface YachtResult {
ranking: { rank: number; player: PlayerId; total: number; upper: number; upperBonus: number; lower: number; yahtzeeBonus: number }[];
winners: PlayerId[];
sheets: Record<PlayerId, Partial<Record<Category, number>>>;
summary: string; // 예: "민수 승리! 245점 (요트 1회)"
}
```
## 9. UI/UX
- 모바일 세로: 위에 주사위 5개(각 64px 이상, 고정된 주사위는 아래로 내려가며 자물쇠 표시), 그 아래 큰 "굴리기 (남은 2번)" 버튼, 아래 절반은 점수표. 점수표는 내 열을 크게, 다른 사람은 가로 스크롤 열 또는 탭.
- 조작: 주사위를 누르면 고정/해제. 점수표의 빈 칸에 미리보기 점수가 회색으로 보이고, 누르면 "식스에 24점 기록할까요?" 확인 후 기록(실수 방지, 설정에서 끌 수 있음).
- 하이라이트: 조건을 만족하는 칸은 초록색, 0점이 되는 칸은 흐리게. 상단 보너스 진행률("63까지 12점 남음") 막대.
- 애니메이션: 주사위 굴림 0.6초(서버 결과 수신 후 재생). 요트 달성 시 큰 축하 효과.
- 초보자 도움말: "규칙 보기"에 칸별 조건과 예시 그림. 힌트 옵션: 추천 고정(간단한 휴리스틱: 가장 많은 같은 눈 유지)을 연하게 표시.
- 다른 사람 차례에도 주사위와 고정 상태가 실시간으로 보인다(관전 재미).
## 10. 테스트 체크리스트
- [ ] `yacht51` 점수: [6,6,6,6,2] 각 칸 값이 6.1 예시와 일치. [3,3,5,5,5] 풀 하우스 21. [1,2,3,4,6] 스몰 15, 라지 0. [2,3,4,5,6] 스몰 15, 라지 30.
- [ ] `fullHouseAllowsYacht`: [4,4,4,4,4] 풀 하우스 = 20(true) / 0(false).
- [ ] 상단 보너스: 상단 합 62 → 0, 63 → 35.
- [ ] 첫 굴림 전 `SCORE`, `SET_HOLD` 거부. 3번 굴린 뒤 `ROLL` 거부. 5개 모두 고정 후 `ROLL` 거부.
- [ ] 고정 해제 후 재굴림: 2번째에 고정한 주사위를 3번째에 풀면 그 주사위도 굴려진다.
- [ ] 이미 채운 칸 `SCORE` 거부. 조건 불충족 칸은 0점 기록 허용.
- [ ] `yahtzee` 요트 보너스: 요트 50 기록 후 두 번째 요트 → +100. 요트 칸 0점이면 보너스 0.
- [ ] `forced` 조커: 요트 칸 채움 + [5,5,5,5,5] + 파이브 칸 비어 있음 → 파이브 이외 `SCORE` 거부. 파이브 차 있음 → 라지 스트레이트 40 인정. 하단 모두 차 있음 → 빈 상단 칸 0점만 허용.
- [ ] 12라운드 × 3명 진행 후 자동 종료, 동점자는 같은 rank.
- [ ] 시간 초과: 굴리지 않은 상태 → 자동 굴림 후 최고 점수 칸 기록. 모든 칸 0점 → 희생 순서대로 기록.
- [ ] 연결 끊김 사용자 차례가 유예 후 자동 진행되고, 다른 사람 진행이 막히지 않음.
- [ ] 기권: 남은 칸 0점, 이후 차례에서 제외, 결과에 포함.
- [ ] 정보 유출: View에 `rng` 없음. 같은 시드·액션 열 리플레이 시 같은 주사위.
- [ ] 1인 플레이: 12라운드 연속 진행, 결과 정상.
## 11. 참고 자료
- 나무위키 "요트(주사위)": https://namu.wiki/w/요트(주사위) (51 수록판 칸별 점수, 최대 325점, 전통 규칙과의 차이)
- 나무위키 "세계 게임전집 51": https://namu.wiki/w/51%20Worldwide%20Games
- Wikipedia "Yacht (dice game)": https://en.wikipedia.org/wiki/Yacht_(dice_game)
- Wikipedia "Yahtzee"(13칸 점수, 요트 보너스 100, 강제/자유 조커 규칙): https://en.wikipedia.org/wiki/Yahtzee
## 12. 메모 (상표·법적 주의 등)
- "Yahtzee"(야찌)는 Hasbro의 등록 상표다. 화면·옵션 이름에 쓰지 말고 "13칸 변형", "미국식 규칙" 등으로 표기한다. "요트(Yacht)"는 오래된 공용 게임 이름이라 사용 가능하다.
- "세계의 아소비 대전 51", "Clubhouse Games"는 닌텐도 상표이므로 화면에 "○○ 규칙"처럼 쓰지 않고 내부 문서에서만 출처로 언급한다. 표시 이름 후보: "요트 다이스", "주사위 요트".
- 돈·포인트를 걸지 않는다. 점수는 게임 안 점수일 뿐이다.

251
docs/games/yut.md Normal file
View File

@@ -0,0 +1,251 @@
# 윷놀이 (`yut`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명(개인전), 팀전 4·6·8명(2팀) · 예상 시간: 약 15~25분 · 난이도: 쉬움
## 1. 개요
- 윷가락 4개를 던져 나온 결과(도·개·걸·윷·모, 빽도)만큼 말을 움직여, 자기 말을 모두 먼저 판 밖으로 내보내면(날) 이기는 한국 전통 놀이. 설날·명절에 남녀노소가 함께 하는 가장 대중적인 놀이로, 규칙 설명 없이도 대부분의 한국인이 안다. 그만큼 "우리 집 규칙"이 다양하므로 기본값을 정하고 나머지는 옵션으로 연다.
- 인원 근거: 전통 윷놀이는 2명이 겨루거나, 여러 명이 두 편으로 나뉘어(인원 제한 없음) 한다. 개인전은 화면·말 색 구분이 가능한 4명까지, 팀전은 2팀 × 최대 4명(총 8명)으로 제한한다. `minPlayers = 2`, `maxPlayers = 8`이며 개인전 5명 이상은 옵션 검증에서 막는다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mode` | 개인전 / 팀전 | `individual`(2~4명), `team`(4·6·8명, 2팀) | `individual` |
| `pieces` | 한 편(개인 또는 팀)이 가진 말 수 | 2, 3, 4, 5 | 4 |
| `stickModel` | 윷가락 확률 모델 | `realistic`(가락 하나가 평평한 면(배)이 위로 갈 확률 0.6), `fair`(0.5) | `realistic` |
| `backdo` | 빽도 사용 여부(표시된 가락 하나만 배가 위면 1칸 뒤로) | `true`, `false`(빽도 결과를 그냥 도로 처리) | `true` |
| `backdoFromDo` | "도" 자리(첫 칸)에 있는 말이 빽도를 받으면 | `chammeogi`(참먹이로 가서 한 바퀴 돈 것으로 침), `finish`(즉시 남) | `chammeogi` |
| `finishRule` | 날(완주) 판정 | `pass`(참먹이를 지나야 남, 참먹이에 멈추면 아직 안 남), `reach`(참먹이에 도착만 해도 남) | `pass` |
| `nakPercent` | 낙(윷가락이 판 밖으로 떨어짐) 확률. 0이면 낙 없음 | 0, 3, 5, 10 | 0 |
| `backdoCaptureBonus` | 빽도로 잡아도 한 번 더 던지기를 주는지 | `true`, `false` | `true` |
| `ranking` | 3~4명 개인전에서 1등이 나온 뒤 | `first`(1등 나오면 종료, 나머지는 진행도로 순위), `all`(꼴찌가 정해질 때까지 계속) | `first` |
| `turnSeconds` | 행동(던지기·말 고르기) 1회당 제한 시간 | 15, 30, 60, `null`(무제한, 끊김 처리만) | 30 |
옵션 검증(zod `superRefine`):
- `mode = individual`이면 인원 2~4명.
- `mode = team`이면 인원 4, 6, 8명(짝수). 좌석 순서대로 0,2,4,6번 자리가 청팀, 1,3,5,7번 자리가 홍팀. 방장이 시작 전 좌석을 바꿔 팀을 조정한다.
- 2명은 `individual`만 허용(1:1).
## 3. 구성물
- 윷판 1개: 29개 자리(바깥 20 + 대각선 안쪽 9). 정확한 그래프는 5장 참조.
- 윷가락 4개: 한쪽은 평평한 면(배), 반대쪽은 둥근 면(등). 그중 1개에 빽도 표시(X).
- 말: 편마다 `pieces`개(기본 4개), 편마다 다른 색. 팀전은 팀당 한 세트.
## 4. 준비(셋업)
1. 편 구성: 개인전은 각자 1편, 팀전은 2편(청팀·홍팀).
2. 모든 말은 "대기"(판 밖, 아직 출발 안 함) 상태로 시작.
3. 선 정하기: RNG로 첫 차례 편을 정한다(전통의 "윷 던져 높은 쪽이 선"을 대신함). 개인전 차례는 좌석 시계 방향. 팀전은 청1 → 홍1 → 청2 → 홍2 … 순으로 팀이 번갈아 가며, 팀 안에서는 좌석 순으로 돌아간다(그 팀의 다음 차례 사람이 던지고 말도 고른다).
4. RNG 상태를 State에 저장한다.
## 5. 진행 규칙
### 5.1 윷 던지기 결과
윷가락 4개를 각각 독립적으로 던진다. 각 가락이 배(평평한 면)가 위로 나올 확률을 `p`라 한다(`realistic` 0.6, `fair` 0.5).
| 결과 | 조건(배가 위인 가락 수) | 이동 | 한 번 더 | realistic(p=0.6) | fair(p=0.5) |
|---|---|---|---|---|---|
| 도 | 1개(표시 가락이 아닌 것) | 앞으로 1 | 아니오 | 0.1152 | 0.1875 |
| 빽도 | 1개이고 그것이 표시 가락 | 뒤로 1 | 아니오 | 0.0384 | 0.0625 |
| 개 | 2개 | 앞으로 2 | 아니오 | 0.3456 | 0.375 |
| 걸 | 3개 | 앞으로 3 | 아니오 | 0.3456 | 0.25 |
| 윷 | 4개 | 앞으로 4 | 예 | 0.1296 | 0.0625 |
| 모 | 0개(모두 등) | 앞으로 5 | 예 | 0.0256 | 0.0625 |
- 계산식: 배 k개일 확률 = C(4,k)·p^k·(1−p)^(4−k). 빽도 = p·(1−p)^3, 도 = 3·p·(1−p)^3.
- `backdo = false`이면 빽도 조건도 그냥 "도"로 처리한다(표시 가락 무시).
- `realistic`의 0.6은 나무위키에 소개된 실제 윷 단면 모델(배가 위로 갈 확률 약 0.61, 모 약 2.3%)을 반올림한 값이다. 실제 놀이 감각(개·걸이 많고 모가 드묾)과 맞다.
- 낙: `nakPercent > 0`이면 매 던지기마다 먼저 RNG로 낙 여부를 판정한다. 낙이면 그 던지기는 무효(결과 없음)이며, 그 던지기가 윷·모로 얻은 추가 던지기였더라도 그 전에 모아 둔 결과는 그대로 남는다. 낙 자체는 한 번 더 던지기를 주지 않는다.
### 5.2 윷판 그래프(정확한 정의)
자리 ID와 전통 명칭(명칭은 지역마다 조금 다르며 UI 표기는 선택 사항):
| ID | 명칭 | ID | 명칭 | ID | 명칭 | ID | 명칭 |
|---|---|---|---|---|---|---|---|
| 0 | 참먹이(출발·도착) | 5 | 모(모서리) | 10 | 뒷모(모서리) | 15 | 찌모(모서리) |
| 1 | 도 | 6 | 뒷도 | 11 | 찌도 | 16 | 날도 |
| 2 | 개 | 7 | 뒷개 | 12 | 찌개 | 17 | 날개 |
| 3 | 걸 | 8 | 뒷걸 | 13 | 찌걸 | 18 | 날걸 |
| 4 | 윷 | 9 | 뒷윷 | 14 | 찌윷 | 19 | 날윷 |
| 20 | 앞모도 | 21 | 앞모개 | 22 | 방(중앙) | 23 | 속윷 |
| 24 | 속모 | 25 | 뒷모도 | 26 | 뒷모개 | 27 | 사려 |
| 28 | 안찌 | | | | | | |
배치(말은 반시계 방향으로 돈다): 참먹이(0)는 오른쪽 아래 모서리, 모(5) 오른쪽 위, 뒷모(10) 왼쪽 위, 찌모(15) 왼쪽 아래.
- 대각선 1 (모 → 찌모): 5 → 20 → 21 → 22(방) → 23 → 24 → 15
- 대각선 2 (뒷모 → 참먹이): 10 → 25 → 26 → 22(방) → 27 → 28 → 0
한 칸 전진 함수 `next(node, ctx)` (ctx = 이번 이동의 출발 자리인지, 직전에 지난 자리):
| 현재 자리 | 이번 이동을 여기서 "출발"하는 경우 | 이동 중 "지나가는" 경우 |
|---|---|---|
| 대기(HOME) | 1 | - |
| 1~4, 6~9, 11~14, 16~18 | n+1 | n+1 |
| 19 | 0 | 0 |
| 5 (모) | 20 (지름길) | 6 |
| 10 (뒷모) | 25 (지름길) | 11 |
| 15 (찌모) | 16 | 16 (지름길 없음) |
| 20 → 21, 21 → 22 | 동일 | 동일 |
| 22 (방) | 27 (참먹이 쪽으로 꺾음) | 직전이 21이면 23(직진), 직전이 26이면 27(직진) |
| 23 → 24, 24 → 15 | 동일 | 동일 |
| 25 → 26, 26 → 22 | 동일 | 동일 |
| 27 → 28, 28 → 0 | 동일 | 동일 |
| 0 (참먹이, 한 바퀴 돈 말) | GOAL | GOAL(`pass`) |
- 핵심: 모서리(모·뒷모)와 방은 그 자리에 "멈춰 있다가" 다음 이동을 시작할 때만 꺾는다. 지나가는 중이면 직진한다.
- 방에 멈췄다가 출발하면 항상 참먹이 방향(27)으로 간다(가장 빠른 길).
- 대기 상태의 말이 처음 들어올 때는 0을 지나는 것이 아니라 1부터 센다(도 = 1, 모 = 5).
### 5.3 차례 진행
1. 던지기 단계: 현재 차례 사람이 `THROW`. 결과를 그 차례의 "모아 둔 결과" 목록(`pending`)에 넣는다.
- 윷 또는 모가 나오면 반드시 한 번 더 던진다(던지기 단계 유지). 윷·모가 계속 나오면 계속 던진다(상한 없음).
- 도·개·걸·빽도가 나오면(또는 낙) 던지기를 멈추고 이동 단계로 간다.
2. 이동 단계: `pending`에서 결과 하나와 움직일 말(대기 말 또는 판 위의 말 무더기) 하나를 골라 `MOVE`. 순서는 자유. 모든 결과를 다 쓸 때까지 반복한다.
- 대기 말은 전진 결과(도~모)로만 판에 올릴 수 있다. 빽도로는 못 올린다.
- 어떤 결과도 둘 곳이 없으면(예: 판 위에 말이 없는데 남은 결과가 빽도뿐) 그 결과들은 자동으로 버려진다. 일부 결과만 둘 곳이 없으면, 다른 결과를 먼저 쓰게 하고 매 이동 후 다시 판정한다(예: 빽도 + 개 → 개로 말을 올린 뒤 빽도를 그 말에 쓸 수 있음).
3. 잡기 보너스: 이동이 상대 말을 잡으면 즉시 던지기 단계로 돌아가 1번 더 던진다. 남은 `pending` 결과는 그대로 유지되고 새 결과가 추가된다. 한 번 이동으로 업힌 상대 말 여러 개를 잡아도 보너스는 1번. 한 차례에 잡기를 여러 번 하면 그때마다 1번씩.
4. `pending`이 비고 추가 던지기도 없으면 차례 종료, 다음 편(팀전은 다음 팀의 다음 사람)으로.
### 5.4 이동 규칙
- 전진 n: `next()`를 n번 적용. 중간에 GOAL에 닿으면 남은 칸은 버리고 그 말(무더기)은 "남"(완주).
- 날 판정:
- `pass`(기본): 한 바퀴를 돈 말이 참먹이(0)를 "지나가야" 남. 참먹이에 정확히 멈추면 아직 판 위(잡힐 수 있음). 다음 이동에서 어떤 전진 결과든 1칸만 나가도 남.
- `reach`: 한 바퀴 돈 말이 0에 도착하는 순간 남.
- 빽도(뒤로 1): 그 말이 바로 직전에 있던 자리로 되돌아간다(말마다 지나온 자리 기록 `trail`을 유지하고 마지막 칸을 되돌림). 방(22)처럼 들어오는 길이 둘인 자리에서도 "왔던 길"로 돌아간다.
- 도 자리(1)에서 빽도(이 말의 기록이 [1]뿐): `backdoFromDo = chammeogi`면 참먹이(0)로 가고 "한 바퀴 돈 말"로 취급한다(다음 전진에서 바로 날 수 있음). `finish`면 즉시 남.
- 참먹이(0)에서 빽도: 기록상 직전 자리(19 또는 28)로. 기록이 없으면 19로.
- 빽도로 대기 말을 움직일 수는 없다.
- 업기: 자기 편 말이 있는 자리에 멈추면 무조건 업는다(합쳐서 하나의 무더기). 이후 그 무더기는 함께 움직이고, 함께 잡히고, 함께 난다. 업힌 무더기를 일부만 떼어 움직일 수 없다. 팀전에서는 팀 말끼리 업힌다.
- 잡기: 상대 편 말(무더기)이 있는 자리에 "멈추면" 그 말들을 모두 대기로 돌려보낸다(기록 초기화). 지나가는 것은 잡지 않는다. 참먹이(0)에 서 있는 말도 잡힌다. 대기·남은 말은 잡을 수 없다.
- 한 자리에 서로 다른 편 말이 함께 있을 수 없다(멈추면 반드시 잡으므로).
### 5.5 예시
- 예시 1(한 차례에 완주): 대기 말만 있는 상태에서 윷(4) → 모(5) → 걸(3)이 나와 `pending=[윷,모,걸]`. 모로 새 말을 올림 → 5(모)에 멈춤. 걸: 5에서 출발하므로 지름길 20 → 21 → 22(방)에 멈춤. 윷: 22에서 출발하므로 27 → 28 → 0 → GOAL(4번째 칸). 이 말은 남(`pass` 기준). 한 차례 만에 1개 완주.
- 예시 2(방 직진): 말이 20(앞모도)에 있고 걸(3) → 21, 22를 지나 23(속윷)에 멈춤. 방을 지나갔으므로 직진해 찌모 쪽으로 간다.
- 예시 3(빽도 + 잡기): 내 말 A가 6(뒷도), 상대 말이 5(모)에 있음. 빽도를 A에 쓰면 5로 가서 상대 말을 잡고 한 번 더 던진다(`backdoCaptureBonus=true`). A는 이제 5에 "멈춰" 있으므로 다음 이동은 지름길(20)로 들어간다.
- 예시 4(참먹이 대기): `pass` 기준, 말이 28(안찌)에서 도(1)로 0에 멈춤 → 아직 안 남. 상대가 0에 멈추면 잡힌다. 다음 차례 도 이상이면 남.
## 6. 승패와 점수 계산
- 자기 편 말 `pieces`개를 모두 낸 편이 1등. 팀전은 이긴 팀 전원이 승리.
- 개인전 3~4명:
- `ranking = first`: 1등이 나오는 즉시 종료. 나머지 순위는 (1) 난 말 수 많은 순, (2) "남은 거리 합"이 작은 순, (3) 그래도 같으면 공동 순위.
- `ranking = all`: 1등이 나온 편은 빠지고 남은 편끼리 계속. 마지막 1편이 남으면 그 편이 꼴찌로 확정되며 종료.
- 남은 거리(말 1개 기준, `pass` 규칙에서 GOAL까지 최소 칸 수 = 0까지의 거리 + 1): 대기 12, 1→11, 2→10, 3→9, 4→8, 5→7, 6→11, 7→10, 8→9, 9→8, 10→7, 11→10, 12→9, 13→8, 14→7, 15→6, 16→5, 17→4, 18→3, 19→2, 0(참먹이)→1, 20→6, 21→5, 22→4, 23→8, 24→7, 25→6, 26→5, 27→3, 28→2. 업힌 무더기는 말 수만큼 곱한다. (`reach` 규칙이면 전부 1씩 뺀다.) 이 표는 순위 판정 전용이며 이동 규칙에는 쓰이지 않는다.
- 점수(옵션 없이 고정): 결과 화면에는 "난 말 수"와 순위만 표시한다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 빽도 사용 여부(`backdo`), 도 자리 빽도 처리(`backdoFromDo`), 날 판정(`finishRule`), 낙(`nakPercent`), 빽도 잡기 보너스(`backdoCaptureBonus`), 순위 방식(`ranking`): 2장 표 참조.
- 말 수 2~5개(`pieces`): 전통적으로 3~5개, 흔히 4개.
- 지원하지 않는 변형(문서화만): 자리마다 벌칙이 있는 "함정 윷판", 방에 멈추면 어느 방향이든 고를 수 있는 규칙(우리는 항상 참먹이 쪽), 업기를 거부하는 규칙, 빽도를 다음 차례까지 저장하는 규칙. 필요하면 후속 옵션으로 추가한다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type NodeId = number; // 0..28, 5.2 표
type SideId = number; // 개인전: 좌석 인덱스, 팀전: 0=청 1=홍
type Throw = 'do' | 'gae' | 'geol' | 'yut' | 'mo' | 'backdo';
interface Piece {
id: number; // 편 내 0..pieces-1
status: 'home' | 'board' | 'done';
node: NodeId | null; // board일 때만
lapped: boolean; // 참먹이(0)에 서 있을 때 "한 바퀴 돈" 말인지 (0에 있는 board 말은 항상 true)
trail: NodeId[]; // 판에 오른 뒤 지나온 자리(빽도용). 업히면 무더기 대표의 trail을 공유 복사
}
interface YutState {
options: YutOptions;
rng: RngState;
players: PlayerId[]; // 좌석 순
sideOf: Record<PlayerId, SideId>;
sides: { id: SideId; members: PlayerId[]; pieces: Piece[]; finishedRank: number | null }[];
turnOrder: PlayerId[]; // 개인전: 좌석 순, 팀전: 청1,홍1,청2,홍2...
turnIndex: number; // turnOrder에서 현재 차례
phase: 'throw' | 'move' | 'finished';
throwsOwed: number; // 앞으로 더 던져야 하는 횟수(윷/모/잡기). throw 단계 진입 시 1
pending: Throw[]; // 모아 둔 결과
lastThrow: { sticks: [boolean, boolean, boolean, boolean] | null; result: Throw | 'nak' } | null;
turnSeq: number; // 행동마다 +1 (타이머·중복 요청 방지)
deadlineAt: number | null;
log: GameEvent[]; // 최근 이벤트(최대 N개)
}
```
- 같은 노드의 같은 편 말들이 곧 "무더기"다. 별도 스택 객체를 두지 않고 노드로 묶는다(불변식: 한 노드에는 한 편만).
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `THROW` | 없음 | 현재 차례 사람, `phase='throw'` | `throwsOwed > 0`. 위반 시 "지금은 윷을 던질 차례가 아니에요." |
| `MOVE` | `{ throwIndex: number; from: NodeId \| 'home' }` | 현재 차례 사람, `phase='move'` | `pending[throwIndex]` 존재. `from='home'`이면 자기 편 대기 말이 있고 결과가 빽도가 아님. `from`이 노드면 자기 편 말이 그 노드에 있음. 빽도는 판 위 말에만. 위반 시 "그 말은 움직일 수 없어요." 등 |
| `CONCEDE` | 없음 | 아무 참가자, 언제나 | 개인전: 그 편 탈락(남은 편이 1이면 종료). 팀전: 팀원 전원이 기권해야 팀 패배(또는 방장 설정) |
- 팀전에서는 현재 차례인 팀원만 행동할 수 있다(같은 팀 다른 사람은 채팅·추천만).
- `apply` 이벤트: `thrown{sticks,result}`, `nak`, `moved{side,from,to,path,count}`, `stacked`, `captured{victimSide,count}`, `finishedPieces{count}`, `discarded{throws}`, `turnChanged`, `sideFinished{rank}`, `gameOver`.
### 8.3 공개/비공개 정보 (view)
- 윷놀이에는 숨겨진 정보가 없다. 모든 참가자·관전자에게 같은 View를 준다: 말 위치, `pending`, 직전 던지기의 가락 4개 앞뒷면, 차례, 남은 시간.
- 단, `rng` 상태(시드·내부 카운터)는 절대 View에 넣지 않는다(다음 던지기 예측 가능해짐).
- 현재 차례 사람에게만 `legalMoves: { throwIndex, from, to, captures, finishes }[]`를 추가로 계산해 넣는다(하이라이트용). 다른 사람에게도 넣어도 무방하지만 페이로드 절약을 위해 차례인 사람만.
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- 첫 차례 편 결정(setup 1회).
- `THROW`마다: (1) `nakPercent > 0`이면 `rng() < nakPercent/100`로 낙 판정, (2) 가락 4개 각각 `rng() < p`로 배/등 결정(인덱스 0이 빽도 표시 가락). 결과는 5.1 표로 매핑. 가락 단위로 뽑아야 애니메이션(어떤 가락이 뒤집혔는지)과 확률이 일치한다.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: 행동이 필요할 때마다 `now + turnSeconds`. 끊긴 사람은 차례가 오면 5초 유예 후 자동 행동.
- `onTimeout`:
- `phase='throw'` → `THROW`.
- `phase='move'` → 결정적 우선순위로 하나 선택: (1) 상대를 잡는 수(잡히는 말 수 많은 것 우선), (2) 말을 내는 수, (3) 자기 말 업기, (4) 가장 뒤처진(남은 거리 큰) 말을 가장 큰 결과로 전진, (5) 대기 말 올리기. 동률이면 `throwIndex` 작은 것, `from` 노드 번호 작은 것('home' 마지막).
- 같은 사람이 3회 연속 시간 초과되면 "자리 비움" 표시(자동 진행은 계속).
### 8.6 종료 조건과 결과(GameResult)
- 어떤 편의 말이 모두 `done`이 되면 그 편 순위 확정. 6장 규칙에 따라 종료.
```ts
interface YutResult {
ranking: { rank: number; players: PlayerId[]; side: SideId; finishedPieces: number; remainingDistance: number }[];
winners: PlayerId[];
summary: string; // 예: "청팀 승리! (4개 모두 남)"
stats: { throws: Record<Throw | 'nak', number>; captures: Record<SideId, number> };
}
```
## 9. UI/UX
- 모바일 세로: 위쪽 2/3에 정사각형 윷판(자리 원 지름 48px 이상, 모서리·방은 더 크게), 아래에 "모아 둔 결과" 칩(도·개·걸·윷·모·빽도, 각 64px 이상)과 큰 "윷 던지기" 버튼, 맨 아래 대기 말·난 말 표시. PC: 윷판 왼쪽, 정보 패널 오른쪽.
- 조작: 결과 칩을 먼저 누르고 → 움직일 수 있는 말이 반짝이고 → 말을 누르면 도착 자리가 표시되고 → 다시 누르면 확정(오조작 방지 2단계). 결과가 1개이고 움직일 말이 1가지면 "자동 이동" 버튼 강조.
- 합법 수 하이라이트: 도착 자리에 점선 경로 미리보기. 잡을 수 있으면 빨간 테두리 "잡기!", 업기면 "업기", 나면 "날!" 배지.
- 애니메이션: 윷가락 4개가 공중에서 돌다 떨어지는 0.8초 애니메이션(서버 결과를 받은 뒤 재생, 결과 조작 불가). 말은 칸마다 통통 튀며 이동. 잡힌 말은 대기 자리로 날아감.
- 초보자 도움말: "규칙 보기"에 그림으로 도~모, 지름길(모서리·방에 멈추면 꺾임), 업기, 잡기 설명. 힌트 옵션을 켜면 추천 수(8.5 우선순위)를 연하게 표시.
- 접근성: 결과 칩에 글자(도/개/걸/윷/모/빽도)와 이동 칸 수(1~5, −1)를 함께. 색약 대비로 편마다 모양(원·세모·네모·별) 구분.
## 10. 테스트 체크리스트
- [ ] 확률: 시드 고정 10만 회 던지기에서 realistic/fair 각 결과 빈도가 5.1 표 값 ±0.5%p 이내.
- [ ] 윷 → 모 → 걸: 던지기 단계가 걸에서 멈추고 `pending=[yut,mo,geol]`, 순서 자유롭게 사용 가능(예시 1 그대로 재현해 1개 완주).
- [ ] 모(5)를 지나가는 이동(4에서 걸 → 7)은 지름길로 안 꺾인다. 5에 멈췄다 출발하면 20으로 꺾인다.
- [ ] 방(22): 21에서 지나가면 23, 26에서 지나가면 27, 22에서 출발하면 27.
- [ ] 찌모(15)에 멈췄다 출발해도 16으로(지름길 없음).
- [ ] `pass`: 28에서 도 → 0에 멈춤, 미완주, 상대가 0에 오면 잡힘. 다음 도 → 남. `reach`: 28에서 도 → 즉시 남.
- [ ] 빽도는 왔던 길로: 21→22로 온 말이 빽도 → 21. 26→22로 온 말이 빽도 → 26. 빽도로 21에 간 말이 다음에 개 → 22를 지나 23(직진).
- [ ] 도 자리 빽도: `chammeogi`면 0으로 가고 다음 개 → 남. `finish`면 즉시 남.
- [ ] 판 위 말 없음 + `pending=[backdo]` → 결과 버려지고 차례 넘어감. `pending=[backdo, gae]` → 개로 말을 올린 뒤 빽도 사용 가능.
- [ ] 업기: 같은 편 말 위에 멈추면 합쳐지고, 이후 한 번에 2개가 같이 이동·같이 남. 업힌 2개가 잡히면 둘 다 대기로, 잡은 쪽 보너스는 1번.
- [ ] 잡기 보너스: 잡은 즉시 `phase='throw'`, 남은 `pending` 유지. 보너스 던지기에서 윷이 나오면 또 던짐.
- [ ] 지나가는 칸의 상대 말은 잡히지 않는다.
- [ ] 낙 10%: 낙이 나오면 결과 없음, 그 전에 모아 둔 윷 결과는 유지되어 이동 단계로 간다.
- [ ] 팀전 4명: 차례 순서 청1→홍1→청2→홍2, 홍1 차례에 청2가 보낸 `MOVE`는 "지금은 다른 사람 차례예요."로 거부.
- [ ] 개인전 3명 `ranking=all`: 1등 편이 빠진 뒤 차례 순서에서 제외되고 끝까지 진행.
- [ ] 시간 초과: throw 단계 → 자동 던지기. move 단계에서 잡기 가능한 수가 있으면 그것을 고른다.
- [ ] 연결 끊김: 끊긴 사람 차례에 유예 후 자동 진행, 재접속하면 즉시 현재 View 수신.
- [ ] 정보 유출: View JSON에 `rng`, 시드, 다음 결과가 없음(직렬화 스냅샷 테스트).
- [ ] 결정성: 같은 시드 + 같은 액션 열 → 같은 최종 State(리플레이 테스트).
- [ ] 옵션 검증: 개인전 5명, 팀전 5명 거부.
## 11. 참고 자료
- 나무위키 "윷놀이": https://namu.wiki/w/윷놀이 (가락 확률 모델, 빽도·낙·업기·잡기·지름길·참먹이 규칙과 지역 차이)
- 한국민족문화대백과사전 "윷놀이": https://encykorea.aks.ac.kr/Article/E0042794
- 영남일보 "윷놀이판에 담긴 한국어 문화"(윷판 29개 자리 명칭): https://www.yeongnam.com/web/view.php?key=20200129010004667
- 모아모앙 윷놀이 규칙 정리(대각선 자리 명칭, fair 확률): https://moamoang.co.kr/yut-nori/
- Wikipedia "Yunnori": https://en.wikipedia.org/wiki/Yunnori
## 12. 메모 (상표·법적 주의 등)
- 윷놀이는 전통 민속놀이로 상표·저작권 문제가 없다. 윷판 그림·말 디자인만 자체 제작하면 된다.
- 내기(돈 걸기) 기능은 넣지 않는다. 점수·포인트도 방 안에서만 쓰이는 표시용이다.
- 지역마다 규칙이 달라 사용자 항의가 잦을 수 있으므로, 방 만들기 화면에 현재 적용 규칙 요약(빽도 사용, 날 판정 방식 등)을 한 줄로 보여 준다.