/** 섯다 화면 (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 = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' }; const BET_HELP: Record = { 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 = { normal: '', ttaengjabi: '땡잡이', amhaeng: '암행어사', gusa: '구사 재경기', mungGusa: '멍텅구리구사 재경기', tie: '동점', limit: '재경기 횟수 초과로 나눔', fold: '모두 다이', }; const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, 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; 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 (
b[0]).join(', ')}` : ''}`} >
{u.nickname} {seat.id === me && (나)} {seat.isDealer && ( 선 )} {timed && }
{((seat.lastAction && !seat.folded) || seat.roundBet > 0) && ( {seat.lastAction && !seat.folded && {BET_KO[seat.lastAction.type]} } {seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`} )}
{badges.map(([t, c]) => ( {t} ))}
{seat.cards.length > 0 && (
{seat.cards.map((c, i) => ( 0 ? { marginLeft: -14 } : undefined} /> ))}
)}
{seat.handName && {seat.handName}}
); } function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record }) { 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 (
{v.rematchNo > 0 && v.rematchReason && (
재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]}
)}
모인 칩
{formatChips(v.pot + paid)}
{v.pots.length > 1 && (
{v.pots.map((p, i) => (
{i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
))}
)}
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)} {v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''}
); } function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { const n = v.seats.length; const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me)); const [flash, setFlash] = useState(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 (
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 ; })} {flash &&
{flash}
}

{announce}

); } function resultLine(v: SeotdaView, players: Record): 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; act: (a: unknown) => void; pending: boolean }) { const seat = v.seats.find((s) => s.id === me); const [pick, setPick] = useState([]); const [hover, setHover] = useState(null); useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]); if (!me || !seat) return v.handPhase === 'handEnd' ? : 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 (
{showHand && (
{myCards.map((c) => { const chosen = v.myChosen?.includes(c.id); return ( toggle(c.id) : undefined} /> ); })} {v.myHand && (
지금: {v.myHand.name} {v.myHand.strength && ({STRENGTH_KO[v.myHand.strength]})} {v.myHand.hint}
)}
{seat.folded &&

이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.

} {choosing && (

쓸 카드 2장을 골라 주세요. 남는 1장은 아무에게도 보이지 않아요. {preview && ( <> {' '} 고른 조합: {preview.name} )}

{v.options.hints && v.myCombos && ( )}
)} {v.handPhase === 'choose' && seat.chosen === true &&

골랐어요. 다른 사람을 기다리는 중이에요.

}
)} {seat.inHand && v.handPhase === 'betting' && !seat.folded && (
{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 ( ); })}

{myTurn ? hoverBet ? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}` : '내 차례예요. 버튼에 적힌 칩이 바로 나가요.' : v.toAct ? `${nick(players, v.toAct)}님 차례예요.` : ''}

)} {v.handPhase === 'handEnd' && } {v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && ( )}
); } function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record }) { return (
{v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'} {v.foldWin && ( {nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요. )} {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 ( {v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `} {p.type === 'win' && ( <> {who} 승리 ({name} {p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''}) )} {p.type === 'split' && ( <> {who} 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'} )} {p.type === 'rematch' && ( <> {REASON_KO[p.reason]} — {who}님이 다시 겨뤄요. 칩은 그대로 남아요. )} ); })}
); } // ---------------------------------------------------------------- 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 ( {rows.map((r) => { const hit = !!mine && options.hints && r.match(mine.value, mine.special); return ( ); })}
{r.cards.map(([m, sp], i) => ( ))} {r.name} {hit && 내 패} {r.note &&
{r.note}
}
); } function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const [confirmQuit, setConfirmQuit] = useState(false); const ranked = [...v.seats].sort((a, b) => b.stack - a.stack); return ( <>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`} {v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''} {v.phase !== 'finished' && }
학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'}
    {ranked.map((s) => (
  1. {nick(players, s.id)} {s.isDealer ? ' · 선' : ''} {s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''} {s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
  2. ))}
{v.result && (
최종 순위 {v.result.ranking.map((g, 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')} 칩)` : ''} ))} 점수 = 마지막 칩 − 시작 칩 × 리바이 횟수
)}
족보표
{v.history.length > 0 && (
판 기록
    {[...v.history].reverse().map((r, i) => (
  1. {r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''} {' '} {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]}` : ''}`}
  2. ))}
)} {v.rejoin?.mine && ( {}} label="재경기 참가">

구사 재경기에 다시 들어갈까요?

{formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요.

)} {v.rebuy?.mine && !confirmQuit && ( {}} label="칩 다시 받기">

칩을 모두 잃었어요

놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.

)} {confirmQuit && v.rebuy?.mine && ( 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 { 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const tooBig = o.baseUnit * 20 > o.startingChips; return (
set({ cardMode })} /> {o.cardMode === 'two' && ( set({ dealMode })} /> )} set({ startingChips })} /> set({ baseUnit })} /> {tooBig && 학교는 시작 칩의 20분의 1 이하로 골라 주세요.} set({ endCondition })} /> {o.endCondition === 'hands' && ( set({ handLimit })} /> )} set({ turnSeconds })} /> {more && ( <> set({ maxRaise })} /> set({ rebuy })} /> {o.rebuy === 'limited' && set({ rebuyLimit })} />} set({ schoolUpEvery })} /> set({ dealerRule })} /> set({ gwangTtaeng })} /> set({ ttaengjabi })} /> set({ amhaeng: k === 'on' })} /> set({ gusa: k === 'on' })} /> {o.gusa && ( <> set({ mungGusa })} /> set({ rejoinCost })} /> )} set({ tieRule })} /> set({ maxRematches })} /> set({ decisionSeconds })} /> set({ hints: k === 'on' })} /> )}
); } 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 (

화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 2장의 조합(족보)이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요.

진행: 판마다 모두 학교(기본 칩)를 내요. 선부터 카드를 받고 차례로 베팅해요. {o?.cardMode === 'three' ? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.' : o?.dealMode === 'oneRound' ? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.' : ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '} 모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요.

베팅: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반 {o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요.

끗: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통).

재경기: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요. {o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '} {o?.maxRematches ?? 3}번을 넘으면 나눠 가져요.

선: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'}

칩이 떨어지면:{' '} {o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '} {o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`}

시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.

); } const ui: GameUI = { Board: SeotdaBoard, Side: SeotdaSide, statusText, isMyTurn, Options, optionsSummary, Rules, }; export default ui;