Files
joke-app/apps/web/src/games/seotda/index.tsx
EJClaw 5dd8e6ea07 M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통),
  땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개
- 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)·
  5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티,
  리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음)
- 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언,
  체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시
- 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시
- 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정
- E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:48:56 +09:00

832 lines
44 KiB
TypeScript
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.
/** 섯다 화면 (docs/games/seotda.md §9). Chips are room-only play points. */
import { useEffect, useRef, useState } from 'react';
import type {
BetType,
CardSlot,
PotOutcome,
SeotdaCardView,
SeotdaOptions,
SeotdaSeatView,
SeotdaView,
SpecialKind,
} from '@bg/games/seotda';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full'];
const BET_KO: Record<BetType, string> = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' };
const BET_HELP: Record<BetType, string> = {
check: '칩을 걸지 않고 넘겨요',
ping: '기본 칩만큼 걸어요',
call: '앞사람만큼 맞춰요',
ddadang: '앞사람이 건 만큼의 두 배',
quarter: '모인 칩의 4분의 1만큼 더',
half: '모인 칩의 절반만큼 더',
full: '모인 칩만큼 더',
die: '이번 판을 포기해요',
};
const STRENGTH_COLOR = { high: 'var(--success)', mid: 'var(--warning)', low: 'var(--text-sub)' } as const;
const STRENGTH_KO = { high: '센 패', mid: '중간 패', low: '약한 패' } as const;
const REMATCH_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '동점' } as const;
const REASON_KO: Record<PotOutcome['reason'], string> = {
normal: '',
ttaengjabi: '땡잡이',
amhaeng: '암행어사',
gusa: '구사 재경기',
mungGusa: '멍텅구리구사 재경기',
tie: '동점',
limit: '재경기 횟수 초과로 나눔',
fold: '모두 다이',
};
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const isCard = (c: CardSlot): c is SeotdaCardView => !('hidden' in c);
/** Illustration card for the rules / 족보표 (no rule code needed on the client). */
function illus(month: number, special: boolean): HwatuInfo {
const gwang = [1, 3, 8].includes(month);
const kind: HwatuInfo['kind'] = special ? (gwang ? 'gwang' : 'yeol') : month === 8 ? 'pi' : 'tti';
return {
id: (month - 1) * 4 + (special ? 0 : 1),
month,
kind,
...(kind === 'tti' ? { tti: 'plain' as const } : {}),
name: `${month}월 ${special ? (gwang ? '광' : '열끗') : month === 8 ? '기러기' : '띠'}`,
};
}
function totalMsOf(v: SeotdaView): number {
const o = v.options;
if (v.phase === 'rebuy' || v.handPhase === 'rejoin') return o.decisionSeconds * 1000;
if (v.handPhase === 'choose') return (o.turnSeconds + 10) * 1000;
if (v.handPhase === 'handEnd') return 4000;
const turn = v.seats.find((s) => s.id === v.toAct);
return turn?.away ? 5000 : o.turnSeconds * 1000;
}
function specialFlash(v: SeotdaView): string | null {
const pots = v.showdown?.pots ?? [];
if (pots.some((p) => p.reason === 'ttaengjabi')) return '땡잡이!';
if (pots.some((p) => p.reason === 'amhaeng')) return '암행어사 출두!';
if (pots.some((p) => p.type === 'rematch' && p.reason === 'gusa')) return '구사 재경기';
if (pots.some((p) => p.type === 'rematch' && p.reason === 'mungGusa')) return '멍텅구리구사 재경기';
if (pots.some((p) => p.type === 'rematch')) return '동점 재경기';
return null;
}
const CSS = `
.sd-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
.sd-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 168px); z-index: 1;
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
.sd-seat.turn { border-color: var(--focus); }
.sd-seat.me { border-color: var(--primary); }
.sd-seat.me.turn { border-color: var(--focus); }
.sd-seat.out { opacity: 0.6; }
.sd-center { position: absolute; left: 50%; top: 47%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 46%; }
.sd-flash { position: absolute; left: 50%; top: 30%; transform: translate(-50%, -50%); z-index: 3; font-size: 2.6rem; font-weight: 900; color: #fde047;
text-shadow: 0 3px 8px rgba(0,0,0,.6); animation: sd-pop .35s ease-out; pointer-events: none; white-space: nowrap; }
@keyframes sd-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } }
.sd-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.sd-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; }
.sd-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
.sd-mine { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
.sd-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
@media (max-width: 719px) {
.sd-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
.sd-seat { position: static; transform: none; width: auto; }
.sd-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
.sd-flash { position: fixed; top: 40%; }
.sd-actions { grid-template-columns: repeat(2, 1fr); }
}
`;
// ---------------------------------------------------------------- board
function SeatBox({ seat, view: v, players, me, deadline, x, y }: { seat: SeotdaSeatView; view: SeotdaView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
const u = userOf(players, seat.id);
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
const badges: [string, string][] = [];
if (seat.left) badges.push(['나감', 'var(--danger)']);
else if (seat.away) badges.push(['자리 비움', 'var(--warning)']);
if (seat.status === 'eliminated') badges.push(['탈락', 'var(--text-sub)']);
if (seat.status === 'busted') badges.push(['리바이 대기', 'var(--warning)']);
if (seat.folded) badges.push(['다이', 'var(--danger)']);
if (seat.allIn) badges.push(['올인', 'var(--primary)']);
if (seat.chosen === true) badges.push(['고름', 'var(--success)']);
if (seat.chosen === false) badges.push(['고르는 중', 'var(--text-sub)']);
const timed = seat.isTurn || seat.chosen === false;
return (
<div
className={`sd-seat${seat.isTurn ? ' turn' : ''}${seat.id === me ? ' me' : ''}${out ? ' out' : ''}`}
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
aria-label={`${u.nickname} ${formatChips(seat.stack)}${seat.isDealer ? ', 선' : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
>
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
<Avatar user={u} size={22} />
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{seat.id === me && <span className="muted"> (나)</span>}
</strong>
{seat.isDealer && (
<span className="sd-badge" style={{ background: '#facc15', color: '#422006' }} title="선(먼저 하는 사람)">
선
</span>
)}
{timed && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={seat.id === me} size={28} />}
</div>
<div className="row" style={{ gap: 6, flexWrap: 'nowrap', alignItems: 'flex-start' }}>
<div className="stack" style={{ gap: 2, flex: 1, minWidth: 0 }}>
<Chips n={seat.stack} strong />
{((seat.lastAction && !seat.folded) || seat.roundBet > 0) && (
<span className="small" style={{ whiteSpace: 'nowrap' }} title="마지막 행동 · 이번 라운드에 건 칩">
{seat.lastAction && !seat.folded && <span className="muted">{BET_KO[seat.lastAction.type]} </span>}
{seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`}
</span>
)}
<div className="row" style={{ gap: 3 }}>
{badges.map(([t, c]) => (
<span key={t} className="sd-badge" style={{ background: c, color: '#fff' }}>
{t}
</span>
))}
</div>
</div>
{seat.cards.length > 0 && (
<div style={{ display: 'flex', flex: 'none' }}>
{seat.cards.map((c, i) => (
<HwatuCard key={isCard(c) ? c.key : `h${i}`} card={isCard(c) ? c : null} size="sm" style={i > 0 ? { marginLeft: -14 } : undefined} />
))}
</div>
)}
</div>
{seat.handName && <strong style={{ color: 'var(--primary)', textAlign: 'center' }}>{seat.handName}</strong>}
</div>
);
}
function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
// On the result screen the paid-out pots are no longer in v.pot (only a carried rematch pot is).
const paid = v.handPhase === 'handEnd' ? (v.foldWin?.amount ?? (v.showdown?.pots ?? []).filter((p) => p.type !== 'rematch').reduce((a, p) => a + p.amount, 0)) : 0;
return (
<div className="sd-center">
{v.rematchNo > 0 && v.rematchReason && (
<div className="sd-badge" style={{ background: '#fde047', color: '#422006', fontSize: '1rem', padding: '2px 12px', marginBottom: 6 }}>
재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]}
</div>
)}
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
{formatChips(v.pot + paid)}
</div>
{v.pots.length > 1 && (
<div className="small" style={{ opacity: 0.9 }}>
{v.pots.map((p, i) => (
<div key={i}>
{i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
</div>
))}
</div>
)}
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)}
{v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''}
</div>
</div>
);
}
function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
const n = v.seats.length;
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
const [flash, setFlash] = useState<string | null>(null);
const [announce, setAnnounce] = useState('');
const prevToAct = useRef(v.toAct);
const showKey = `${v.handNo}-${v.rematchNo}-${v.handPhase}`;
useEffect(() => {
const f = v.handPhase === 'handEnd' ? specialFlash(v) : null;
if (f) {
setFlash(f);
const t = setTimeout(() => setFlash(null), 1400);
return () => clearTimeout(t);
}
return undefined;
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (v.toAct && v.toAct !== prevToAct.current) {
if (v.toAct === me) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
} else setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
}
prevToAct.current = v.toAct;
}, [v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (v.handPhase !== 'handEnd') return;
if (v.foldWin) setAnnounce(`${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요.`);
else if (v.showdown) setAnnounce(resultLine(v, players));
const won = v.foldWin?.winner === me || v.showdown?.pots.some((p) => p.type !== 'rematch' && p.winners.includes(me ?? ''));
if (me && won) sounds.win();
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="board-wrap stack" style={{ gap: 12 }}>
<style>{CSS}</style>
<div className="sd-table" role="region" aria-label="섯다 테이블" style={{ '--seat-w': v.options.cardMode === 'three' ? '196px' : n > 6 ? '150px' : '172px' } as React.CSSProperties}>
{v.seats.map((s, i) => {
const rel = (i - myIdx + n) % n;
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
return <SeatBox key={s.id} seat={s} view={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
})}
<CenterInfo view={v} players={players} />
{flash && <div className="sd-flash">{flash}</div>}
</div>
<MyArea view={v} me={me} players={players} act={act} pending={pending} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function resultLine(v: SeotdaView, players: Record<string, PublicUser>): string {
if (v.foldWin) return `${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요(모두 다이).`;
if (!v.showdown) return '';
return v.showdown.pots
.map((p) => {
const who = p.winners.map((id) => nick(players, id)).join('·');
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name ?? '';
if (p.type === 'rematch') return `${REASON_KO[p.reason]}: ${who}님이 다시 겨뤄요(${formatChips(p.amount)})`;
if (p.type === 'split') return `${who}님이 ${formatChips(p.amount)}을 나눠 가졌어요${p.reason === 'limit' ? '(재경기 횟수 초과)' : ''}`;
return `${who}님이 ${name}${p.reason === 'normal' ? '' : ' 발동'}(으)로 ${formatChips(p.amount)}을 가져갔어요`;
})
.join(' / ');
}
/** My cards, hand name, 3장 choice and the action buttons. */
function MyArea({ view: v, me, players, act, pending }: { view: SeotdaView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
const seat = v.seats.find((s) => s.id === me);
const [pick, setPick] = useState<number[]>([]);
const [hover, setHover] = useState<BetType | null>(null);
useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]);
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel view={v} players={players} /> : null;
const myCards = seat.cards.filter(isCard);
const choosing = v.handPhase === 'choose' && seat.chosen === false;
const preview = choosing && pick.length === 2 ? v.myCombos?.find((c) => c.cardIds.includes(pick[0]!) && c.cardIds.includes(pick[1]!)) : null;
const myTurn = v.toAct === me && v.handPhase === 'betting';
const legal = new Map(v.legal.map((l) => [l.type, l]));
const hoverBet = hover ? legal.get(hover) : undefined;
const toggle = (id: number) => setPick((p) => (p.includes(id) ? p.filter((x) => x !== id) : p.length >= 2 ? [p[1]!, id] : [...p, id]));
const showHand = myCards.length > 0 && seat.inHand;
return (
<div className="stack" style={{ gap: 10 }}>
{showHand && (
<div className="card stack" style={{ gap: 10, padding: 12 }}>
<div className="sd-mine">
{myCards.map((c) => {
const chosen = v.myChosen?.includes(c.id);
return (
<HwatuCard
key={c.key}
card={c}
size="lg"
selected={choosing && pick.includes(c.id)}
highlight={!!chosen}
dimmed={!!v.myChosen && !chosen}
onClick={choosing ? () => toggle(c.id) : undefined}
/>
);
})}
{v.myHand && (
<div className="stack" style={{ gap: 2, minWidth: 160, maxWidth: 320 }}>
<span style={{ fontSize: '1.5rem', fontWeight: 900, color: v.myHand.strength ? STRENGTH_COLOR[v.myHand.strength] : undefined }}>
지금: {v.myHand.name}
{v.myHand.strength && <span className="small" style={{ marginLeft: 6 }}>({STRENGTH_KO[v.myHand.strength]})</span>}
</span>
<span className="small muted">{v.myHand.hint}</span>
</div>
)}
</div>
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.</p>}
{choosing && (
<div className="stack" style={{ gap: 8 }}>
<p style={{ margin: 0, textAlign: 'center' }}>
<strong>쓸 카드 2장을 골라 주세요.</strong> 남는 1장은 아무에게도 보이지 않아요.
{preview && (
<>
{' '}
고른 조합: <strong>{preview.name}</strong>
</>
)}
</p>
<div className="row" style={{ justifyContent: 'center' }}>
{v.options.hints && v.myCombos && (
<button type="button" className="btn btn-secondary" onClick={() => setPick([...v.myCombos!.find((c) => c.best)!.cardIds])}>
추천 조합
</button>
)}
<button
type="button"
className="btn btn-primary"
disabled={pick.length !== 2 || pending}
onClick={() => act({ type: 'choose', cardIds: [pick[0], pick[1]] })}
>
{preview ? `${preview.name}(으)로 정하기` : '두 장을 고르세요'}
</button>
</div>
</div>
)}
{v.handPhase === 'choose' && seat.chosen === true && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
</div>
)}
{seat.inHand && v.handPhase === 'betting' && !seat.folded && (
<div className="stack" style={{ gap: 6 }}>
<div className="sd-actions" role="group" aria-label="베팅">
{BET_ORDER.filter((t) => t !== 'full' || v.options.maxRaise === 'full').map((t) => {
const l = legal.get(t);
const enabled = myTurn && !!l && !pending;
const cls = t === 'die' ? 'btn-danger' : t === 'check' || t === 'call' ? 'btn-primary' : 'btn-soft';
return (
<button
key={t}
type="button"
className={`btn ${cls}`}
disabled={!enabled}
title={BET_HELP[t]}
onMouseEnter={() => setHover(t)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(t)}
onBlur={() => setHover(null)}
onClick={() => act({ type: t })}
>
<span>{BET_KO[t]}</span>
<small>{l && l.amount > 0 ? `${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : t === 'die' ? '포기' : l ? '0 칩' : '-'}</small>
</button>
);
})}
</div>
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
{myTurn
? hoverBet
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
: v.toAct
? `${nick(players, v.toAct)}님 차례예요.`
: ''}
</p>
</div>
)}
{v.handPhase === 'handEnd' && <ResultPanel view={v} players={players} />}
{v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && (
<button type="button" className="btn btn-primary btn-block" disabled={seat.ready || pending} onClick={() => act({ type: 'ready' })}>
{seat.ready ? '다른 사람을 기다리는 중…' : v.next === 'rematch' ? '재경기 시작' : '다음 판으로'}
</button>
)}
</div>
);
}
function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
return (
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
<strong>{v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'}</strong>
{v.foldWin && (
<span>
{nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요.
</span>
)}
{v.showdown?.pots.map((p, i) => {
const who = p.winners.map((id) => nick(players, id)).join(' · ');
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name;
return (
<span key={i}>
{v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `}
{p.type === 'win' && (
<>
<strong>{who}</strong> 승리 ({name}
{p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''})
</>
)}
{p.type === 'split' && (
<>
<strong>{who}</strong> 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'}
</>
)}
{p.type === 'rematch' && (
<>
<strong>{REASON_KO[p.reason]}</strong> — {who}님이 다시 겨뤄요. 칩은 그대로 남아요.
</>
)}
</span>
);
})}
</div>
);
}
// ---------------------------------------------------------------- side
interface JokboRow {
name: string;
cards: [number, boolean][];
note: string;
match: (value: number | null, special: SpecialKind | null) => boolean;
}
function jokboRows(o: SeotdaOptions): JokboRow[] {
const v = (lo: number, hi = lo) => (x: number | null, sp: SpecialKind | null) => !sp && x !== null && x >= lo && x <= hi;
const rows: JokboRow[] = [
{ name: '38광땡', cards: [[3, true], [8, true]], note: '가장 센 패', match: v(1000) },
...(o.gwangTtaeng === 'all'
? [
{ name: '18광땡', cards: [[1, true], [8, true]], note: '', match: v(990) } as JokboRow,
{ name: '13광땡', cards: [[1, true], [3, true]], note: '', match: v(980) } as JokboRow,
]
: []),
{ name: '장땡(10땡)', cards: [[10, true], [10, false]], note: '같은 월 두 장 중 최고', match: v(910) },
{ name: '9땡 ~ 삥땡(1땡)', cards: [[9, true], [9, false]], note: '숫자가 클수록 셈', match: v(901, 909) },
{ name: '알리', cards: [[1, true], [2, false]], note: '1 + 2', match: v(806) },
{ name: '독사', cards: [[1, true], [4, false]], note: '1 + 4', match: v(805) },
{ name: '구삥', cards: [[1, true], [9, false]], note: '1 + 9', match: v(804) },
{ name: '장삥', cards: [[1, true], [10, false]], note: '1 + 10', match: v(803) },
{ name: '장사', cards: [[4, false], [10, false]], note: '4 + 10', match: v(802) },
{ name: '세륙', cards: [[4, false], [6, false]], note: '4 + 6', match: v(801) },
{ name: '갑오(9끗)', cards: [[2, false], [7, false]], note: '두 월을 더한 일의 자리 9', match: v(9) },
{ name: '8끗 ~ 1끗', cards: [[3, false], [5, false]], note: '일의 자리가 클수록 셈', match: v(1, 8) },
{ name: '망통(0끗)', cards: [[2, false], [8, false]], note: '가장 약한 패', match: v(0) },
];
const sp = (k: SpecialKind) => (_x: number | null, s: SpecialKind | null) => s === k;
if (o.ttaengjabi !== 'off')
rows.push({
name: '땡잡이',
cards: [[3, true], [7, true]],
note: `${o.ttaengjabi === 'strict' ? '3월 광 + 7월 열끗' : '3월 + 7월 아무 패'}. 상대 최고가 1~9땡이면 이김, 아니면 망통`,
match: sp('ttaengjabi'),
});
if (o.amhaeng) rows.push({ name: '암행어사', cards: [[4, true], [7, true]], note: '4·7월 열끗. 13·18광땡을 잡음, 아니면 1끗', match: sp('amhaeng') });
if (o.gusa) {
rows.push({ name: '구사', cards: [[4, false], [9, true]], note: '4 + 9. 상대가 알리 이하면 재경기, 아니면 3끗', match: sp('gusa') });
if (o.mungGusa !== 'off') {
const upTo = o.mungGusa === 'upTo9' ? '9땡' : o.mungGusa === 'upToJang' ? '장땡' : '8땡';
rows.push({ name: '멍텅구리구사', cards: [[4, true], [9, true]], note: `4·9월 열끗. 상대가 ${upTo} 이하면 재경기`, match: sp('mungGusa') });
}
}
return rows;
}
function JokboTable({ options, mine }: { options: SeotdaOptions; mine?: SeotdaView['myHand'] }) {
const rows = jokboRows(options);
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<tbody>
{rows.map((r) => {
const hit = !!mine && options.hints && r.match(mine.value, mine.special);
return (
<tr key={r.name} style={{ background: hit ? 'var(--warning-soft)' : undefined, borderBottom: '1px solid var(--border)' }} aria-current={hit ? 'true' : undefined}>
<td style={{ padding: '4px 2px', whiteSpace: 'nowrap' }}>
{r.cards.map(([m, sp], i) => (
<HwatuCard key={i} card={illus(m, sp)} size="sm" style={{ marginRight: 2 }} />
))}
</td>
<td style={{ padding: '4px 6px' }}>
<strong>{r.name}</strong>
{hit && <span className="sd-badge" style={{ background: 'var(--warning)', color: '#fff', marginLeft: 4 }}>내 패</span>}
{r.note && <div className="small muted">{r.note}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
const [confirmQuit, setConfirmQuit] = useState(false);
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
return (
<>
<style>{CSS}</style>
<PlayChipNotice />
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
{v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''}
</strong>
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">
학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'}
</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{ranked.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.isDealer ? ' · 선' : ''}
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
{s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
</span>
<Chips n={s.stack} />
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((g, i) => (
<span key={i}>
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
{v.options.endCondition === 'hands' || v.handsPlayed >= 300
? ` (${(v.result!.scores[g[0]!] ?? 0) >= 0 ? '+' : ''}${(v.result!.scores[g[0]!] ?? 0).toLocaleString('ko-KR')} 칩)`
: ''}
</span>
))}
<span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>
</div>
)}
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보표</summary>
<JokboTable options={v.options} mine={v.myHand} />
</details>
{v.history.length > 0 && (
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>판 기록</summary>
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
{[...v.history].reverse().map((r, i) => (
<li key={i} className="small">
<span className="muted">
{r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''}
</span>{' '}
{r.how === 'rematch'
? `재경기로 넘어감 ${formatChips(r.amount)}`
: `${r.winners.map((id) => nick(players, id)).join('·')} ${formatChips(r.amount)}${r.name ? ` · ${r.name}` : ''}${r.how !== 'normal' ? ` · ${REASON_KO[r.how]}` : ''}`}
</li>
))}
</ol>
</details>
)}
{v.rejoin?.mine && (
<Sheet onClose={() => {}} label="재경기 참가">
<h2>구사 재경기에 다시 들어갈까요?</h2>
<p className="muted">
{formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요.
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending || !v.rejoin.canAfford} onClick={() => act({ type: 'rejoin', accept: true })}>
{v.rejoin.canAfford ? `${formatChips(v.rejoin.cost)} 내고 참가` : '칩이 모자라요'}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rejoin', accept: false })}>
참가 안 함
</button>
</div>
</Sheet>
)}
{v.rebuy?.mine && !confirmQuit && (
<Sheet onClose={() => {}} label="칩 다시 받기">
<h2>칩을 모두 잃었어요</h2>
<p className="muted">
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
칩 다시 받기
</button>
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
그만하기
</button>
</div>
</Sheet>
)}
{confirmQuit && v.rebuy?.mine && (
<ConfirmSheet
title="여기서 그만할까요?"
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
confirm="그만하기"
danger
onConfirm={() => act({ type: 'rebuy', accept: false })}
onClose={() => setConfirmQuit(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: SeotdaView, me: string | null): boolean {
if (!me || v.phase === 'finished') return false;
if (v.phase === 'rebuy') return !!v.rebuy?.mine;
if (v.handPhase === 'betting') return v.toAct === me;
if (v.handPhase === 'choose') return v.seats.find((s) => s.id === me)?.chosen === false;
if (v.handPhase === 'rejoin') return !!v.rejoin?.mine;
return false;
}
function statusText(v: SeotdaView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'rebuy') return v.rebuy?.mine ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
switch (v.handPhase) {
case 'betting':
if (v.toAct === me) return v.myHand && v.options.hints ? `내 차례예요 · 지금: ${v.myHand.name}` : '내 차례예요. 베팅해 주세요';
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
case 'choose':
return isMyTurn(v, me) ? '쓸 카드 2장을 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
case 'rejoin':
return v.rejoin?.mine ? '구사 재경기에 참가할지 골라 주세요' : '다이한 사람이 재경기 참가를 고르는 중이에요';
case 'handEnd':
return resultLine(v, players) || '판이 끝났어요';
default:
return '잠시만 기다려 주세요';
}
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<SeotdaOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<SeotdaOptions>) => onChange({ ...o, ...patch });
const tooBig = o.baseUnit * 20 > o.startingChips;
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="카드 수">
<Seg value={o.cardMode} options={[['two', '2장 섯다'], ['three', '3장 섯다(최대 6명)']]} onChange={(cardMode) => set({ cardMode })} />
</Field>
{o.cardMode === 'two' && (
<Field label="카드 나눠 주기">
<Seg value={o.dealMode} options={[['twoRounds', '1장씩 두 번(베팅 2번)'], ['oneRound', '2장 한 번에(베팅 1번)']]} onChange={(dealMode) => set({ dealMode })} />
</Field>
)}
<Field label="시작 칩">
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
</Field>
<Field label="학교(기본 칩) = 삥">
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
{tooBig && <span className="field-error">학교는 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
</Field>
<Field label="끝내는 방식">
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
{o.endCondition === 'hands' && (
<Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />
)}
</Field>
<Field label="행동 제한 시간">
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="최대 레이즈">
<Seg value={o.maxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(maxRaise) => set({ maxRaise })} />
</Field>
<Field label="칩 다시 받기(리바이)">
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
</Field>
<Field label="학교 올리기">
<Seg value={o.schoolUpEvery} options={[[0, '안 올림'], [5, '5판마다 2배'], [10, '10판마다 2배'], [15, '15판마다 2배']]} onChange={(schoolUpEvery) => set({ schoolUpEvery })} />
</Field>
<Field label="다음 판의 선">
<Seg value={o.dealerRule} options={[['winner', '이긴 사람'], ['rotate', '돌아가며']]} onChange={(dealerRule) => set({ dealerRule })} />
</Field>
<Field label="광땡 종류">
<Seg value={o.gwangTtaeng} options={[['all', '38·18·13광땡'], ['only38', '38광땡만']]} onChange={(gwangTtaeng) => set({ gwangTtaeng })} />
</Field>
<Field label="땡잡이(3·7)">
<Seg value={o.ttaengjabi} options={[['strict', '3월 광 + 7월 열끗'], ['loose', '3월 + 7월 아무 패'], ['off', '없음']]} onChange={(ttaengjabi) => set({ ttaengjabi })} />
</Field>
<Field label="특수패">
<Seg value={o.amhaeng ? 'on' : 'off'} options={[['on', '암행어사 켜기'], ['off', '암행어사 끄기']]} onChange={(k) => set({ amhaeng: k === 'on' })} />
<Seg value={o.gusa ? 'on' : 'off'} options={[['on', '구사 재경기 켜기'], ['off', '구사 재경기 끄기']]} onChange={(k) => set({ gusa: k === 'on' })} />
</Field>
{o.gusa && (
<>
<Field label="멍텅구리구사 재경기 범위">
<Seg value={o.mungGusa} options={[['upTo8', '8땡까지'], ['upTo9', '9땡까지'], ['upToJang', '장땡까지'], ['off', '일반 구사로']]} onChange={(mungGusa) => set({ mungGusa })} />
</Field>
<Field label="구사 재경기 때 다이한 사람">
<Seg value={o.rejoinCost} options={[['none', '참가 불가'], ['half', '모인 칩 절반 내고 참가'], ['full', '모인 칩만큼 내고 참가']]} onChange={(rejoinCost) => set({ rejoinCost })} />
</Field>
</>
)}
<Field label="같은 족보일 때">
<Seg value={o.tieRule} options={[['rematch', '재경기'], ['split', '나눠 가짐']]} onChange={(tieRule) => set({ tieRule })} />
</Field>
<Field label="재경기 최대 횟수(넘으면 나눠 가짐)">
<Seg value={o.maxRematches} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(maxRematches) => set({ maxRematches })} />
</Field>
<Field label="재참가·리바이 답 시간">
<Seg value={o.decisionSeconds} options={[[10, '10초'], [15, '15초'], [30, '30초']]} onChange={(decisionSeconds) => set({ decisionSeconds })} />
</Field>
<Field label="도움 표시">
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '내 족보 이름 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: SeotdaOptions): string {
return [
o.cardMode === 'three' ? '3장 섯다' : o.dealMode === 'twoRounds' ? '2장 섯다(1장씩 두 번)' : '2장 섯다(한 번에)',
`시작 ${o.startingChips.toLocaleString('ko-KR')}칩`,
`학교 ${o.baseUnit}칩`,
o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인',
`${o.turnSeconds}초`,
].join(' · ');
}
function Rules({ options }: { options?: SeotdaOptions }) {
const o = options;
return (
<div className="stack">
<p>
화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 <strong>2장의 조합(족보)</strong>이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요.
</p>
<p>
<strong>진행</strong>: 판마다 모두 <strong>학교(기본 칩)</strong>를 내요. 선부터 카드를 받고 차례로 베팅해요.
{o?.cardMode === 'three'
? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.'
: o?.dealMode === 'oneRound'
? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.'
: ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '}
모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요.
</p>
<p>
<strong>베팅</strong>: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반
{o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요.
</p>
<p>
<strong>끗</strong>: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통).
</p>
<JokboTable options={o ?? ({ gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8', hints: false } as SeotdaOptions)} />
<p>
<strong>재경기</strong>: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요.
{o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '}
{o?.maxRematches ?? 3}번을 넘으면 나눠 가져요.
</p>
<p>
<strong>선</strong>: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'}
</p>
<p>
<strong>칩이 떨어지면</strong>:{' '}
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`}
</p>
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
</div>
);
}
const ui: GameUI<SeotdaView, SeotdaOptions> = {
Board: SeotdaBoard,
Side: SeotdaSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;