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

35 KiB
Raw Blame History

섯다 (seotda)

마일스톤: M3 · 인원: 최소 2 ~ 최대 10명 (3장 섯다는 최대 6명) · 예상 시간: 약 1525분 (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)

// 서버만 보는 전체 상태
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.

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를 그린다.
  • 명칭: "섯다"는 일반 놀이 이름이라 상표 문제가 적다. 특정 서비스 이름(예: "한게임 섯다", "피망 섯다")은 쓰지 않는다. 표시 이름 후보: "섯다"(기본), 어린이 노출을 줄이고 싶다면 "화투 족보 대결".