- 섯다(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>
832 lines
44 KiB
TypeScript
832 lines
44 KiB
TypeScript
/** 섯다 화면 (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;
|