Files
joke-app/docs/games/seotda.md
EJClaw f4d63e1544 docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00

516 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 섯다 (`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를 그린다.
- 명칭: "섯다"는 일반 놀이 이름이라 상표 문제가 적다. 특정 서비스 이름(예: "한게임 섯다", "피망 섯다")은 쓰지 않는다. 표시 이름 후보: "섯다"(기본), 어린이 노출을 줄이고 싶다면 "화투 족보 대결".