diff --git a/apps/web/src/games/one-card/ColorCard.tsx b/apps/web/src/games/one-card/ColorCard.tsx new file mode 100644 index 0000000..2123402 --- /dev/null +++ b/apps/web/src/games/one-card/ColorCard.tsx @@ -0,0 +1,153 @@ +/** + * 색깔 카드 artwork (own design, docs/games/one-card.md §9, §12): solid colour field, a white diamond in the + * middle, a colour-blind pattern per colour (빨강 점 · 노랑 줄 · 초록 격자 · 파랑 물결) and the colour name. + */ +import { useId } from 'react'; +import type { CardFace, Color } from '@bg/games/one-card'; + +export const COLOR_HEX: Record = { R: '#d32f2f', Y: '#f4b400', G: '#2e7d32', B: '#1565c0' }; +export const COLOR_INK: Record = { R: '#ffffff', Y: '#1f2937', G: '#ffffff', B: '#ffffff' }; +export const COLOR_KO: Record = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' }; +const PATTERN_KO: Record = { R: '점무늬', Y: '줄무늬', G: '격자무늬', B: '물결무늬' }; + +function Pattern({ color, id }: { color: Color; id: string }) { + const ink = color === 'Y' ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.22)'; + switch (color) { + case 'R': + return ( + + + + ); + case 'Y': + return ( + + + + ); + case 'G': + return ( + + + + ); + case 'B': + return ( + + + + ); + } +} + +/** Four colour quadrants used by the wild cards. */ +function Quadrants({ cx, cy, r }: { cx: number; cy: number; r: number }) { + const q = (dx: number, dy: number, c: Color) => ( + 0 ? 1 : 0} ${cx} ${cy + dy * r} Z`} fill={COLOR_HEX[c]} /> + ); + return ( + + {q(1, -1, 'R')} + {q(1, 1, 'B')} + {q(-1, 1, 'G')} + {q(-1, -1, 'Y')} + + ); +} + +function SkipIcon({ cx, cy, r, color }: { cx: number; cy: number; r: number; color: string }) { + return ( + + + + + ); +} + +function ReverseIcon({ cx, cy, s, color }: { cx: number; cy: number; s: number; color: string }) { + return ( + + + + + ); +} + +export function colorCardLabel(c: CardFace): string { + switch (c.kind) { + case 'num': + return `${COLOR_KO[c.color]} ${c.value}`; + case 'skip': + return `${COLOR_KO[c.color]} 건너뛰기`; + case 'reverse': + return `${COLOR_KO[c.color]} 방향 바꾸기`; + case 'draw2': + return `${COLOR_KO[c.color]} +2`; + case 'wild': + return '색 바꾸기'; + case 'wild4': + return '색 바꾸기+4'; + default: + return ''; + } +} + +/** Pure artwork (no interaction); size comes from the parent box (viewBox 100 × 140). */ +export function ColorCardArt({ card }: { card: CardFace | null }) { + const pid = `occ${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; + if (!card) { + return ( + + + + + + + ); + } + const wild = card.kind === 'wild' || card.kind === 'wild4'; + const color: Color | null = 'color' in card ? card.color : null; + const bg = color ? COLOR_HEX[color] : '#1f2433'; + const ink = color ? COLOR_INK[color] : '#f8fafc'; + const corner = card.kind === 'num' ? String(card.value) : card.kind === 'draw2' ? '+2' : card.kind === 'wild4' ? '+4' : card.kind === 'skip' ? '⊘' : card.kind === 'reverse' ? '⇄' : '★'; + const centerInk = color ? COLOR_HEX[color] : '#1f2433'; + return ( + + {color && ( + + + + )} + + {color && } + {/* white diamond (not an oval) */} + + {wild ? ( + <> + + {card.kind === 'wild4' && ( + + +4 + + )} + + ) : card.kind === 'skip' ? ( + + ) : card.kind === 'reverse' ? ( + + ) : ( + + {card.kind === 'draw2' ? '+2' : corner} + + )} + + {corner} + + + {color ? COLOR_KO[color] : wild ? '색 바꾸기' : ''} + + + ); +} + +export const patternName = (c: Color): string => PATTERN_KO[c]; diff --git a/apps/web/src/games/one-card/index.tsx b/apps/web/src/games/one-card/index.tsx new file mode 100644 index 0000000..a185f67 --- /dev/null +++ b/apps/web/src/games/one-card/index.tsx @@ -0,0 +1,1114 @@ +/** 원카드 / 색깔 카드 화면 (docs/games/one-card.md §9). */ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; +import type { Card, CardFace, Color, LogEntry, OneCardOptions, OneCardView, SeatView, Suit } from '@bg/games/one-card'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, Sheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { PlayingCard } from '../../components/cards/PlayingCard'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { COLOR_HEX, COLOR_INK, COLOR_KO, ColorCardArt, colorCardLabel, patternName } from './ColorCard'; + +const SUITS: Suit[] = ['S', 'H', 'D', 'C']; +const COLORS: Color[] = ['R', 'Y', 'G', 'B']; +const SUIT_SYMBOL: Record = { S: '♠', H: '♥', D: '◆', C: '♣' }; +const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; +const SUIT_INK: Record = { S: '#111827', H: '#dc2626', D: '#dc2626', C: '#111827' }; +const RANK_LABEL: Record = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' }; +const RANK_KO: Record = { 1: '에이스', 11: '잭', 12: '퀸', 13: '킹' }; +const TRUMP_RANK: Record = { 1: 'A', 10: 'T', 11: 'J', 12: 'Q', 13: 'K' }; +const MODE_KO: Record = { classic: '원카드', 'uno-style': '색깔 카드' }; + +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 rankLabel = (r: number) => RANK_LABEL[r] ?? String(r); + +/** Short name, e.g. "♥7", "흑백 조커", "빨강 5". */ +function faceName(c: CardFace): string { + if (c.kind === 'std') return `${SUIT_SYMBOL[c.suit]}${rankLabel(c.rank)}`; + if (c.kind === 'joker') return c.joker === 'black' ? '흑백 조커' : '컬러 조커'; + return colorCardLabel(c); +} +/** Screen-reader name, e.g. "하트 7", "스페이드 에이스". */ +function ariaName(c: CardFace): string { + if (c.kind === 'std') return `${SUIT_KO[c.suit]} ${RANK_KO[c.rank] ?? c.rank}`; + return faceName(c); +} +function trumpId(c: CardFace): string { + if (c.kind === 'joker') return c.joker === 'black' ? 'X1' : 'X2'; + if (c.kind !== 'std') return ''; + return `${TRUMP_RANK[c.rank] ?? String(c.rank)}${c.suit}`; +} + +function sortHand(hand: Card[], by: 'suit' | 'rank'): Card[] { + const suitIdx = (c: Card) => (c.kind === 'std' ? SUITS.indexOf(c.suit) : 'color' in c ? COLORS.indexOf(c.color) : 9); + const rank = (c: Card) => + c.kind === 'std' ? (c.rank === 1 ? 14 : c.rank) : c.kind === 'num' ? c.value : c.kind === 'skip' ? 10 : c.kind === 'reverse' ? 11 : c.kind === 'draw2' ? 12 : c.kind === 'wild' ? 20 : c.kind === 'wild4' ? 21 : 30; + return [...hand].sort((a, b) => (by === 'suit' ? suitIdx(a) - suitIdx(b) || rank(a) - rank(b) : rank(a) - rank(b) || suitIdx(a) - suitIdx(b))); +} + +const CSS = ` +.oc { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; } +.oc-opps { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 6px; margin: 0; list-style: none; scrollbar-width: thin; } +.oc-seat { flex: none; min-width: 112px; max-width: 170px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; + border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 3px; font-size: .9rem; } +.oc-seat.turn { border-color: var(--focus); } +.oc-seat.gone { opacity: .55; } +.oc-seat .nm { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.oc-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; } +.oc-count i { display: inline-block; width: 12px; height: 16px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; } +.oc-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; line-height: 1.6; } +.oc-one { background: #f59e0b; color: #1f2937; animation: oc-pop .25s ease; } +.oc-table { position: relative; border-radius: 24px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); + border: 8px solid var(--wood); box-shadow: var(--shadow); padding: 14px; display: grid; grid-template-columns: auto auto 1fr; gap: 16px; align-items: center; color: #fff; } +.oc-pile { position: relative; width: var(--pw); height: calc(var(--pw) * 1.4); padding: 0; border: 0; background: none; border-radius: 10px; cursor: default; } +.oc-pile[data-can='true'] { cursor: pointer; } +.oc-pile[data-can='true']::after { content: ''; position: absolute; inset: -5px; border-radius: 14px; border: 3px dashed #fde047; } +.oc-pile .cnt { position: absolute; right: -6px; bottom: -8px; background: #111827; color: #fff; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .85rem; } +.oc-discard { position: relative; width: calc(var(--pw) * 1.12); height: calc(var(--pw) * 1.12 * 1.4); } +.oc-discard > .oc-face { position: absolute; inset: 0; } +.oc-discard > .oc-face.top { animation: oc-fly .28s ease-out; } +.oc-discard[data-drop='true'] { outline: 3px dashed #fde047; outline-offset: 4px; border-radius: 10px; } +.oc-info { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; min-width: 0; text-shadow: 0 1px 2px rgba(0,0,0,.5); } +.oc-info .row { gap: 6px; } +.oc-attack { background: #b91c1c; color: #fff; font-size: 1.5rem; font-weight: 900; border-radius: 12px; padding: 2px 12px; animation: oc-pop .3s ease; text-shadow: none; } +.oc-dir { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; } +.oc-dir b { display: inline-block; font-size: 1.5rem; transition: transform .3s ease; } +.oc-dir[data-dir='-1'] b { transform: scaleX(-1); } +.oc-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.35); border-radius: 999px; padding: 2px 10px; font-weight: 800; } +.oc-swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px solid #fff; display: inline-block; } +.oc-face { display: block; width: 100%; height: 100%; } +.oc-face svg, .oc-face > span { width: 100% !important; height: 100% !important; display: block; } +.oc-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } +.oc-hand { display: flex; align-items: flex-end; padding-top: 16px; min-height: calc(var(--cw) * 1.4 + 18px); } +.oc-hand.wrap { flex-wrap: wrap; row-gap: 18px; } +.oc-card { position: relative; flex: none; width: var(--cw); height: calc(var(--cw) * 1.4); padding: 0; border: 0; background: none; border-radius: 9px; + transition: transform .12s ease, opacity .12s ease; cursor: pointer; } +.oc-card + .oc-card { margin-left: var(--ov); } +.oc-card[data-legal='true'] { box-shadow: 0 0 0 3px var(--success); } +.oc-card[data-legal='false'] { opacity: .5; filter: grayscale(.4); cursor: default; } +.oc-card[aria-pressed='true'] { transform: translateY(-16px); box-shadow: 0 0 0 4px var(--primary); z-index: 2; } +.oc-card:hover[data-legal='true'] { transform: translateY(-6px); z-index: 1; } +.oc-card[aria-pressed='true']:hover { transform: translateY(-16px); } +.oc-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; z-index: 3; } +.oc-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.oc-actions .btn { flex: 1 1 auto; } +.oc-onebtn { flex: none !important; width: 76px; height: 76px; border-radius: 50%; border: 3px solid #b45309; background: #fbbf24; color: #1f2937; + font-weight: 900; font-size: 1.02rem; line-height: 1.1; padding: 0; cursor: pointer; box-shadow: 0 3px 8px rgba(0,0,0,.25); } +.oc-onebtn:disabled { background: var(--surface-2); color: var(--text-sub); border-color: var(--border); box-shadow: none; cursor: not-allowed; } +.oc-onebtn:not(:disabled) { animation: oc-shake .3s ease-in-out 3; } +.oc-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; } +.oc-tools .segmented button { min-height: 40px; padding: 0 12px; } +.oc-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 8px; } +.oc-panel.warn { border: 3px solid var(--warning); } +.oc-mini { display: flex; flex-wrap: wrap; gap: 4px; } +.oc-mini .oc-face { width: 40px; height: 56px; } +.oc-pick { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.oc-pick button { min-height: 72px; font-size: 1.3rem; font-weight: 900; border-radius: 14px; border: 3px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; } +.oc-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 240px; overflow: auto; } +.oc-rules td, .oc-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } +.oc-rules .oc-face { width: 34px; height: 48px; } +@keyframes oc-fly { from { transform: translate(-40px, 30px) scale(.8); opacity: .3; } to { transform: none; opacity: 1; } } +@keyframes oc-pop { 0% { transform: scale(.6); } 70% { transform: scale(1.12); } 100% { transform: scale(1); } } +@keyframes oc-shake { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-8deg); } 75% { transform: rotate(8deg); } } +@media (max-width: 719px) { + .oc-table { grid-template-columns: auto auto; padding: 10px; gap: 10px; border-width: 6px; justify-content: center; } + .oc-info { grid-column: 1 / -1; align-items: center; } + .oc-info .row { justify-content: center; } + .oc-seat { min-width: 96px; padding: 5px 6px; } + .oc-onebtn { width: 64px; height: 64px; font-size: .92rem; } +} +`; + +// ---------------------------------------------------------------- cards + +function Face({ card, mode }: { card: CardFace | null; mode: OneCardView['mode'] }) { + return ( + + {mode === 'classic' ? : } + + ); +} + +// ---------------------------------------------------------------- seats + +function seatLabel(s: SeatView, players: Record, me: string | null, v: OneCardView): string { + const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, `카드 ${s.cards}장`]; + if (s.status === 'out') parts.push('카드를 모두 냄'); + if (s.status === 'bankrupt') parts.push('파산'); + if (s.status === 'left') parts.push('나감'); + if (s.away) parts.push('자리 비움'); + if (s.isTurn) parts.push('차례'); + if (s.oneCard === 'open') parts.push('원카드 외치기 전'); + if (s.oneCard === 'declared') parts.push('원카드 외침'); + if (v.options.unoTargetScore > 0 && v.mode === 'uno-style') parts.push(`${s.score}점`); + return parts.join(', '); +} + +function SeatChip({ s, v, players, me, deadline }: { s: SeatView; v: OneCardView; players: Record; me: string | null; deadline: number | null }) { + const u = userOf(players, s.id); + const place = v.result?.players.find((p) => p.player === s.id)?.rank; + return ( +
  • +
    + + {u.nickname} + {s.isTurn && } +
    +
    + + + {s.cards} + + {s.oneCard && ( + + {s.oneCard === 'declared' ? '원카드!' : '1장!'} + + )} + {s.status === 'out' && {place ? `${place}등` : '다 냄'}} + {s.status === 'bankrupt' && 파산} + {s.status === 'left' && 나감} + {s.away && 자리 비움} + {v.mode === 'uno-style' && v.options.unoTargetScore > 0 && {s.score}점} +
    +
  • + ); +} + +// ---------------------------------------------------------------- table + +function TableArea({ v, players, canDraw, onDraw, onDropCard }: { v: OneCardView; players: Record; canDraw: boolean; onDraw: () => void; onDropCard: (id: string) => void }) { + const [over, setOver] = useState(false); + const pw = 'clamp(64px, 9vw, 92px)'; + const recent = v.recent; + const top = recent[recent.length - 1]; + const drawText = v.pendingAttack > 0 ? `${v.pendingAttack}장 먹기` : '1장 먹기'; + return ( +
    + +
    { + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + const id = e.dataTransfer.getData('text/plain'); + if (id) onDropCard(id); + }} + > + {recent.slice(-3).map((c, i, arr) => { + const last = i === arr.length - 1; + const rot = last ? 0 : (i - arr.length + 1) * 9; + return ( + + + + ); + })} +
    +
    + {v.pendingAttack > 0 && ( + + +{v.pendingAttack}장! + + )} +
    + + ↻ + {v.direction === 1 ? '시계 방향' : '반대 방향'} + + {v.extraTurn && K: 한 번 더} +
    + {v.declaredSuit && ( + + 지정 무늬 {SUIT_SYMBOL[v.declaredSuit]} {SUIT_KO[v.declaredSuit]} + + )} + {v.mode === 'uno-style' && ( + + {v.currentColor ? ( + <> + + 지금 색: {COLOR_KO[v.currentColor]} + {v.declaredColor ? ' (지정)' : ''} + + ) : ( + '아무 색이나 낼 수 있어요' + )} + + )} + {v.reference && 조커 밑 기준: {faceName(v.reference)}} + {v.wild4 && ( + + {nick(players, v.wild4.target)}님이 의심할지 고르는 중 + + )} +
    +
    + ); +} + +// ---------------------------------------------------------------- my hand + +function useWidth(): [React.RefObject, number] { + const ref = useRef(null); + const [w, setW] = useState(0); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setW(el.clientWidth); + const ro = new ResizeObserver(() => setW(el.clientWidth)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return [ref, w]; +} + +interface PickState { + ids: string[]; + kind: 'suit' | 'color'; +} + +function MyHand({ v, act, pending, players }: { v: OneCardView; act: (a: unknown) => void; pending: boolean; players: Record }) { + const my = v.my!; + const [sortBy, setSortBy] = useState<'suit' | 'rank'>('suit'); + const [sel, setSel] = useState([]); + const [declare, setDeclare] = useState(true); + const [hints, setHints] = useState(true); + const [pick, setPick] = useState(null); + const [note, setNote] = useState(''); + const [ref, width] = useWidth(); + const hand = sortHand(my.hand, sortBy); + const legal = new Set(my.legalCardIds); + const turnKey = `${v.turnNo}-${v.phase}`; + useEffect(() => { + setSel([]); + setPick(null); + setNote(''); + }, [turnKey]); + useEffect(() => setDeclare(true), [v.turnNo]); + + const byId = (id: string) => my.hand.find((c) => c.id === id); + const sameRank = (a: Card, b: Card) => a.kind === 'std' && b.kind === 'std' && a.rank === b.rank; + const leavesOne = my.hand.length - Math.max(1, sel.length) === 1; + + const send = (ids: string[], extra: Record = {}) => { + const leaves = my.hand.length - ids.length === 1; + act({ type: 'play', cardIds: ids, ...extra, ...(leaves && declare ? { declareOneCard: true } : {}) }); + setSel([]); + setPick(null); + }; + const playIds = (ids: string[]) => { + const first = byId(ids[0]!); + if (!first || !legal.has(first.id) || pending) return; + if (first.kind === 'std' && first.rank === 7) setPick({ ids, kind: 'suit' }); + else if (first.kind === 'wild' || first.kind === 'wild4') setPick({ ids, kind: 'color' }); + else send(ids); + }; + const tap = (c: Card) => { + if (pending) return; + if (sel.includes(c.id)) { + if (sel[0] === c.id || !my.multiPlay) playIds(sel); + else setSel(sel.filter((x) => x !== c.id)); + return; + } + const first = sel[0] ? byId(sel[0]) : undefined; + if (my.multiPlay && first && sameRank(first, c) && v.phase === 'turn') { + setSel([...sel, c.id]); + return; + } + if (!legal.has(c.id)) { + setNote(`${ariaName(c)}은(는) 지금 낼 수 없어요.`); + return; + } + setNote(''); + setSel([c.id]); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const btns = Array.from(e.currentTarget.querySelectorAll('button.oc-card')); + const i = btns.indexOf(document.activeElement as HTMLButtonElement); + if (i < 0) return; + e.preventDefault(); + btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus(); + }; + + // Layout: one overlapping row on wide screens, wrapped rows on phones (cards ≥ 48 × 68). + const n = hand.length; + const phone = width > 0 && width < 600; + const cw = phone ? 54 : 78; + let ov = 6; + if (!phone && n > 1) ov = Math.max(-cw * 0.6, Math.min(6, (width - cw * n) / (n - 1))); + if (phone) ov = n > 12 ? -14 : 4; + const myTurn = v.current === v.me && (v.phase === 'turn' || v.phase === 'afterDraw' || v.phase === 'wild4Response'); + const selCard = sel[0] ? byId(sel[0]) : undefined; + const hintText = selCard && hints ? my.hints[selCard.id] : ''; + const counters = v.pendingAttack > 0 && myTurn ? my.legalCardIds.map((id) => byId(id)).filter((c): c is Card => !!c) : []; + + return ( +
    +
    + + 내 카드 {n}장{my.drawnCardId ? ' · 방금 먹은 카드를 낼 수 있어요' : ''} + +
    +
    + + +
    + +
    +
    +
    + {hand.map((c) => { + const isLegal = legal.has(c.id) || (my.multiPlay && !!selCard && sameRank(selCard, c) && v.phase === 'turn'); + const picked = sel.includes(c.id); + const reason = hints && legal.has(c.id) ? my.hints[c.id] : ''; + return ( + + ); + })} + {n === 0 && 손에 카드가 없어요.} +
    + {(note || hintText || counters.length > 0) && ( +

    + {note || hintText} + {!note && counters.length > 0 && ` 막을 수 있는 카드: ${counters.map(faceName).join(', ')}`} + {!note && v.pendingAttack > 0 && myTurn && counters.length === 0 && ' 막을 카드가 없어요. 카드를 먹어 주세요.'} +

    + )} + 0 && legal.has(sel[0]!)} + playLabel={sel.length > 1 ? `${sel.length}장 내기` : '내기'} + onPlay={() => playIds(sel)} + leavesOne={leavesOne && sel.length > 0} + declare={declare} + setDeclare={setDeclare} + /> + {pick && ( + setPick(null)} label={pick.kind === 'suit' ? '무늬 고르기' : '색 고르기'}> +

    {pick.kind === 'suit' ? '다음 무늬를 골라 주세요' : '다음 색을 골라 주세요'}

    +
    + {pick.kind === 'suit' + ? SUITS.map((s) => ( + + )) + : COLORS.map((c) => ( + + ))} +
    + +
    + )} +
    + ); +} + +function ActionBar(props: { + v: OneCardView; + act: (a: unknown) => void; + pending: boolean; + players: Record; + canPlay: boolean; + playLabel: string; + onPlay: () => void; + leavesOne: boolean; + declare: boolean; + setDeclare: (b: boolean) => void; +}) { + const { v, act, pending, players, canPlay, playLabel, onPlay, leavesOne, declare, setDeclare } = props; + const my = v.my!; + const w = v.oneCardWindow; + const oneLabel = !w ? '원카드' : w.player === v.me ? '원카드 외치기' : `${nick(players, w.player)}님 잡기`; + const myTurn = v.current === v.me && (v.phase === 'turn' || v.phase === 'afterDraw' || (v.phase === 'wild4Response' && my.legalCardIds.length > 0)); + return ( +
    + {myTurn && ( + + )} + {my.canDraw && ( + + )} + {my.canPass && ( + + )} + {myTurn && leavesOne && ( + + )} + +
    + ); +} + +// ---------------------------------------------------------------- prompts + +function Wild4Panel({ v, players, act, pending }: { v: OneCardView; players: Record; act: (a: unknown) => void; pending: boolean }) { + if (!v.wild4 || !v.my?.canChallenge) return null; + const by = nick(players, v.wild4.by); + return ( +
    + {by}님이 색 바꾸기+4를 냈어요. + + 의심하면 {by}님 카드를 나만 볼 수 있어요. 낼 수 없었는데 냈다면 {by}님이 {v.pendingAttack}장을 먹고, 아니면 내가 {v.pendingAttack + 2}장을 먹어요. + {v.options.unoStacking ? ' 색 바꾸기+4가 있으면 받아칠 수도 있어요.' : ''} + +
    + + +
    +
    + ); +} + +function RevealPanel({ v, players }: { v: OneCardView; players: Record }) { + if (!v.reveal) return null; + const by = nick(players, v.reveal.by); + return ( +
    + {v.reveal.illegal ? `의심 성공! ${by}님은 지금 색 카드가 있었어요.` : `${by}님은 규칙대로 냈어요. 의심이 틀렸어요.`} + {by}님의 카드(나만 볼 수 있어요) +
    + {v.reveal.cards.map((c, i) => ( + + + + ))} +
    +
    + ); +} + +function RoundPanel({ v, players, act, pending }: { v: OneCardView; players: Record; act: (a: unknown) => void; pending: boolean }) { + if (v.phase !== 'roundEnd') return null; + const last = v.rounds[v.rounds.length - 1]; + return ( +
    + + {v.round}판 결과: {last?.winner ? `${nick(players, last.winner)}님 +${last.points}점` : '이긴 사람 없음'} + + + {[...v.seats] + .sort((a, b) => b.score - a.score) + .map((s) => `${nick(players, s.id)} ${s.score}점`) + .join(' · ')}{' '} + (목표 {v.options.unoTargetScore}점) + + {v.my?.canReady ? ( + + ) : ( + 다른 사람을 기다리는 중이에요… + )} +
    + ); +} + +// ---------------------------------------------------------------- log + +function logText(e: LogEntry, players: Record): string { + const n = (id: string) => `${nick(players, id)}님`; + switch (e.t) { + case 'play': { + const extra = e.suit ? ` → ${SUIT_SYMBOL[e.suit]} 무늬로` : e.color ? ` → ${COLOR_KO[e.color]}으로` : ''; + return `${n(e.p)}: ${e.cards.map(faceName).join(' ')}${extra}`; + } + case 'draw': + switch (e.why) { + case 'attack': + return `${n(e.p)}이 공격을 받아 ${e.n}장 먹었어요`; + case 'penalty': + return `${n(e.p)}이 원카드에 잡혀 ${e.n}장 먹었어요`; + case 'draw2': + case 'wild4': + return `${n(e.p)}이 ${e.n}장 먹고 차례를 넘겼어요`; + case 'challenge': + return `의심 결과로 ${n(e.p)}이 ${e.n}장 먹었어요`; + case 'start': + return `시작 카드가 +2라서 ${n(e.p)}이 ${e.n}장 먹었어요`; + default: + return `${n(e.p)}이 ${e.n}장 먹었어요`; + } + case 'pass': + return `${n(e.p)}이 넘겼어요`; + case 'blocked': + return `${n(e.p)}이 3으로 공격을 막았어요`; + case 'declare': + return `${n(e.p)}이 원카드를 외쳤어요!`; + case 'caught': + return `${n(e.by)}이 먼저 외쳐서 ${n(e.p)}이 ${e.n}장 먹었어요`; + case 'bankrupt': + return `${n(e.p)}이 파산했어요`; + case 'out': + return `${n(e.p)}이 카드를 모두 냈어요 (${e.place}등)`; + case 'left': + return `${n(e.p)}이 나갔어요`; + case 'timeout': + return `${n(e.p)}의 시간이 다 됐어요`; + case 'reshuffle': + return `버린 카드 ${e.n}장을 섞어 더미를 채웠어요`; + case 'challenge': + return `${n(e.p)}이 의심했어요: ${e.illegal ? `${n(e.by)}이 규칙을 어겼어요` : '규칙대로 냈어요'}`; + case 'skip': + return `${n(e.p)} 차례를 건너뛰어요`; + case 'reverse': + return '진행 방향이 바뀌었어요'; + case 'round': + return `${e.round}판 끝 · ${e.winner ? `${n(e.winner)} +${e.points}점` : '이긴 사람 없음'}`; + } +} + +// ---------------------------------------------------------------- board + +function OneCardBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [announce, setAnnounce] = useState(''); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + const lastLog = v.log[v.log.length - 1]; + useEffect(() => { + if (mine && !prevMine.current) { + haptic(40); + sounds.myTurn(); + setAnnounce(v.pendingAttack > 0 ? `내 차례예요. 공격 ${v.pendingAttack}장을 받고 있어요.` : '내 차례예요.'); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (lastLog && !mine) setAnnounce(logText(lastLog, players)); + }, [lastLog?.seq, lastLog?.t]); // eslint-disable-line react-hooks/exhaustive-deps + + // Opponents in turn order after me (spectators: seat order). + const myIdx = v.seats.findIndex((s) => s.id === me); + const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)]; + const canDraw = !!v.my?.canDraw && !pending; + const meSeat = v.seats.find((s) => s.id === me); + return ( +
    + +
      + {others.map((s) => ( + + ))} +
    + act({ type: 'draw' })} + onDropCard={(id) => { + if (v.my?.legalCardIds.includes(id)) { + const c = v.my.hand.find((x) => x.id === id); + if (c && !((c.kind === 'std' && c.rank === 7) || c.kind === 'wild' || c.kind === 'wild4')) { + const leaves = v.my.hand.length === 2; + act({ type: 'play', cardIds: [id], ...(leaves ? { declareOneCard: true } : {}) }); + } + } + }} + /> + + + + {v.my && meSeat && meSeat.status === 'playing' && v.phase !== 'roundEnd' && v.phase !== 'finished' && } + {meSeat && meSeat.status !== 'playing' && v.phase !== 'finished' && v.phase !== 'roundEnd' && ( +
    + + {meSeat.status === 'out' ? '카드를 모두 냈어요! 다른 사람의 게임을 지켜봐요.' : meSeat.status === 'bankrupt' ? '파산했어요. 남은 게임을 지켜봐요.' : '게임에서 나갔어요.'} + + {meSeat.status === 'out' && v.my?.canDeclare && ( + + )} +
    + )} +

    + {announce} +

    +
    + ); +} + +// ---------------------------------------------------------------- side + +function rulesLine(v: Pick): string { + const o = v.options; + if (v.mode === 'classic') { + return `공격: 2 ${o.attack2}장 · A ${o.attackA}장 · ♠A ${o.attackSpadeA}장 · 흑백 조커 ${o.attackBlackJoker}장 · 컬러 조커 ${o.attackColorJoker}장 · 원카드 벌칙 ${v.penalty}장 · ${v.bankruptLimit ? `파산 ${v.bankruptLimit}장` : '파산 없음'}`; + } + const w4 = o.unoWild4Rule === 'strict' ? '+4는 지금 색이 없을 때만' : o.unoWild4Rule === 'challenge' ? '+4 의심하기 가능' : '+4 제한 없음'; + return `${w4} · ${o.unoStacking ? '+2·+4 받아치기 됨' : '받아치기 없음'} · 원카드 벌칙 ${v.penalty}장${v.bankruptLimit ? ` · 파산 ${v.bankruptLimit}장` : ''}${o.unoTargetScore ? ` · 목표 ${o.unoTargetScore}점` : ''}`; +} + +function OneCardSide({ view: v, me, players, deadline }: BoardProps) { + const scoreMode = v.mode === 'uno-style' && v.options.unoTargetScore > 0; + const waitingId = v.current; + return ( + <> + +
    +
    + + {MODE_KO[v.mode]} + {scoreMode ? ` · ${v.round}판` : ''} + {v.phase === 'finished' ? ' · 게임 끝' : ''} + + {waitingId && v.phase !== 'finished' && } +
    + {rulesLine(v)} +
      + {v.seats.map((s) => ( +
    1. + + + {s.isTurn ? '▶ ' : ''} + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {s.status === 'out' ? ' · 다 냄' : s.status === 'bankrupt' ? ' · 파산' : s.status === 'left' ? ' · 나감' : s.away ? ' · 자리 비움' : ''} + + {scoreMode && {s.score}점} + + + {s.cards} + +
    2. + ))} +
    +
    + {v.result && ( +
    + 최종 순위 + {v.result.players.map((p) => ( + + {p.rank}등 {nick(players, p.player)} + + {' '} + · {p.status === 'bankrupt' ? '파산' : p.status === 'left' ? '나감' : `남은 카드 ${p.cardsLeft}장`} + {v.mode === 'uno-style' ? ` · ${p.points}점` : p.cardsLeft > 0 ? ` (${p.points}점)` : ''} + + + ))} + {v.result.summary} +
    + )} + {v.log.length > 0 && ( +
    + 기록 +
      + {[...v.log].reverse().map((e) => ( +
    1. + {logText(e, players)} +
    2. + ))} +
    +
    + )} +
    + 특수 카드 + +
    + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: OneCardView, me: string | null): boolean { + if (!me || !v.my) return false; + if (v.phase === 'roundEnd') return v.my.canReady; + if (v.phase === 'finished') return false; + return v.current === me; +} + +function statusText(v: OneCardView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (v.phase === 'roundEnd') return v.my?.canReady ? `${v.round}판이 끝났어요. 다음 판으로 넘어가 주세요` : `${v.round}판이 끝났어요. 다른 사람을 기다리는 중이에요`; + if (me && v.current === me) { + if (v.phase === 'wild4Response') return '내 차례예요. 색 바꾸기+4를 의심할지 골라 주세요'; + if (v.phase === 'afterDraw') return '내 차례예요. 먹은 카드를 낼지 골라 주세요'; + if (v.pendingAttack > 0) return `내 차례예요 · 공격 ${v.pendingAttack}장! 막거나 먹어 주세요`; + if (v.extraTurn) return '내 차례예요 · K로 한 번 더 해요'; + return '내 차례예요'; + } + const w = v.oneCardWindow; + if (w && !w.declared && w.player === me) return '카드가 1장 남았어요. 원카드를 외치세요!'; + if (!v.current) return '잠시만 기다려 주세요'; + if (v.phase === 'wild4Response') return `${nick(players, v.current)}님이 의심할지 고르는 중이에요`; + return `${nick(players, v.current)}님 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
    + {options.map(([k, l]) => ( + + ))} +
    + ); +} + +function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { + return ( +
    + {label} + {children} + {help && {help}} +
    + ); +} + +function NumSelect({ value, min, max, onChange, label }: { value: number; min: number; max: number; onChange: (n: number) => void; label: string }) { + return ( + + ); +} + +const onOff: [boolean, string][] = [ + [true, '켜기'], + [false, '끄기'], +]; + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const classic = o.mode === 'classic'; + return ( +
    + + set({ mode })} /> + + + set({ handSize })} /> + + + set({ endCondition })} /> + + + set({ turnSeconds })} /> + + {!classic && ( + + set({ unoWild4Rule })} + /> + + )} + + {more && classic && ( + <> + +
    + + + + + +
    +
    + + set({ threeDefense })} /> + + + set({ kEffect })} /> + + + set({ qInTwoPlayer })} /> + + + set({ lastCardRule })} + /> + + + set({ afterJoker })} /> + + + set({ multiPlay })} /> + + + set({ deckCount })} /> + + + )} + {more && !classic && ( + <> + + set({ unoStacking })} /> + + + set({ unoTargetScore })} /> + + + )} + {more && ( + <> + + set({ oneCardPenalty })} /> + + + set({ autoDeclare })} /> + + + set({ bankruptLimit })} + /> + + + set({ voluntaryDraw })} /> + + + set({ playAfterDraw })} /> + + + )} +
    + ); +} + +function optionsSummary(o: OneCardOptions): string { + const parts = [MODE_KO[o.mode], `${o.handSize}장`]; + if (o.mode === 'classic') { + parts.push(`2 공격 ${o.attack2}장`); + if (o.multiPlay) parts.push('여러 장 내기'); + } else { + parts.push(o.unoWild4Rule === 'strict' ? '+4 제한' : o.unoWild4Rule === 'challenge' ? '+4 의심하기' : '+4 자유'); + if (o.unoStacking) parts.push('받아치기'); + if (o.unoTargetScore) parts.push(`${o.unoTargetScore}점까지`); + } + if (o.endCondition === 'last-standing') parts.push('꼴찌까지'); + parts.push(`${o.turnSeconds}초`); + return parts.join(' · '); +} + +// ---------------------------------------------------------------- rules + +function SpecialTable({ mode, o }: { mode: OneCardView['mode']; o: OneCardOptions }) { + const C = (c: CardFace) => ; + const rows: [ReactNode, string, string][] = + mode === 'classic' + ? [ + [C({ kind: 'std', suit: 'H', rank: 2 }), '공격', `다음 사람이 ${o.attack2}장 먹어요. 2·같은 무늬 A·조커로 받아칠 수 있어요.`], + [C({ kind: 'std', suit: 'D', rank: 1 }), '공격', `A는 ${o.attackA}장, ♠A는 ${o.attackSpadeA}장. A에는 A·조커로 받아쳐요.`], + [C({ kind: 'joker', joker: 'black' }), '공격', `흑백 조커 ${o.attackBlackJoker}장, 컬러 조커 ${o.attackColorJoker}장. 언제든 낼 수 있어요.`], + [C({ kind: 'std', suit: 'C', rank: 3 }), '방어', o.threeDefense === 'same-suit' ? '같은 무늬 2·A 공격을 막아요(조커는 못 막아요).' : '이 방에서는 일반 카드예요.'], + [C({ kind: 'std', suit: 'S', rank: 7 }), '무늬 바꾸기', '낸 사람이 다음 무늬를 정해요.'], + [C({ kind: 'std', suit: 'H', rank: 11 }), '점프', '다음 사람을 건너뛰어요.'], + [C({ kind: 'std', suit: 'S', rank: 12 }), '방향 바꾸기', '진행 방향이 반대로 바뀌어요.'], + [C({ kind: 'std', suit: 'D', rank: 13 }), 'K', o.kEffect === 'extra-turn' ? '한 번 더 해요.' : o.kEffect === 'skip-two' ? '두 사람을 건너뛰어요.' : '이 방에서는 일반 카드예요.'], + ] + : [ + [C({ kind: 'skip', color: 'R' }), '건너뛰기', '다음 사람을 건너뛰어요.'], + [C({ kind: 'reverse', color: 'Y' }), '방향 바꾸기', '방향이 바뀌어요. 2명이면 건너뛰기와 같아요.'], + [C({ kind: 'draw2', color: 'G' }), '+2', '다음 사람이 2장 먹고 차례를 잃어요.'], + [C({ kind: 'wild' }), '색 바꾸기', '언제든 내고 다음 색을 정해요.'], + [C({ kind: 'wild4' }), '색 바꾸기+4', '색을 정하고 다음 사람이 4장 먹고 차례를 잃어요.'], + ]; + return ( + + + {rows.map(([pic, name, desc]) => ( + + + + + + ))} + +
    {pic} + {name} + {desc}
    + ); +} + +function Rules({ options }: { options?: OneCardOptions }) { + const o: Partial = options ?? {}; + const full: OneCardOptions = { + handSize: 7, + deckCount: 'auto', + attack2: 2, + attackA: 3, + attackSpadeA: 5, + attackBlackJoker: 5, + attackColorJoker: 7, + threeDefense: 'same-suit', + kEffect: 'extra-turn', + qInTwoPlayer: 'no-effect', + lastCardRule: 'joker', + afterJoker: 'previous', + multiPlay: false, + oneCardPenalty: 'auto', + autoDeclare: false, + bankruptLimit: 'auto', + voluntaryDraw: true, + playAfterDraw: 'auto', + endCondition: 'first-out', + turnSeconds: 20, + unoStacking: false, + unoWild4Rule: 'strict', + unoTargetScore: 0, + mode: 'classic', + ...o, + }; + const classic = full.mode === 'classic'; + return ( +
    +

    + 손에 든 카드를 가장 먼저 모두 내면 이겨요. 바닥 카드와 {classic ? '무늬나 숫자' : '색이나 숫자·기호'}가 같은 카드를 한 장 내요. 낼 카드가 없거나 내기 싫으면 더미에서 1장을 먹고 차례를 + 넘겨요. +

    + {classic ? ( +

    + 공격: 2·A·조커를 내면 다음 사람이 그만큼 먹어요. 받은 사람은 받아칠 카드를 내서 장수를 더해 넘기거나, 모인 장수만큼 먹어요. 7을 내면 다음 무늬를 정하고, 그다음 사람은 그 + 무늬나 7만 낼 수 있어요. +

    + ) : ( +

    + 색 바꾸기+4:{' '} + {full.unoWild4Rule === 'strict' + ? '지금 색 카드가 손에 없을 때만 낼 수 있어요.' + : full.unoWild4Rule === 'challenge' + ? '아무 때나 낼 수 있지만, 다음 사람이 의심하기를 할 수 있어요. 지금 색 카드가 있었는데 냈다면 낸 사람이 4장, 아니면 의심한 사람이 6장을 먹어요.' + : '아무 때나 낼 수 있어요.'} +

    + )} + +

    + 원카드: 카드를 내서 1장이 남으면 "원카드!" 버튼을 눌러요. 다른 사람이 먼저 누르면 {full.oneCardPenalty === 'auto' ? (classic ? 1 : 2) : full.oneCardPenalty}장을 + 먹어요. 카드를 낼 때 "내면서 원카드 외치기"를 켜 두면 안전해요. +

    + {classic && ( +

    + 마지막 카드:{' '} + {full.lastCardRule === 'none' + ? '어떤 카드로든 끝낼 수 있어요.' + : full.lastCardRule === 'joker' + ? '조커로는 끝낼 수 없어요.' + : full.lastCardRule === 'attack' + ? '공격 카드(2·A·조커)로는 끝낼 수 없어요.' + : '특수 카드로는 끝낼 수 없어요.'} +

    + )} +

    + 시간 안에 고르지 않으면 카드를 내지 않고 자동으로 먹어요(공격받는 중이면 모인 장수만큼).{' '} + {full.bankruptLimit === 'none' || (full.bankruptLimit === 'auto' && !classic) + ? '' + : `손에 ${full.bankruptLimit === 'auto' ? 20 : full.bankruptLimit}장 이상 쌓이면 파산해서 탈락해요.`} +

    + {!classic && full.unoTargetScore > 0 &&

    여러 판을 해요. 이긴 사람은 다른 사람 손에 남은 카드 점수(숫자 그대로, 기능 카드 20점, 색 바꾸기 50점)를 얻고, {full.unoTargetScore}점에 먼저 닿으면 이겨요.

    } +
    + ); +} + +const ui: GameUI = { + Board: OneCardBoard, + Side: OneCardSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 67eb707..406dcef 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -9,6 +9,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { baduk: () => import('./baduk'), seotda: () => import('./seotda'), poker: () => import('./poker'), + 'one-card': () => import('./one-card'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 6d4e91a..c93875b 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -8,7 +8,8 @@ export const GAME_ICON: Record = { mahjong: '🀇', yut: '🪵', othello: '◐', + 'one-card': '♣', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['윷놀이', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8d6700d..1e61b65 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -77,6 +77,54 @@ const PLANS: Record = { await finishHand(pages); }, }, + 'one-card': { + // 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play, + // pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends. + players: 3, + drive: async (pages) => { + const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); + let turns = 0; + let plays = 0; + let draws = 0; + let finished = false; + for (let t = 0; t < 600 && turns < 60; t++) { + if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) { + finished = true; + break; + } + let acted = false; + for (const p of pages) { + const one = p.locator('.oc-onebtn'); + if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {}); + const before = await statusOf(p); + if (!before.includes('내 차례예요')) continue; + const accept = p.getByRole('button', { name: /^받아들이기/ }); + const card = p.locator('button.oc-card[data-legal="true"]').first(); + if (await accept.count()) await accept.click(); + else if (await card.count()) { + await card.click(); + await card.click(); + const pick = p.locator('.oc-pick button').first(); + if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click(); + plays++; + } else { + const pass = p.getByRole('button', { name: '넘기기' }); + if (await pass.count()) await pass.click(); + else await p.getByRole('button', { name: /장 먹기$/ }).first().click(); + draws++; + } + await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, before.trim(), { timeout: 5000 }).catch(() => {}); + turns++; + acted = true; + break; + } + if (!acted) await pages[0]!.waitForTimeout(150); + } + if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`); + if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') }); + console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`); + }, + }, othello: { // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 6afc773..6d5024e 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -6,6 +6,7 @@ import { janggi } from './janggi'; import { baduk } from './baduk'; import { seotda } from './seotda'; import { poker } from './poker'; +import { oneCard } from './one-card'; export const GAMES: Record = { [omok.id]: omok, @@ -15,4 +16,5 @@ export const GAMES: Record = { [baduk.id]: baduk, [seotda.id]: seotda, [poker.id]: poker, + [oneCard.id]: oneCard, }; diff --git a/packages/games/src/one-card/cards.ts b/packages/games/src/one-card/cards.ts new file mode 100644 index 0000000..e6bb9c5 --- /dev/null +++ b/packages/games/src/one-card/cards.ts @@ -0,0 +1,100 @@ +/** 원카드 카드 정의 (docs/games/one-card.md §3, §8.1). */ + +export type Suit = 'S' | 'H' | 'D' | 'C'; +export type Color = 'R' | 'Y' | 'G' | 'B'; +export const SUITS: Suit[] = ['S', 'H', 'D', 'C']; +export const COLORS: Color[] = ['R', 'Y', 'G', 'B']; + +/** 1 = A, 11 = J, 12 = Q, 13 = K. */ +export type ClassicCard = + | { id: string; kind: 'std'; suit: Suit; rank: number } + | { id: string; kind: 'joker'; joker: 'black' | 'color' }; +export type UnoCard = + | { id: string; kind: 'num'; color: Color; value: number } + | { id: string; kind: 'skip' | 'reverse' | 'draw2'; color: Color } + | { id: string; kind: 'wild' | 'wild4' }; +export type Card = ClassicCard | UnoCard; + +export const SUIT_SYMBOL: Record = { S: '♠', H: '♥', D: '◆', C: '♣' }; +export const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; +export const COLOR_KO: Record = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' }; +const RANK_KO: Record = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' }; +export const rankLabel = (r: number): string => RANK_KO[r] ?? String(r); + +/** Classic deck(s): ids "S1".."C13", "JB"/"JC"; the second deck adds a "b" suffix. */ +export function classicDeck(decks: 1 | 2): ClassicCard[] { + const out: ClassicCard[] = []; + for (let d = 0; d < decks; d++) { + const sfx = d === 0 ? '' : 'b'; + for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push({ id: `${suit}${rank}${sfx}`, kind: 'std', suit, rank }); + out.push({ id: `JB${sfx}`, kind: 'joker', joker: 'black' }); + out.push({ id: `JC${sfx}`, kind: 'joker', joker: 'color' }); + } + return out; +} + +/** Colour-card deck, 108 cards: per colour 0 ×1, 1–9 ×2, skip/reverse/+2 ×2; 4 wild, 4 wild +4. */ +export function unoDeck(): UnoCard[] { + const out: UnoCard[] = []; + for (const color of COLORS) { + out.push({ id: `${color}0`, kind: 'num', color, value: 0 }); + for (const copy of ['a', 'b']) { + for (let value = 1; value <= 9; value++) out.push({ id: `${color}${value}${copy}`, kind: 'num', color, value }); + out.push({ id: `${color}S${copy}`, kind: 'skip', color }); + out.push({ id: `${color}R${copy}`, kind: 'reverse', color }); + out.push({ id: `${color}D${copy}`, kind: 'draw2', color }); + } + } + for (let i = 1; i <= 4; i++) out.push({ id: `W${i}`, kind: 'wild' }); + for (let i = 1; i <= 4; i++) out.push({ id: `F${i}`, kind: 'wild4' }); + return out; +} + +export const isStd = (c: Card): c is Extract => c.kind === 'std'; +export const isJoker = (c: Card): c is Extract => c.kind === 'joker'; +export const isWild = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4'; +export const colorOf = (c: Card): Color | null => ('color' in c ? c.color : null); +export const isSpadeAce = (c: Card): boolean => isStd(c) && c.rank === 1 && c.suit === 'S'; + +/** Korean name, e.g. "♥7", "흑백 조커", "빨강 5", "색 바꾸기+4". */ +export function cardName(c: Card): string { + switch (c.kind) { + case 'std': + return `${SUIT_SYMBOL[c.suit]}${rankLabel(c.rank)}`; + case 'joker': + return c.joker === 'black' ? '흑백 조커' : '컬러 조커'; + case 'num': + return `${COLOR_KO[c.color]} ${c.value}`; + case 'skip': + return `${COLOR_KO[c.color]} 건너뛰기`; + case 'reverse': + return `${COLOR_KO[c.color]} 방향 바꾸기`; + case 'draw2': + return `${COLOR_KO[c.color]} +2`; + case 'wild': + return '색 바꾸기'; + case 'wild4': + return '색 바꾸기+4'; + } +} + +/** Card points: classic tie-break (number, J/Q/K 10, A 1, joker 20); colour cards per §6. */ +export function cardPoints(c: Card): number { + switch (c.kind) { + case 'std': + return c.rank >= 11 ? 10 : c.rank; + case 'joker': + return 20; + case 'num': + return c.value; + case 'skip': + case 'reverse': + case 'draw2': + return 20; + case 'wild': + case 'wild4': + return 50; + } +} + +export const handPoints = (cards: readonly Card[]): number => cards.reduce((a, c) => a + cardPoints(c), 0); diff --git a/packages/games/src/one-card/index.ts b/packages/games/src/one-card/index.ts new file mode 100644 index 0000000..87c85c7 --- /dev/null +++ b/packages/games/src/one-card/index.ts @@ -0,0 +1,1044 @@ +/** 원카드 / 색깔 카드 (docs/games/one-card.md). Pure and deterministic; state is JSON. */ +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; +import { COLORS, SUITS, SUIT_SYMBOL, classicDeck, colorOf, handPoints, isJoker, isStd, unoDeck, type Card, type Color, type Suit } from './cards'; +import { + attackOf, + canBlockWithThree, + canCounter, + canStartWith, + classicMatches, + currentColor, + forbiddenAsLast, + hintFor, + legalCardIds, + referenceCard, + resolvedBankrupt, + resolvedPenalty, + resolvedPlayAfterDraw, + top, + unoCanStack, + unoMatches, +} from './rules'; +import { + oneCardActionSchema, + oneCardOptionsSchema, + type CardFace, + type LogEntry, + type OneCardAction, + type OneCardFinal, + type OneCardOptions, + type OneCardState, + type OneCardView, + type PlayAction, + type PlayerResult, +} from './types'; + +export * from './cards'; +export * from './types'; +export { attackOf, canCounter, canBlockWithThree, currentColor, legalCardIds, referenceCard, resolvedBankrupt, resolvedPenalty, resolvedPlayAfterDraw } from './rules'; + +const AWAY_MS = 5000; +const WILD4_MS = 10_000; +const ROUND_END_MS = 8000; +const AWAY_AFTER = 3; +const LOG_MAX = 30; +const LOG_VIEW = 15; + +interface Ctx { + rng: Rng; + now: number; + events: GameEvent[]; +} + +// ---------------------------------------------------------------- small helpers + +/** + * Copy for `apply`: containers are copied, card objects / log entries / options are shared because + * nothing ever mutates them (much cheaper than structuredClone for 100+ card objects). + */ +function cloneState(s: OneCardState): OneCardState { + const rec = (r: Record): Record => ({ ...r }); + return { + ...s, + seats: [...s.seats], + hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])), + drawPile: [...s.drawPile], + discard: [...s.discard], + phase: { ...s.phase }, + oneCardWindow: s.oneCardWindow ? { ...s.oneCardWindow } : null, + status: rec(s.status), + finishOrder: [...s.finishOrder], + bankruptOrder: [...s.bankruptOrder], + leftOrder: [...s.leftOrder], + timeoutsInRow: rec(s.timeoutsInRow), + away: rec(s.away), + scores: rec(s.scores), + rounds: [...s.rounds], + ready: [...s.ready], + log: [...s.log], + }; +} + +export function playerRange(o: Pick): { min: number; max: number } { + return { min: 2, max: o.mode === 'classic' ? 9 : 10 }; +} + +const alive = (s: OneCardState): PlayerId[] => s.seats.filter((p) => s.status[p] === 'playing'); +const gone = (s: OneCardState, p: PlayerId): boolean => s.leftOrder.includes(p); +export const deckSize = (s: OneCardState): number => (s.mode === 'classic' ? 54 * s.decks : 108); +/** Every card is in exactly one place (test invariant). */ +export const cardTotal = (s: OneCardState): number => + s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0); + +function log(s: OneCardState, e: Record & { t: LogEntry['t'] }): void { + s.log.push({ seq: s.seq, ...e } as LogEntry); + if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); +} + +/** Moves `steps` seats in the current direction over players still in the round (`from` may be out). */ +function advance(s: OneCardState, from: PlayerId, steps: number): PlayerId { + const n = s.seats.length; + if (alive(s).length === 0) return from; + let idx = s.seats.indexOf(from); + let moved = 0; + while (moved < steps) { + idx = (idx + s.direction + n) % n; + if (s.status[s.seats[idx]!] === 'playing') moved++; + } + return s.seats[idx]!; +} + +/** Next player after `from` skipping `skips` people; skipping everyone else comes back to `from`. */ +function nextAfter(s: OneCardState, from: PlayerId, skips: number): PlayerId { + const steps = 1 + skips; + if (s.status[from] === 'playing' && steps >= alive(s).length) return from; + return advance(s, from, steps); +} + +function startTurn(s: OneCardState, p: PlayerId, extra = false): void { + s.current = p; + s.phase = { kind: 'turn' }; + s.extraTurn = extra; + s.turnNo += 1; +} + +function reshuffle(s: OneCardState, ctx: Ctx): void { + // Keep the top card (and, for jokers, everything down to the card that still decides what may follow). + let keepFrom = s.discard.length - 1; + if (s.mode === 'classic') while (keepFrom > 0 && isJoker(s.discard[keepFrom]!)) keepFrom--; + const pool = s.discard.slice(0, keepFrom); + if (pool.length === 0) return; + s.discard = s.discard.slice(keepFrom); + s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(pool)]; + ctx.events.push({ type: 'reshuffled', count: pool.length }); + log(s, { t: 'reshuffle', n: pool.length }); +} + +/** Draws up to `n` cards (refilling once from the discard pile), then applies bankruptcy. */ +function drawCards(s: OneCardState, p: PlayerId, n: number, why: Extract['why'], ctx: Ctx): number { + const hand = s.hands[p]!; + let got = 0; + for (let i = 0; i < n; i++) { + if (s.drawPile.length === 0) reshuffle(s, ctx); + const c = s.drawPile.shift(); + if (!c) break; + hand.push(c); + got++; + } + ctx.events.push({ type: 'cardsDrawn', player: p, count: got, why }); + log(s, { t: 'draw', p, n: got, why }); + if (s.oneCardWindow?.player === p && hand.length !== 1) s.oneCardWindow = null; + const limit = resolvedBankrupt(s.options); + if (limit !== null && hand.length >= limit && s.status[p] === 'playing') goBankrupt(s, p, ctx); + return got; +} + +function removeFromPlay(s: OneCardState, p: PlayerId): void { + // Hand goes to the bottom of the draw pile without shuffling (§5.4). + s.drawPile.push(...s.hands[p]!); + s.hands[p] = []; + if (s.oneCardWindow?.player === p) s.oneCardWindow = null; +} + +function goBankrupt(s: OneCardState, p: PlayerId, ctx: Ctx): void { + s.status[p] = 'bankrupt'; + s.bankruptOrder.push(p); + removeFromPlay(s, p); + ctx.events.push({ type: 'bankrupt', player: p }); + log(s, { t: 'bankrupt', p }); +} + +/** Someone left play outside their own turn action (bankrupt by a catch, or left the room). */ +function repairAfterRemoval(s: OneCardState, p: PlayerId, ctx: Ctx): void { + if (maybeEndRound(s, ctx)) return; + const ph = s.phase; + if (ph.kind === 'wild4Response') { + if (ph.by === p) { + // The +4 of a player who is gone no longer counts; the target just plays on. + s.pendingAttack = 0; + startTurn(s, ph.target); + } else if (ph.target === p) { + s.pendingAttack = 0; + startTurn(s, nextAfter(s, p, 0)); + } + return; + } + if ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) { + s.pendingAttack = 0; + startTurn(s, nextAfter(s, p, 0)); + } +} + +// ---------------------------------------------------------------- rounds + +function dealRound(s: OneCardState, starter: PlayerId, ctx: Ctx): void { + const o = s.options; + const players = s.seats.filter((p) => !gone(s, p)); + const deck: Card[] = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck(); + s.drawPile = ctx.rng.shuffle(deck); + s.discard = []; + s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as Card[]])); + for (const p of s.seats) s.status[p] = gone(s, p) ? 'left' : 'playing'; + s.finishOrder = []; + s.bankruptOrder = []; + s.declaredSuit = null; + s.declaredColor = null; + s.direction = 1; + s.pendingAttack = 0; + s.extraTurn = false; + s.oneCardWindow = null; + s.reveal = null; + s.ready = []; + s.roundStarter = starter; + // Deal one card at a time from the first player. + const order: PlayerId[] = []; + for (let k = 0, p = starter; k < players.length; k++, p = advance(s, p, 1)) order.push(p); + for (let r = 0; r < o.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!); + ctx.events.push({ type: 'dealt', players: order, count: o.handSize, round: s.round }); + s.current = starter; + s.phase = { kind: 'turn' }; + if (s.mode === 'classic') { + // Flip until a plain number card; 2/A/3/7/J/Q/K/jokers go to the bottom (§4, §10). + for (;;) { + const c = s.drawPile.shift()!; + if (isStd(c) && [4, 5, 6, 8, 9, 10].includes(c.rank)) { + s.discard.push(c); + break; + } + s.drawPile.push(c); + } + startTurn(s, starter); + return; + } + // Colour cards: the original start-card rules (§4). + let c = s.drawPile.shift()!; + while (c.kind === 'wild4') { + s.drawPile = ctx.rng.shuffle([...s.drawPile, c]); + c = s.drawPile.shift()!; + } + s.discard.push(c); + ctx.events.push({ type: 'startCard', card: faceOf(c) }); + switch (c.kind) { + case 'draw2': + drawCards(s, starter, 2, 'start', ctx); + startTurn(s, nextAfter(s, starter, 0)); + break; + case 'skip': + log(s, { t: 'skip', p: starter }); + startTurn(s, nextAfter(s, starter, 0)); + break; + case 'reverse': + s.direction = -1; + log(s, { t: 'reverse' }); + startTurn(s, advance(s, starter, 1)); + break; + default: + // Numbers start as they are; a starting "색 바꾸기" leaves the colour open for the first player. + startTurn(s, starter); + } +} + +function placeOf(s: OneCardState): PlayerId[][] { + const groups: PlayerId[][] = s.finishOrder.map((p) => [p]); + const rest = alive(s).sort((a, b) => cmpHand(s, a, b)); + for (const p of rest) { + const last = groups[groups.length - 1]; + const prev = last?.[0]; + if (last && prev && rest.includes(prev) && cmpHand(s, prev, p) === 0) last.push(p); + else groups.push([p]); + } + for (const p of [...s.bankruptOrder].reverse()) groups.push([p]); + for (const p of [...s.leftOrder].reverse()) if (s.status[p] === 'left') groups.push([p]); + return groups; +} + +function cmpHand(s: OneCardState, a: PlayerId, b: PlayerId): number { + const ha = s.hands[a]!; + const hb = s.hands[b]!; + return ha.length - hb.length || handPoints(ha) - handPoints(hb); +} + +function roundWinner(s: OneCardState): PlayerId | null { + if (s.finishOrder[0]) return s.finishOrder[0]; + const live = alive(s); + return live.length === 1 ? live[0]! : null; +} + +/** Colour-card points the winner collects from everyone else's remaining cards (§6). */ +function roundPoints(s: OneCardState, winner: PlayerId | null): number { + if (!winner || s.mode !== 'uno-style') return 0; + return s.seats.filter((p) => p !== winner).reduce((a, p) => a + handPoints(s.hands[p]!), 0); +} + +function maybeEndRound(s: OneCardState, ctx: Ctx): boolean { + if (s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return true; + const firstOut = s.options.endCondition === 'first-out' || multiRound(s); + if ((firstOut && s.finishOrder.length > 0) || alive(s).length <= 1) { + endRound(s, ctx); + return true; + } + return false; +} + +const multiRound = (s: OneCardState): boolean => s.mode === 'uno-style' && s.options.unoTargetScore > 0; + +function endRound(s: OneCardState, ctx: Ctx): void { + s.oneCardWindow = null; + s.pendingAttack = 0; + const winner = roundWinner(s); + const points = roundPoints(s, winner); + if (!multiRound(s)) { + finishSingle(s, winner, points, ctx); + return; + } + if (winner) s.scores[winner] = (s.scores[winner] ?? 0) + points; + s.rounds.push({ round: s.round, winner, points }); + log(s, { t: 'round', round: s.round, winner, points }); + ctx.events.push({ type: 'roundEnd', round: s.round, winner, points }); + const remaining = s.seats.filter((p) => !gone(s, p)); + if ((winner && s.scores[winner]! >= s.options.unoTargetScore) || remaining.length <= 1) { + finishMulti(s, ctx); + return; + } + s.phase = { kind: 'roundEnd' }; + s.ready = []; + s.turnNo += 1; +} + +function results(s: OneCardState, ranking: PlayerId[][], pointsOf: (p: PlayerId) => number): PlayerResult[] { + const out: PlayerResult[] = []; + let rank = 1; + for (const g of ranking) { + for (const p of g) out.push({ player: p, rank, status: s.status[p]!, cardsLeft: s.hands[p]!.length, points: pointsOf(p) }); + rank += g.length; + } + return out; +} + +function finishSingle(s: OneCardState, winner: PlayerId | null, points: number, ctx: Ctx): void { + const ranking = placeOf(s); + const uno = s.mode === 'uno-style'; + const pointsOf = (p: PlayerId) => (uno ? (p === winner ? points : 0) : handPoints(s.hands[p]!)); + const bankrupts = s.bankruptOrder.length; + let summary: string; + if (s.finishOrder.length === 0) { + summary = s.leftOrder.length > 0 && bankrupts === 0 ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요'; + } else if (s.options.endCondition === 'last-standing' && s.finishOrder.length > 1) { + summary = `${s.finishOrder.length}명이 차례로 카드를 모두 냈어요. 끝까지 남은 사람이 꼴찌예요`; + } else { + summary = uno ? `가장 먼저 카드를 모두 내서 ${points}점을 얻었어요` : '가장 먼저 카드를 모두 낸 사람이 이겼어요'; + } + if (bankrupts > 0 && s.finishOrder.length > 0) summary += ` · 파산 ${bankrupts}명`; + s.final = { + ranking, + players: results(s, ranking, pointsOf), + ...(uno ? { scores: Object.fromEntries(s.seats.map((p) => [p, pointsOf(p)])) } : {}), + summary, + }; + finishState(s, ctx); +} + +function finishMulti(s: OneCardState, ctx: Ctx): void { + const remaining = s.seats.filter((p) => !gone(s, p)).sort((a, b) => s.scores[b]! - s.scores[a]!); + const ranking: PlayerId[][] = []; + for (const p of remaining) { + const last = ranking[ranking.length - 1]; + if (last && s.scores[last[0]!] === s.scores[p]) last.push(p); + else ranking.push([p]); + } + for (const p of [...s.leftOrder].reverse()) ranking.push([p]); + const best = remaining[0]; + const target = s.options.unoTargetScore; + const summary = + best && s.scores[best]! >= target + ? `${s.round}판 만에 ${s.scores[best]}점으로 목표 ${target}점에 먼저 닿았어요` + : '다른 사람이 모두 나가서 점수가 가장 높은 사람이 이겼어요'; + s.final = { ranking, players: results(s, ranking, (p) => s.scores[p] ?? 0), scores: { ...s.scores }, summary }; + finishState(s, ctx); +} + +function finishState(s: OneCardState, ctx: Ctx): void { + s.phase = { kind: 'finished' }; + s.oneCardWindow = null; + s.reveal = null; + ctx.events.push({ type: 'gameEnd', ranking: s.final!.ranking }); +} + +// ---------------------------------------------------------------- playing cards + +function closeWindowOnTurnAction(s: OneCardState, actor: PlayerId): void { + const w = s.oneCardWindow; + if (!w) return; + // Classic: open until the owner's next action. Colour cards: until the next turn action by anyone (§5.3). + if (s.mode === 'uno-style' || w.player === actor) s.oneCardWindow = null; +} + +function playCards(s: OneCardState, p: PlayerId, a: PlayAction, ctx: Ctx): void { + const o = s.options; + const hand = s.hands[p]!; + const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)!); + const prevColor = s.mode === 'uno-style' ? currentColor(s) : null; + const wasAttacked = s.pendingAttack > 0; + const topBefore = top(s); + const illegalWild4 = cards[0]!.kind === 'wild4' && prevColor !== null && hand.some((c) => c.id !== cards[0]!.id && colorOf(c) === prevColor); + s.hands[p] = hand.filter((c) => !a.cardIds.includes(c.id)); + s.discard.push(...cards); + const handLeft = s.hands[p]!.length; + ctx.events.push({ type: 'cardPlayed', player: p, cards: cards.map(faceOf) }); + let skips = 0; + let extra = false; + let immediateDraw = false; + let wild4Challenge = false; + if (s.mode === 'classic') { + const allJokers = cards.every(isJoker); + if (!(allJokers && o.afterJoker === 'previous')) s.declaredSuit = null; + const attack = cards.reduce((x, c) => x + attackOf(o, c), 0); + if (wasAttacked && attack === 0 && canBlockWithThree(o, topBefore, cards[0]!)) { + s.pendingAttack = 0; + ctx.events.push({ type: 'attackBlocked', player: p }); + log(s, { t: 'blocked', p }); + } else if (attack > 0) { + s.pendingAttack += attack; + ctx.events.push({ type: 'attackStacked', total: s.pendingAttack }); + } + const first = cards[0]!; + const n = cards.length; + if (isStd(first)) { + switch (first.rank) { + case 7: + s.declaredSuit = a.chooseSuit!; + ctx.events.push({ type: 'suitDeclared', suit: a.chooseSuit }); + break; + case 11: + skips += n; + break; + case 12: + if (alive(s).length === 2) { + if (o.qInTwoPlayer === 'as-skip') skips += n; + } else if (n % 2 === 1) { + s.direction = s.direction === 1 ? -1 : 1; + ctx.events.push({ type: 'directionChanged', direction: s.direction }); + log(s, { t: 'reverse' }); + } + break; + case 13: + if (o.kEffect === 'extra-turn') extra = true; + else if (o.kEffect === 'skip-two') skips += 2 * n; + break; + } + } + log(s, { t: 'play', p, cards: cards.map(faceOf), ...(first.kind === 'std' && first.rank === 7 ? { suit: a.chooseSuit } : {}) }); + } else { + const c = cards[0]!; + s.declaredColor = null; + switch (c.kind) { + case 'skip': + skips = 1; + break; + case 'reverse': + if (alive(s).length <= 2) skips = 1; + else { + s.direction = s.direction === 1 ? -1 : 1; + ctx.events.push({ type: 'directionChanged', direction: s.direction }); + log(s, { t: 'reverse' }); + } + break; + case 'draw2': + s.pendingAttack += 2; + immediateDraw = !o.unoStacking; + break; + case 'wild': + case 'wild4': + s.declaredColor = a.chooseColor!; + ctx.events.push({ type: 'colorDeclared', color: a.chooseColor }); + if (c.kind === 'wild4') { + s.pendingAttack += 4; + if (o.unoWild4Rule === 'challenge') wild4Challenge = true; + else immediateDraw = !o.unoStacking; + } + break; + } + log(s, { t: 'play', p, cards: cards.map(faceOf), ...(isWildKind(c) ? { color: a.chooseColor } : {}) }); + } + + if (handLeft === 0) { + s.status[p] = 'out'; + s.finishOrder.push(p); + ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length }); + log(s, { t: 'out', p, place: s.finishOrder.length }); + if (s.mode === 'uno-style' && s.pendingAttack > 0 && alive(s).length > 0) { + // Going out on +2/+4: the next player still takes them (counts for the points). + const victim = nextAfter(s, p, 0); + drawCards(s, victim, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx); + s.pendingAttack = 0; + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, victim, 0)); + return; + } + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, p, skips)); + return; + } + + if (handLeft === 1) { + const declared = o.autoDeclare || !!a.declareOneCard; + s.oneCardWindow = { player: p, declared, openedSeq: s.seq }; + ctx.events.push({ type: 'oneCardOpen', player: p, declared }); + if (declared) log(s, { t: 'declare', p }); + } + + if (extra) { + startTurn(s, p, true); + return; + } + const next = nextAfter(s, p, skips); + if (wild4Challenge) { + s.current = next; + s.phase = { kind: 'wild4Response', by: p, target: next, prevColor, illegal: illegalWild4 }; + s.turnNo += 1; + return; + } + if (immediateDraw) { + drawCards(s, next, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx); + s.pendingAttack = 0; + ctx.events.push({ type: 'skipped', player: next }); + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, next, 0)); + return; + } + if (skips > 0) { + for (let k = 1; k <= skips && k < alive(s).length; k++) { + const skipped = advance(s, p, k); + if (skipped !== p) log(s, { t: 'skip', p: skipped }); + } + } + startTurn(s, next); +} + +const isWildKind = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4'; +export const faceOf = ({ id: _id, ...face }: Card): CardFace => face as CardFace; + +/** Normal or attack draw by the current player (also used by the timer). */ +function drawTurn(s: OneCardState, p: PlayerId, ctx: Ctx, allowAfterDraw: boolean): void { + if (s.pendingAttack > 0) { + const n = s.pendingAttack; + s.pendingAttack = 0; + drawCards(s, p, n, 'attack', ctx); + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, p, 0)); + return; + } + const before = s.hands[p]!.length; + drawCards(s, p, 1, 'normal', ctx); + if (maybeEndRound(s, ctx)) return; + if (s.status[p] === 'playing' && allowAfterDraw && resolvedPlayAfterDraw(s.options) && s.hands[p]!.length > before) { + const drawn = s.hands[p]![s.hands[p]!.length - 1]!; + if (canStartWith(s, p, drawn)) { + s.phase = { kind: 'afterDraw', drawnCardId: drawn.id }; + return; + } + } + startTurn(s, nextAfter(s, p, 0)); +} + +function resolveWild4(s: OneCardState, challenge: boolean, ctx: Ctx): void { + const ph = s.phase; + if (ph.kind !== 'wild4Response') throw new Error('not in wild4Response'); + const { by, target, illegal } = ph; + const total = s.pendingAttack; + s.pendingAttack = 0; + if (!challenge) { + drawCards(s, target, total, 'wild4', ctx); + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, target, 0)); + return; + } + const cards = s.hands[by]!.map(faceOf); + s.reveal = { to: target, by, cards, illegal, seq: s.seq }; + ctx.events.push({ type: 'wild4Challenged', by, target, illegal }); + log(s, { t: 'challenge', p: target, by, illegal }); + if (illegal) { + drawCards(s, by, total, 'challenge', ctx); + if (maybeEndRound(s, ctx)) return; + startTurn(s, target); + } else { + drawCards(s, target, total + 2, 'challenge', ctx); + if (maybeEndRound(s, ctx)) return; + startTurn(s, nextAfter(s, target, 0)); + } +} + +function declareOneCard(s: OneCardState, actor: PlayerId, ctx: Ctx): void { + const w = s.oneCardWindow!; + if (w.player === actor) { + w.declared = true; + ctx.events.push({ type: 'oneCardDeclared', player: actor }); + log(s, { t: 'declare', p: actor }); + return; + } + const target = w.player; + const penalty = resolvedPenalty(s.options); + s.oneCardWindow = null; + ctx.events.push({ type: 'oneCardCaught', catcher: actor, target, penalty }); + log(s, { t: 'caught', p: target, by: actor, n: penalty }); + drawCards(s, target, penalty, 'penalty', ctx); + if (s.status[target] !== 'playing') repairAfterRemoval(s, target, ctx); +} + +function leave(s: OneCardState, p: PlayerId, ctx: Ctx): void { + s.leftOrder.push(p); + ctx.events.push({ type: 'left', player: p }); + log(s, { t: 'left', p }); + s.ready = s.ready.filter((x) => x !== p); + if (s.phase.kind === 'roundEnd') { + s.status[p] = 'left'; + if (s.seats.filter((x) => !gone(s, x)).length <= 1) finishMulti(s, ctx); + else maybeStartNextRound(s, ctx); + return; + } + if (s.status[p] !== 'playing') return; + s.status[p] = 'left'; + removeFromPlay(s, p); + repairAfterRemoval(s, p, ctx); +} + +function requiredReady(s: OneCardState): PlayerId[] { + return s.seats.filter((p) => !gone(s, p)); +} + +function maybeStartNextRound(s: OneCardState, ctx: Ctx): void { + if (s.phase.kind !== 'roundEnd') return; + if (!requiredReady(s).every((p) => s.ready.includes(p))) return; + s.round += 1; + const players = s.seats.filter((p) => !gone(s, p)); + // Next round starts one seat clockwise from the previous first player. + const n = s.seats.length; + let idx = s.seats.indexOf(s.roundStarter); + do idx = (idx + 1) % n; + while (!players.includes(s.seats[idx]!)); + dealRound(s, s.seats[idx]!, ctx); +} + +function waiting(s: OneCardState): PlayerId[] { + switch (s.phase.kind) { + case 'turn': + case 'afterDraw': + return [s.current]; + case 'wild4Response': + return [s.phase.target]; + case 'roundEnd': + return requiredReady(s).filter((p) => !s.ready.includes(p)); + case 'finished': + return []; + } +} + +function canDeclare(s: OneCardState, p: PlayerId): boolean { + const w = s.oneCardWindow; + if (!w || w.declared || s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return false; + const st = s.status[p]; + return st === 'playing' || (st === 'out' && w.player !== p); +} + +const lastCardReason: Record = { + none: '', + joker: '마지막 카드로 조커는 낼 수 없어요.', + attack: '마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.', + special: '마지막 카드로 특수 카드는 낼 수 없어요.', +}; + +function validatePlay(s: OneCardState, actor: PlayerId, a: PlayAction): Validation { + const ph = s.phase; + const responding = ph.kind === 'wild4Response'; + if (responding) { + if (ph.target !== actor) return fail('지금은 내 차례가 아니에요.'); + if (!s.options.unoStacking) return fail('의심하기나 받아들이기를 골라 주세요.'); + } else { + if (ph.kind !== 'turn' && ph.kind !== 'afterDraw') return fail('지금은 카드를 낼 수 없어요.'); + if (s.current !== actor) return fail('지금은 내 차례가 아니에요.'); + } + const hand = s.hands[actor]!; + if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.'); + const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)); + if (cards.some((c) => !c)) return fail('손에 없는 카드예요.'); + const cs = cards as Card[]; + if (ph.kind === 'afterDraw' && (cs.length !== 1 || cs[0]!.id !== ph.drawnCardId)) return fail('방금 먹은 카드만 낼 수 있어요.'); + const first = cs[0]!; + if (s.mode === 'uno-style') { + if (cs.length !== 1) return fail('한 번에 한 장만 낼 수 있어요.'); + if (responding) { + if (first.kind !== 'wild4') return fail('색 바꾸기+4로만 받아칠 수 있어요.'); + } else if (s.pendingAttack > 0) { + if (!unoCanStack(top(s), first)) return fail(top(s).kind === 'wild4' ? '색 바꾸기+4로 받아치거나 카드를 먹으세요.' : '+2나 색 바꾸기+4로 받아치거나 카드를 먹으세요.'); + } else if (!unoMatches(s, first, hand)) { + return fail(first.kind === 'wild4' ? '지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.' : '같은 색이나 같은 숫자·기호 카드만 낼 수 있어요.'); + } + if (isWildKind(first) && !a.chooseColor) return fail('색을 골라 주세요.'); + return ok; + } + if (cs.length > 1) { + if (!s.options.multiPlay) return fail('한 번에 한 장만 낼 수 있어요.'); + if (!cs.every((c) => isStd(c) && isStd(first) && c.rank === first.rank)) return fail('같은 숫자 카드만 함께 낼 수 있어요.'); + } + if (s.pendingAttack > 0) { + if (!canCounter(top(s), first, s.decks === 2) && !canBlockWithThree(s.options, top(s), first)) { + return fail('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.'); + } + } else if (!classicMatches(s, first)) { + return fail(s.declaredSuit ? `${SUIT_SYMBOL[s.declaredSuit]} 무늬나 7만 낼 수 있어요.` : '같은 무늬나 같은 숫자 카드만 낼 수 있어요.'); + } + if (cs.length === hand.length && cs.some((c) => forbiddenAsLast(s.options, c))) return fail(lastCardReason[s.options.lastCardRule]); + if (isStd(first) && first.rank === 7 && !a.chooseSuit) return fail('무늬를 골라 주세요.'); + return ok; +} + +function drawAllowed(s: OneCardState, p: PlayerId): boolean { + if (s.phase.kind !== 'turn' || s.current !== p) return false; + return s.pendingAttack > 0 || s.options.voluntaryDraw || legalCardIds(s, p).length === 0; +} + +function deadlineMs(s: OneCardState): number | null { + switch (s.phase.kind) { + case 'turn': + case 'afterDraw': + return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000; + case 'wild4Response': + return s.away[s.phase.target] ? AWAY_MS : WILD4_MS; + case 'roundEnd': + return ROUND_END_MS; + case 'finished': + return null; + } +} + +/** All play actions for `p` built from a set of card ids (suits / colours / declare variants). */ +function playVariants(s: OneCardState, p: PlayerId, ids: string[]): PlayAction[] { + const hand = s.hands[p]!; + const first = hand.find((c) => c.id === ids[0])!; + let out: PlayAction[] = [{ type: 'play', cardIds: ids }]; + if (isStd(first) && first.rank === 7) out = SUITS.map((chooseSuit: Suit) => ({ type: 'play', cardIds: ids, chooseSuit })); + if (isWildKind(first)) out = COLORS.map((chooseColor: Color) => ({ type: 'play', cardIds: ids, chooseColor })); + if (hand.length - ids.length === 1) out = out.flatMap((x) => [x, { ...x, declareOneCard: true }]); + return out; +} + +// ---------------------------------------------------------------- definition + +export const oneCard: GameDefinition = { + id: 'one-card', + nameKo: '원카드', + minPlayers: 2, + maxPlayers: 10, + stateVersion: 1, + defaultOptions: oneCardOptionsSchema.parse({}), + optionsSchema: oneCardOptionsSchema as unknown as GameDefinition['optionsSchema'], + actionSchema: oneCardActionSchema as unknown as GameDefinition['actionSchema'], + playersFor: playerRange, + + setup({ players, options, rng, now }) { + const range = playerRange(options); + if (players.length < range.min || players.length > range.max) throw new Error(`one-card: ${players.length} players not allowed in ${options.mode}`); + let decks: 1 | 2 = 1; + if (options.mode === 'classic') { + decks = options.deckCount === 'auto' ? (players.length >= 6 ? 2 : 1) : options.deckCount; + // One deck cannot deal this many hands and still leave a draw pile. + if (decks === 1 && players.length * options.handSize > 44) decks = 2; + } + const s: OneCardState = { + mode: options.mode, + options, + decks, + seats: [...players], + hands: {}, + drawPile: [], + discard: [], + declaredSuit: null, + declaredColor: null, + direction: 1, + current: players[0]!, + phase: { kind: 'turn' }, + pendingAttack: 0, + extraTurn: false, + oneCardWindow: null, + status: Object.fromEntries(players.map((p) => [p, 'playing' as const])), + finishOrder: [], + bankruptOrder: [], + leftOrder: [], + turnStartedAt: now, + seq: 0, + turnNo: 0, + timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])), + away: Object.fromEntries(players.map((p) => [p, false])), + round: 1, + roundStarter: players[0]!, + scores: Object.fromEntries(players.map((p) => [p, 0])), + rounds: [], + ready: [], + reveal: null, + log: [], + final: null, + }; + const ctx: Ctx = { rng, now, events: [] }; + // §4: shuffle (inside dealRound) after picking the first player with the same RNG. + const starter = players[rng.int(players.length)]!; + dealRound(s, starter, ctx); + return s; + }, + + validate(s, actor, a) { + if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.'); + if (a.type === 'leave') return gone(s, actor) ? fail('이미 나간 플레이어예요.') : ok; + if (gone(s, actor)) return fail('방을 나간 플레이어예요.'); + switch (a.type) { + case 'timeout': + return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.'); + case 'ready': + return s.phase.kind === 'roundEnd' && !s.ready.includes(actor) ? ok : fail('지금은 다음 판을 기다릴 때가 아니에요.'); + case 'declareOneCard': { + const w = s.oneCardWindow; + if (!w || s.phase.kind === 'roundEnd') return fail('지금은 원카드를 외칠 때가 아니에요.'); + if (w.declared) return fail('이미 원카드를 외쳤어요.'); + return canDeclare(s, actor) ? ok : fail('게임에서 빠진 사람은 외칠 수 없어요.'); + } + case 'challengeWild4': + case 'acceptWild4': + if (s.phase.kind !== 'wild4Response' || s.phase.target !== actor) return fail('지금은 의심하기를 할 수 없어요.'); + return ok; + case 'pass': + if (s.phase.kind !== 'afterDraw' || s.current !== actor) return fail('지금은 넘길 수 없어요.'); + return ok; + case 'draw': + if (s.phase.kind === 'afterDraw' && s.current === actor) return fail('먹은 카드를 내거나 넘기기를 눌러 주세요.'); + if (s.phase.kind !== 'turn' || s.current !== actor) return fail('지금은 내 차례가 아니에요.'); + if (!drawAllowed(s, actor)) return fail('낼 수 있는 카드가 있으면 먹을 수 없어요.'); + return ok; + case 'play': + return validatePlay(s, actor, a); + } + }, + + apply(prev, actor, a, { rng, now }) { + const s = cloneState(prev); + const ctx: Ctx = { rng, now, events: [] }; + const before = `${s.turnNo}|${s.phase.kind}`; + s.seq += 1; + const turnAction = a.type === 'play' || a.type === 'draw' || a.type === 'pass' || a.type === 'challengeWild4' || a.type === 'acceptWild4' || a.type === 'timeout'; + if (turnAction) { + if (s.phase.kind !== 'roundEnd') closeWindowOnTurnAction(s, actor); + if (s.reveal && s.reveal.to === actor) s.reveal = null; + } + if (a.type !== 'timeout' && a.type !== 'leave' && a.type !== 'declareOneCard') { + s.timeoutsInRow[actor] = 0; + s.away[actor] = false; + } + switch (a.type) { + case 'play': + playCards(s, actor, a, ctx); + break; + case 'draw': + drawTurn(s, actor, ctx, true); + break; + case 'pass': + ctx.events.push({ type: 'passed', player: actor }); + log(s, { t: 'pass', p: actor }); + startTurn(s, nextAfter(s, actor, 0)); + break; + case 'declareOneCard': + declareOneCard(s, actor, ctx); + break; + case 'challengeWild4': + resolveWild4(s, true, ctx); + break; + case 'acceptWild4': + resolveWild4(s, false, ctx); + break; + case 'ready': + s.ready.push(actor); + maybeStartNextRound(s, ctx); + break; + case 'leave': + leave(s, actor, ctx); + break; + case 'timeout': { + ctx.events.push({ type: 'timeout', player: actor }); + if (s.phase.kind !== 'roundEnd') { + s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1; + if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) { + s.away[actor] = true; + ctx.events.push({ type: 'away', player: actor }); + } + log(s, { t: 'timeout', p: actor }); + } + switch (s.phase.kind) { + case 'turn': + // Never auto-plays a card (§8.5): take the attack or draw one and end the turn. + drawTurn(s, actor, ctx, false); + break; + case 'afterDraw': + log(s, { t: 'pass', p: actor }); + startTurn(s, nextAfter(s, actor, 0)); + break; + case 'wild4Response': + resolveWild4(s, false, ctx); + break; + case 'roundEnd': + s.ready.push(actor); + maybeStartNextRound(s, ctx); + break; + } + break; + } + } + if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now; + return { state: s, events: ctx.events }; + }, + + view(s, viewer) { + const me = viewer && s.seats.includes(viewer) ? viewer : null; + const ph = s.phase; + const live = ph.kind !== 'finished' && ph.kind !== 'roundEnd'; + const w = s.oneCardWindow; + let my: OneCardView['my'] = null; + if (me) { + const hand = s.hands[me] ?? []; + const legal = live ? legalCardIds(s, me) : []; + my = { + hand, + legalCardIds: legal, + hints: Object.fromEntries(legal.map((id) => [id, hintFor(s, hand.find((c) => c.id === id)!)])), + canDraw: live && drawAllowed(s, me) && !gone(s, me), + drawCount: s.pendingAttack > 0 ? s.pendingAttack : 1, + canPass: ph.kind === 'afterDraw' && s.current === me, + drawnCardId: ph.kind === 'afterDraw' && s.current === me ? ph.drawnCardId : null, + canDeclare: !gone(s, me) && canDeclare(s, me), + canChallenge: ph.kind === 'wild4Response' && ph.target === me && !gone(s, me), + canReady: ph.kind === 'roundEnd' && !gone(s, me) && !s.ready.includes(me), + multiPlay: s.mode === 'classic' && s.options.multiPlay, + }; + } + const t = s.discard[s.discard.length - 1]; + const ref = s.mode === 'classic' && t && isJoker(t) && s.options.afterJoker === 'previous' ? referenceCard(s) : null; + return { + mode: s.mode, + options: s.options, + decks: s.decks, + penalty: resolvedPenalty(s.options), + bankruptLimit: resolvedBankrupt(s.options), + playAfterDraw: resolvedPlayAfterDraw(s.options), + me, + seats: s.seats.map((p) => ({ + id: p, + cards: s.hands[p]?.length ?? 0, + status: s.status[p]!, + isTurn: waiting(s).includes(p) && ph.kind !== 'roundEnd', + away: !!s.away[p], + oneCard: w?.player === p ? (w.declared ? 'declared' : 'open') : null, + score: s.scores[p] ?? 0, + ready: ph.kind === 'roundEnd' && s.ready.includes(p), + })), + current: live ? (ph.kind === 'wild4Response' ? ph.target : s.current) : null, + direction: s.direction, + phase: ph.kind, + turnNo: s.turnNo, + extraTurn: s.extraTurn, + recent: s.discard.slice(-5), + reference: ref, + declaredSuit: s.declaredSuit, + declaredColor: s.declaredColor, + currentColor: s.mode === 'uno-style' && t ? currentColor(s) : null, + pendingAttack: s.pendingAttack, + drawPileCount: s.drawPile.length, + discardCount: s.discard.length, + oneCardWindow: w ? { player: w.player, declared: w.declared } : null, + wild4: ph.kind === 'wild4Response' ? { by: ph.by, target: ph.target } : null, + my, + reveal: me && s.reveal && s.reveal.to === me ? { by: s.reveal.by, cards: s.reveal.cards, illegal: s.reveal.illegal } : null, + turnStartedAt: s.turnStartedAt, + deadlineTotalMs: deadlineMs(s) ?? 0, + round: s.round, + rounds: s.rounds, + log: s.log.slice(-LOG_VIEW), + result: s.final, + }; + }, + + activePlayers(s) { + const out = waiting(s); + if (s.oneCardWindow && !s.oneCardWindow.declared) for (const p of s.seats) if (!out.includes(p) && !gone(s, p) && canDeclare(s, p)) out.push(p); + return out; + }, + + deadline(s) { + const ms = deadlineMs(s); + return ms === null ? null : s.turnStartedAt + ms; + }, + + timeoutPlayers: waiting, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + if (!s.seats.includes(player) || gone(s, player) || s.phase.kind === 'finished') return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (!s.final) return null; + return { ranking: s.final.ranking, ...(s.final.scores ? { scores: s.final.scores } : {}), summary: s.final.summary, reason: 'normal' }; + }, + + legalActions(s, p) { + if (!s.seats.includes(p) || gone(s, p) || s.phase.kind === 'finished') return []; + const out: OneCardAction[] = []; + const ph = s.phase; + if (ph.kind === 'roundEnd') return s.ready.includes(p) ? [] : [{ type: 'ready' }]; + if (canDeclare(s, p)) out.push({ type: 'declareOneCard' }); + if (ph.kind === 'wild4Response' && ph.target === p) out.push({ type: 'challengeWild4' }, { type: 'acceptWild4' }); + const mineNow = ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) || (ph.kind === 'wild4Response' && ph.target === p); + if (mineNow) { + const hand = s.hands[p]!; + for (const id of legalCardIds(s, p)) { + const c = hand.find((x) => x.id === id)!; + const sets: string[][] = [[id]]; + if (s.mode === 'classic' && s.options.multiPlay && isStd(c) && ph.kind === 'turn') { + const same = hand.filter((x) => x.id !== id && isStd(x) && x.rank === c.rank).map((x) => x.id); + if (same.length) sets.push([id, ...same]); + } + for (const set of sets) out.push(...playVariants(s, p, set)); + } + if (drawAllowed(s, p)) out.push({ type: 'draw' }); + if (ph.kind === 'afterDraw') out.push({ type: 'pass' }); + } + return out.filter((a) => oneCard.validate(s, p, a).ok); + }, + + /** Own hand plus the draw pile — card ids exactly as they would appear in a view. */ + secretsOf(s, player) { + return [...(s.hands[player] ?? []).map((c) => c.id), ...s.drawPile.map((c) => c.id)]; + }, +}; + +export type { OneCardFinal }; diff --git a/packages/games/src/one-card/one-card.test.ts b/packages/games/src/one-card/one-card.test.ts new file mode 100644 index 0000000..4a3e711 --- /dev/null +++ b/packages/games/src/one-card/one-card.test.ts @@ -0,0 +1,869 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; +import { + cardTotal, + classicDeck, + deckSize, + oneCard, + unoDeck, + type Card, + type OneCardAction, + type OneCardOptions, + type OneCardState, +} from './index'; + +// ---------------------------------------------------------------- helpers + +const ALL: Card[] = [...classicDeck(2), ...unoDeck()]; +const card = (id: string): Card => { + const c = ALL.find((x) => x.id === id); + if (!c) throw new Error(`no card ${id}`); + return c; +}; +const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); +let clock = 1_000_000; + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], seed = 1): OneCardState { + const options = oneCard.optionsSchema.parse(opts); + return oneCard.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); +} + +/** Replaces hands / discard / turn; the rest of the deck becomes the draw pile (unless given). */ +function rig( + s: OneCardState, + r: { hands: Record; discard: string[]; current?: string; drawPile?: string[]; pending?: number }, +): OneCardState { + for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(card); + s.discard = r.discard.map(card); + s.current = r.current ?? s.seats[0]!; + s.phase = { kind: 'turn' }; + s.pendingAttack = r.pending ?? 0; + s.declaredSuit = null; + s.declaredColor = null; + s.direction = 1; + s.extraTurn = false; + s.oneCardWindow = null; + if (r.drawPile) s.drawPile = r.drawPile.map(card); + else { + const used = new Set([...Object.values(r.hands).flat(), ...r.discard]); + const deck = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck(); + s.drawPile = deck.filter((c) => !used.has(c.id)); + } + return s; +} + +function actE(s: OneCardState, p: string, a: OneCardAction): { state: OneCardState; events: GameEvent[] } { + const v = oneCard.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return oneCard.apply(s, p, a, { rng: rng(), now: (clock += 1000) }); +} +const act = (s: OneCardState, p: string, a: OneCardAction) => actE(s, p, a).state; +const play = (s: OneCardState, p: string, ids: string[], extra: Partial> = {}) => + act(s, p, { type: 'play', cardIds: ids, ...extra }); +const reject = (s: OneCardState, p: string, a: OneCardAction): string | null => { + const v = oneCard.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const ids = (s: OneCardState, p: string) => s.hands[p]!.map((c) => c.id); +const topId = (s: OneCardState) => s.discard[s.discard.length - 1]!.id; + +// ---------------------------------------------------------------- setup + +describe('셋업 (§4)', () => { + test('same seed → same hands and start card; 7 cards each; classic start card is a plain number', () => { + const a = make({}, ['A', 'B', 'C'], 5); + const b = make({}, ['A', 'B', 'C'], 5); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + expect(JSON.stringify(make({}, ['A', 'B', 'C'], 6).hands)).not.toBe(JSON.stringify(a.hands)); + for (const p of a.seats) expect(a.hands[p]).toHaveLength(7); + expect(cardTotal(a)).toBe(54); + }); + + test('special start cards (2/A/3/7/J/Q/K/joker) go to the bottom in order until a plain number shows', () => { + let sawSpecial = 0; + for (let seed = 1; seed <= 300; seed++) { + const players = ['A', 'B', 'C']; + const s = make({}, players, seed); + // Replay the RNG: first player, then the shuffle (§8.4). + const r = SeededRng.fromSeed(seedN(seed)); + const starter = players[r.int(3)]!; + const deck = r.shuffle(classicDeck(1)); + expect(s.roundStarter).toBe(starter); + const rest = deck.slice(21); + const firstPlain = rest.findIndex((c) => c.kind === 'std' && [4, 5, 6, 8, 9, 10].includes(c.rank)); + const specials = rest.slice(0, firstPlain); + expect(s.discard).toEqual([rest[firstPlain]!]); + expect(s.drawPile.slice(s.drawPile.length - specials.length)).toEqual(specials); + for (const c of specials) expect(c.kind === 'joker' || [1, 2, 3, 7, 11, 12, 13].includes((c as { rank: number }).rank)).toBe(true); + if (specials.length) sawSpecial++; + } + expect(sawSpecial).toBeGreaterThan(50); + }); + + test('deck count: auto uses 2 decks from 6 players; 1 deck is upgraded when it cannot deal', () => { + expect(make({}, ['A', 'B', 'C', 'D', 'E']).decks).toBe(1); + expect(make({}, ['A', 'B', 'C', 'D', 'E', 'F']).decks).toBe(2); + expect(cardTotal(make({}, 'ABCDEF'.split('')))).toBe(108); + expect(make({ deckCount: 2 }, ['A', 'B']).decks).toBe(2); + expect(make({ deckCount: 1 }, 'ABCDEFG'.split('')).decks).toBe(2); + expect(make({ deckCount: 1, handSize: 5 }, 'ABCDEFG'.split('')).decks).toBe(1); + }); + + test('player range per mode; classic refuses 10 players', () => { + expect(oneCard.playersFor!(oneCard.defaultOptions)).toEqual({ min: 2, max: 9 }); + expect(oneCard.playersFor!({ ...oneCard.defaultOptions, mode: 'uno-style' })).toEqual({ min: 2, max: 10 }); + expect(() => make({}, 'ABCDEFGHIJ'.split(''))).toThrow(); + expect(make({ mode: 'uno-style' }, 'ABCDEFGHIJ'.split('')).seats).toHaveLength(10); + }); +}); + +// ---------------------------------------------------------------- classic attacks + +describe('공격과 방어 (§5.2 A)', () => { + test('♥2 (2장) → ♥A 받아치기 → 누적 5장; the next player draws exactly 5 and the turn ends', () => { + let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['H1', 'D9'], C: ['S9', 'S10'] }, discard: ['H5'] }); + s = play(s, 'A', ['H2']); + expect(s.pendingAttack).toBe(2); + expect(s.current).toBe('B'); + s = play(s, 'B', ['H1']); + expect(s.pendingAttack).toBe(5); + expect(s.current).toBe('C'); + s = act(s, 'C', { type: 'draw' }); + expect(s.hands.C).toHaveLength(7); + expect(s.pendingAttack).toBe(0); + expect(s.current).toBe('A'); + expect(topId(s)).toBe('H1'); + }); + + test('♥2 위에 ♣A는 거부, ♣2는 허용', () => { + const s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['C1', 'C2', 'D9'] }, discard: ['H5'] }); + const t = play(s, 'A', ['H2']); + expect(reject(t, 'B', { type: 'play', cardIds: ['C1'] })).toBe('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.'); + expect(play(t, 'B', ['C2']).pendingAttack).toBe(4); + }); + + test('♠A: ♥A 거부, 흑백 조커 허용; 흑백 조커 위 컬러 조커 허용, 컬러 조커 위 흑백 조커 거부', () => { + let s = rig(make(), { hands: { A: ['S1', 'C4'], B: ['H1', 'JB', 'D9'], C: ['JC', 'S9'] }, discard: ['S5'] }); + s = play(s, 'A', ['S1']); + expect(s.pendingAttack).toBe(5); + expect(reject(s, 'B', { type: 'play', cardIds: ['H1'] })).not.toBeNull(); + s = play(s, 'B', ['JB']); + expect(s.pendingAttack).toBe(10); + s = play(s, 'C', ['JC']); + expect(s.pendingAttack).toBe(17); + const t = rig(make(), { hands: { A: ['JC', 'C4'], B: ['JB', 'D9'] }, discard: ['S5'] }); + const u = play(t, 'A', ['JC']); + expect(reject(u, 'B', { type: 'play', cardIds: ['JB'] })).not.toBeNull(); + }); + + test('two decks: the same attack card answers itself (♠A on ♠A, 흑백 조커 on 흑백 조커)', () => { + let s = rig(make({ deckCount: 2 }), { hands: { A: ['S1', 'C4'], B: ['S1b', 'D9'], C: ['JB', 'JBb', 'S9'] }, discard: ['S5'] }); + s = play(s, 'A', ['S1']); + s = play(s, 'B', ['S1b']); + expect(s.pendingAttack).toBe(10); + s = play(s, 'C', ['JB']); + s.current = 'C'; + s.phase = { kind: 'turn' }; + expect(play(s, 'C', ['JBb']).pendingAttack).toBe(20); + }); + + test('threeDefense: ♦A 공격에 ♦3 → 누적 0, 다음 사람은 ♦3 기준 일반 턴; ♣3 거부; 조커 공격에 3 거부', () => { + let s = rig(make(), { hands: { A: ['D1', 'C4'], B: ['D3', 'C3', 'S9'], C: ['D7', 'S8', 'H3'] }, discard: ['D5'] }); + s = play(s, 'A', ['D1']); + expect(reject(s, 'B', { type: 'play', cardIds: ['C3'] })).not.toBeNull(); + s = play(s, 'B', ['D3']); + expect(s.pendingAttack).toBe(0); + expect(s.current).toBe('C'); + expect(s.log.some((e) => e.t === 'blocked')).toBe(true); + expect(reject(s, 'C', { type: 'play', cardIds: ['S8'] })).toBe('같은 무늬나 같은 숫자 카드만 낼 수 있어요.'); + expect(reject(s, 'C', { type: 'play', cardIds: ['H3'] })).toBeNull(); + expect(play(s, 'C', ['D7'], { chooseSuit: 'S' }).declaredSuit).toBe('S'); + + const j = play(rig(make(), { hands: { A: ['JB', 'C4'], B: ['S3', 'H3'] }, discard: ['S5'] }), 'A', ['JB']); + expect(reject(j, 'B', { type: 'play', cardIds: ['S3'] })).not.toBeNull(); + const off = play(rig(make({ threeDefense: 'off' }), { hands: { A: ['D1', 'C4'], B: ['D3', 'S9'] }, discard: ['D5'] }), 'A', ['D1']); + expect(reject(off, 'B', { type: 'play', cardIds: ['D3'] })).not.toBeNull(); + }); +}); + +// ---------------------------------------------------------------- classic specials + +describe('특수 카드 (§5.2 B·C)', () => { + test('7 + ♠ 지정: 다음 사람은 ♠ 카드나 7만; 바닥 7의 원래 무늬(♥)는 안 됨', () => { + let s = rig(make(), { hands: { A: ['H7', 'C4'], B: ['H5', 'C7', 'S9', 'H9'] }, discard: ['H8'] }); + expect(reject(s, 'A', { type: 'play', cardIds: ['H7'] })).toBe('무늬를 골라 주세요.'); + s = play(s, 'A', ['H7'], { chooseSuit: 'S' }); + expect(s.declaredSuit).toBe('S'); + expect(reject(s, 'B', { type: 'play', cardIds: ['H5'] })).toBe('♠ 무늬나 7만 낼 수 있어요.'); + expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).not.toBeNull(); + expect(reject(s, 'B', { type: 'play', cardIds: ['C7'], chooseSuit: 'H' })).toBeNull(); + const t = play(s, 'B', ['S9']); + expect(t.declaredSuit).toBeNull(); + expect(oneCard.view(s, 'B').my!.legalCardIds.sort()).toEqual(['C7', 'S9']); + }); + + test('J in 2 players → same player again; Q in 2 players (default) → opponent; as-skip → again', () => { + let s = rig(make({}, ['A', 'B']), { hands: { A: ['H11', 'H12', 'C4'], B: ['S9', 'S10'] }, discard: ['H5'] }); + s = play(s, 'A', ['H11']); + expect(s.current).toBe('A'); + s = play(s, 'A', ['H12']); + expect(s.current).toBe('B'); + const q = play(rig(make({ qInTwoPlayer: 'as-skip' }, ['A', 'B']), { hands: { A: ['H12', 'C4'], B: ['S9'] }, discard: ['H5'] }), 'A', ['H12']); + expect(q.current).toBe('A'); + }); + + test('J skips one, Q reverses with 3+ players', () => { + let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['H11', 'C4'], B: ['S9'], C: ['H12', 'S2'], D: ['S10'] }, discard: ['H5'] }); + s = play(s, 'A', ['H11']); + expect(s.current).toBe('C'); + s = play(s, 'C', ['H12']); + expect(s.direction).toBe(-1); + expect(s.current).toBe('B'); + }); + + test('K extra-turn: nothing playable on the extra turn → draws 1; K as the last card → out at once', () => { + let s = rig(make({ voluntaryDraw: false }, ['A', 'B']), { hands: { A: ['H13', 'C4', 'D9'], B: ['S9', 'S10'] }, discard: ['H5'] }); + s = play(s, 'A', ['H13']); + expect(s.current).toBe('A'); + expect(s.extraTurn).toBe(true); + expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]); + s = act(s, 'A', { type: 'draw' }); + expect(s.hands.A).toHaveLength(3); + expect(s.current).toBe('B'); + + const k = play(rig(make({}, ['A', 'B']), { hands: { A: ['H13'], B: ['S9', 'S10'] }, discard: ['H5'] }), 'A', ['H13']); + expect(k.phase.kind).toBe('finished'); + expect(oneCard.result(k)!.ranking[0]).toEqual(['A']); + }); + + test('K skip-two: back to the player with 3 people, two skipped with 4', () => { + const three = play(rig(make({ kEffect: 'skip-two' }), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'] }, discard: ['H5'] }), 'A', ['H13']); + expect(three.current).toBe('A'); + const four = play(rig(make({ kEffect: 'skip-two' }, ['A', 'B', 'C', 'D']), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }, discard: ['H5'] }), 'A', ['H13']); + expect(four.current).toBe('D'); + }); + + test('afterJoker: previous → judged by the card under the joker; any → anything', () => { + let s = rig(make(), { hands: { A: ['JB', 'C4'], B: ['C2', 'D9', 'H9', 'S5'], C: ['S8'] }, discard: ['H5'] }); + s = play(s, 'A', ['JB']); + s = act(s, 'B', { type: 'draw' }); + s.current = 'B'; + s.phase = { kind: 'turn' }; + expect(oneCard.view(s, 'B').reference?.id).toBe('H5'); + expect(reject(s, 'B', { type: 'play', cardIds: ['D9'] })).not.toBeNull(); + expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).toBeNull(); + expect(reject(s, 'B', { type: 'play', cardIds: ['S5'] })).toBeNull(); + let t = rig(make({ afterJoker: 'any' }), { hands: { A: ['JB', 'C4'], B: ['D9', 'H9'] }, discard: ['H5'] }); + t = play(t, 'A', ['JB']); + t.pendingAttack = 0; + expect(reject(t, 'B', { type: 'play', cardIds: ['D9'] })).toBeNull(); + }); + + test('multiPlay: same rank only; 2+2 = 4 attack; J+J skips two; Q+Q keeps the direction; K+K one extra turn', () => { + const base = (hands: Record) => rig(make({ multiPlay: true }, ['A', 'B', 'C', 'D']), { hands, discard: ['H5'] }); + const off = rig(make(), { hands: { A: ['H2', 'S2', 'C4'] }, discard: ['H5'] }); + expect(reject(off, 'A', { type: 'play', cardIds: ['H2', 'S2'] })).toBe('한 번에 한 장만 낼 수 있어요.'); + const two = play(base({ A: ['H2', 'S2', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H2', 'S2']); + expect(two.pendingAttack).toBe(4); + expect(topId(two)).toBe('S2'); + expect(reject(base({ A: ['H2', 'S3', 'C4'] }), 'A', { type: 'play', cardIds: ['H2', 'S3'] })).toBe('같은 숫자 카드만 함께 낼 수 있어요.'); + expect(reject(base({ A: ['S2', 'H2', 'C4'] }), 'A', { type: 'play', cardIds: ['S2', 'H2'] })).not.toBeNull(); + expect(play(base({ A: ['H11', 'S11', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H11', 'S11']).current).toBe('D'); + const qq = play(base({ A: ['H12', 'S12', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H12', 'S12']); + expect(qq.direction).toBe(1); + expect(qq.current).toBe('B'); + const kk = play(base({ A: ['H13', 'S13', 'S4', 'C5'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H13', 'S13']); + expect(kk.current).toBe('A'); + expect(play(kk, 'A', ['S4']).current).toBe('B'); + }); + + test('lastCardRule = joker: [조커, ♥5] → ♥5 leaves the joker, which may not be played as the last card', () => { + let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'H5'], B: ['H9', 'S9', 'C9'] }, discard: ['H8'] }); + s = play(s, 'A', ['H5'], { declareOneCard: true }); + s = play(s, 'B', ['H9']); + expect(reject(s, 'A', { type: 'play', cardIds: ['JB'] })).toBe('마지막 카드로 조커는 낼 수 없어요.'); + expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]); + expect(oneCard.view(s, 'A').my!.canDraw).toBe(true); + const attack = rig(make({ lastCardRule: 'attack' }, ['A', 'B']), { hands: { A: ['H2'], B: ['S9'] }, discard: ['H8'] }); + expect(reject(attack, 'A', { type: 'play', cardIds: ['H2'] })).toBe('마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.'); + const special = rig(make({ lastCardRule: 'special' }, ['A', 'B']), { hands: { A: ['H11'], B: ['S9'] }, discard: ['H8'] }); + expect(reject(special, 'A', { type: 'play', cardIds: ['H11'] })).toBe('마지막 카드로 특수 카드는 낼 수 없어요.'); + const none = rig(make({ lastCardRule: 'none' }, ['A', 'B']), { hands: { A: ['JC'], B: ['S9'] }, discard: ['H8'] }); + expect(play(none, 'A', ['JC']).phase.kind).toBe('finished'); + }); + + test('voluntaryDraw off: drawing is refused while a card fits; playAfterDraw on: only the drawn card, or pass', () => { + const s = rig(make({ voluntaryDraw: false }), { hands: { A: ['H9', 'C4'] }, discard: ['H5'] }); + expect(reject(s, 'A', { type: 'draw' })).toBe('낼 수 있는 카드가 있으면 먹을 수 없어요.'); + let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] }); + t = act(t, 'A', { type: 'draw' }); + expect(t.phase).toEqual({ kind: 'afterDraw', drawnCardId: 'H10' }); + expect(reject(t, 'A', { type: 'play', cardIds: ['C4'] })).toBe('방금 먹은 카드만 낼 수 있어요.'); + expect(reject(t, 'A', { type: 'draw' })).not.toBeNull(); + expect(play(t, 'A', ['H10']).current).toBe('B'); + expect(act(t, 'A', { type: 'pass' }).current).toBe('B'); + // Classic default: no play after drawing. + const d = act(rig(make(), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] }), 'A', { type: 'draw' }); + expect(d.current).toBe('B'); + }); +}); + +// ---------------------------------------------------------------- one card + +describe('원카드 선언 (§5.3)', () => { + const setupOne = (opts: Partial = {}) => + rig(make(opts), { hands: { A: ['H5', 'C9'], B: ['S9', 'S10'], C: ['D9', 'D10'] }, discard: ['H8'] }); + + test('caught: B presses first → A takes the penalty (1장) and the window closes', () => { + let s = play(setupOne(), 'A', ['H5']); + expect(s.oneCardWindow).toMatchObject({ player: 'A', declared: false }); + expect(oneCard.activePlayers(s).sort()).toEqual(['A', 'B', 'C']); + const out = actE(s, 'B', { type: 'declareOneCard' }); + s = out.state; + expect(s.hands.A).toHaveLength(2); + expect(s.oneCardWindow).toBeNull(); + expect(out.events).toContainEqual({ type: 'oneCardCaught', catcher: 'B', target: 'A', penalty: 1 }); + expect(s.current).toBe('B'); + }); + + test('A presses first → safe; B is ignored afterwards', () => { + let s = play(setupOne(), 'A', ['H5']); + s = act(s, 'A', { type: 'declareOneCard' }); + expect(s.oneCardWindow).toMatchObject({ declared: true }); + const before = JSON.stringify(s); + expect(reject(s, 'B', { type: 'declareOneCard' })).toBe('이미 원카드를 외쳤어요.'); + expect(JSON.stringify(s)).toBe(before); + }); + + test('play with declareOneCard: true cannot be caught; autoDeclare does it for everyone', () => { + const s = play(setupOne(), 'A', ['H5'], { declareOneCard: true }); + expect(reject(s, 'B', { type: 'declareOneCard' })).not.toBeNull(); + const auto = play(setupOne({ autoDeclare: true }), 'A', ['H5']); + expect(auto.oneCardWindow?.declared).toBe(true); + }); + + test('classic window lifetime and the colour-card rule (closes on the next player’s first action)', () => { + let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['H5', 'C9'], B: ['H9', 'S10', 'S4'], C: ['H10', 'D10', 'S3'] }, discard: ['H8'] }); + s = play(s, 'A', ['H5']); + s = play(s, 'B', ['H9']); + s = play(s, 'C', ['H10']); + expect(s.oneCardWindow?.player).toBe('A'); + s = act(s, 'A', { type: 'draw' }); + expect(s.oneCardWindow).toBeNull(); + let u = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a', 'B2a'], C: ['Y1a'] }, discard: ['R8a'] }); + u = play(u, 'A', ['R5a']); + expect(u.oneCardWindow?.player).toBe('A'); + u = play(u, 'B', ['R9a']); + expect(u.oneCardWindow).toBeNull(); + expect(reject(u, 'C', { type: 'declareOneCard' })).not.toBeNull(); + }); + + test('colour cards: being caught costs 2', () => { + let u = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a'] }, discard: ['R8a'] }); + u = play(u, 'A', ['R5a']); + u = act(u, 'B', { type: 'declareOneCard' }); + expect(u.hands.A).toHaveLength(3); + }); +}); + +// ---------------------------------------------------------------- bankrupt & draw pile + +describe('파산과 드로우 더미 (§5.4, §5.5)', () => { + const fifteen = ['C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'D3', 'D4', 'D6', 'D8']; + + test('15장에서 5장을 먹어 20장 → 즉시 파산; cards go under the draw pile; 2 players → opponent wins', () => { + let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'S9'], B: fifteen }, discard: ['S5'] }); + s = play(s, 'A', ['JB']); + const pileBefore = s.drawPile.length; + const out = actE(s, 'B', { type: 'draw' }); + s = out.state; + expect(s.status.B).toBe('bankrupt'); + expect(s.hands.B).toEqual([]); + expect(s.drawPile).toHaveLength(pileBefore + 15); + expect(s.drawPile.slice(-20).map((c) => c.id).slice(0, 15)).toEqual(fifteen); + expect(out.events.some((e) => e.type === 'bankrupt')).toBe(true); + expect(s.phase.kind).toBe('finished'); + expect(oneCard.result(s)!.ranking).toEqual([['A'], ['B']]); + expect(cardTotal(s)).toBe(54); + }); + + test('3 players: the bankrupt player leaves the order and the next one plays a normal turn', () => { + let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['JB', 'S9'], B: fifteen, C: ['S10', 'H4'] }, discard: ['S5'] }); + s = play(s, 'A', ['JB']); + s = act(s, 'B', { type: 'draw' }); + expect(s.status.B).toBe('bankrupt'); + expect(s.current).toBe('C'); + expect(s.pendingAttack).toBe(0); + s = play(s, 'C', ['S10']); + expect(s.current).toBe('A'); + expect(oneCard.view(s, null).seats.find((x) => x.id === 'B')).toMatchObject({ status: 'bankrupt', cards: 0 }); + }); + + test('draw pile empty: the discard pile (minus the top) is shuffled back; still short → draw what exists', () => { + let s = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'S6', 'S8', 'S9', 'S10', 'S11', 'S12', 'JC'], drawPile: [], pending: 7 }); + s.current = 'B'; + const out = actE(s, 'B', { type: 'draw' }); + s = out.state; + expect(s.hands.B).toHaveLength(8); + expect(s.discard.map((c) => c.id)).toEqual(['S12', 'JC']); + expect(s.drawPile).toHaveLength(1); + expect(out.events.some((e) => e.type === 'reshuffled')).toBe(true); + let t = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'JC'], drawPile: [], pending: 7 }); + t.current = 'B'; + t = act(t, 'B', { type: 'draw' }); + // The joker on top and the card under it (what decides the next play) stay; only ♠3·♠4 come back. + expect(t.discard.map((c) => c.id)).toEqual(['S5', 'JC']); + expect(t.hands.B).toHaveLength(3); + expect(t.pendingAttack).toBe(0); + expect(t.current).toBe('C'); + }); +}); + +// ---------------------------------------------------------------- colour cards + +describe('색깔 카드 (§5.6)', () => { + const uno = (opts: Partial = {}, players = ['A', 'B', 'C']) => make({ mode: 'uno-style', ...opts }, players); + + test('108 cards: 4 colours × 25 + 8 wild', () => { + const d = unoDeck(); + expect(d).toHaveLength(108); + expect(new Set(d.map((c) => c.id)).size).toBe(108); + expect(d.filter((c) => c.kind === 'wild' || c.kind === 'wild4')).toHaveLength(8); + expect(d.filter((c) => 'color' in c && c.color === 'R')).toHaveLength(25); + }); + + test('colour or symbol match; wild needs a colour', () => { + const s = rig(uno(), { hands: { A: ['G5a', 'B7a', 'RSa', 'W1', 'Y2a'] }, discard: ['R5a'] }); + expect(oneCard.view(s, 'A').my!.legalCardIds.sort()).toEqual(['G5a', 'RSa', 'W1']); + expect(reject(s, 'A', { type: 'play', cardIds: ['W1'] })).toBe('색을 골라 주세요.'); + const t = play(s, 'A', ['W1'], { chooseColor: 'Y' }); + expect(t.declaredColor).toBe('Y'); + expect(oneCard.view(t, 'B').currentColor).toBe('Y'); + }); + + test('strict: red 3 in hand → 색 바꾸기+4 refused; no red, only blue +2 → allowed', () => { + const s = rig(uno(), { hands: { A: ['R3a', 'F1', 'G1a'] }, discard: ['R5a'] }); + expect(reject(s, 'A', { type: 'play', cardIds: ['F1'], chooseColor: 'G' })).toBe('지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.'); + let t = rig(uno(), { hands: { A: ['BDa', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] }); + t = play(t, 'A', ['F1'], { chooseColor: 'B' }); + expect(t.hands.B).toHaveLength(6); + expect(t.current).toBe('C'); + expect(t.declaredColor).toBe('B'); + }); + + test('challenge: violation → the player of +4 draws 4; fine → the challenger draws 6; the hand is shown to the challenger only', () => { + let s = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['R3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] }); + s = play(s, 'A', ['F1'], { chooseColor: 'G' }); + expect(s.phase.kind).toBe('wild4Response'); + expect(oneCard.activePlayers(s)).toEqual(['B']); + expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain('illegal'); + s = act(s, 'B', { type: 'challengeWild4' }); + expect(s.hands.A).toHaveLength(6); + expect(s.hands.B).toHaveLength(2); + expect(s.current).toBe('B'); + const vb = oneCard.view(s, 'B'); + expect(vb.reveal).toMatchObject({ by: 'A', illegal: true }); + expect(vb.reveal!.cards).toHaveLength(2); + expect(oneCard.view(s, 'C').reveal).toBeNull(); + expect(oneCard.view(s, 'A').reveal).toBeNull(); + expect(oneCard.view(s, null).reveal).toBeNull(); + // The revealed cards carry no ids, so nobody learns which physical card is where. + for (const c of s.hands.A!) expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain(`"${c.id}"`); + + let t = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] }); + t = play(t, 'A', ['F1'], { chooseColor: 'G' }); + t = act(t, 'B', { type: 'challengeWild4' }); + expect(t.hands.B).toHaveLength(8); + expect(t.hands.A).toHaveLength(2); + expect(t.current).toBe('C'); + expect(oneCard.view(t, 'B').reveal).toMatchObject({ illegal: false }); + + let u = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] }); + u = play(u, 'A', ['F1'], { chooseColor: 'G' }); + u = act(u, 'B', { type: 'acceptWild4' }); + expect(u.hands.B).toHaveLength(6); + expect(u.current).toBe('C'); + }); + + test('2 players: 방향 바꾸기 works as 건너뛰기; +2 makes the opponent draw 2 and lose the turn', () => { + const s = rig(uno({}, ['A', 'B']), { hands: { A: ['RRa', 'RDa', 'G1a'], B: ['Y1a', 'Y2a'] }, discard: ['R5a'] }); + const r = play(s, 'A', ['RRa']); + expect(r.current).toBe('A'); + const d = play(r, 'A', ['RDa']); + expect(d.hands.B).toHaveLength(4); + expect(d.current).toBe('A'); + }); + + test('3 players: reverse turns the direction; skip skips one', () => { + const s = rig(uno(), { hands: { A: ['RRa', 'G1a'], B: ['Y1a'], C: ['RSa', 'Y3a'] }, discard: ['R5a'] }); + const r = play(s, 'A', ['RRa']); + expect(r.direction).toBe(-1); + expect(r.current).toBe('C'); + expect(play(r, 'C', ['RSa']).current).toBe('A'); + }); + + test('start card +2: the first player draws 2 and loses the turn; skip / reverse / wild starts', () => { + const seen = new Set(); + for (let seed = 1; seed <= 3000 && seen.size < 4; seed++) { + const s = make({ mode: 'uno-style' }, ['A', 'B', 'C'], seed); + const start = s.discard[0]!; + expect(s.discard).toHaveLength(1); + expect(start.kind).not.toBe('wild4'); + const first = s.roundStarter; + const i = s.seats.indexOf(first); + if (start.kind === 'draw2' && !seen.has('draw2')) { + seen.add('draw2'); + expect(s.hands[first]).toHaveLength(9); + expect(s.current).toBe(s.seats[(i + 1) % 3]!); + } + if (start.kind === 'skip' && !seen.has('skip')) { + seen.add('skip'); + expect(s.current).toBe(s.seats[(i + 1) % 3]!); + } + if (start.kind === 'reverse' && !seen.has('reverse')) { + seen.add('reverse'); + expect(s.direction).toBe(-1); + expect(s.current).toBe(s.seats[(i + 2) % 3]!); + } + if (start.kind === 'wild' && !seen.has('wild')) { + seen.add('wild'); + expect(s.current).toBe(first); + // The first player may follow with any colour. + expect(oneCard.view(s, first).my!.legalCardIds).toHaveLength(7); + } + } + expect(seen.size).toBe(4); + }); + + test('stacking: +2 on +2 adds up; +4 only on +4; the next player takes the total', () => { + let s = rig(uno({ unoStacking: true }), { hands: { A: ['RDa', 'G1a'], B: ['BDa', 'Y2a'], C: ['RDb', 'F1', 'Y3a'] }, discard: ['R5a'] }); + s = play(s, 'A', ['RDa']); + expect(s.current).toBe('B'); + s = play(s, 'B', ['BDa']); + expect(s.pendingAttack).toBe(4); + s = play(s, 'C', ['F1'], { chooseColor: 'Y' }); + expect(s.pendingAttack).toBe(8); + expect(reject(s, 'A', { type: 'play', cardIds: ['G1a'] })).not.toBeNull(); + s = act(s, 'A', { type: 'draw' }); + expect(s.hands.A).toHaveLength(9); + expect(s.current).toBe('B'); + }); + + test('scoring: B=[7, +2], C=[색 바꾸기], D=[0, 3] → the winner gets 80', () => { + let s = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C', 'D']), { hands: { A: ['R5a'], B: ['R7a', 'BDa'], C: ['W1'], D: ['G0', 'Y3a'] }, discard: ['R2a'] }); + s = play(s, 'A', ['R5a']); + const r = oneCard.result(s)!; + expect(r.scores).toEqual({ A: 80, B: 0, C: 0, D: 0 }); + // Fewer cards first, then lower card points: C (1장) → D (2장, 3점) → B (2장, 27점). + expect(r.ranking).toEqual([['A'], ['C'], ['D'], ['B']]); + expect(s.final!.players.find((x) => x.player === 'A')).toMatchObject({ rank: 1, points: 80, cardsLeft: 0 }); + expect(r.summary).toContain('80점'); + }); + + test('going out on +2 still makes the next player draw (counted in the points)', () => { + let s = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['RDa'], B: ['R7a'] }, discard: ['R2a'] }); + s = play(s, 'A', ['RDa']); + expect(s.hands.B).toHaveLength(3); + expect(oneCard.result(s)!.scores!.A).toBe(7 + s.hands.B!.slice(1).reduce((a, c) => a + (c.kind === 'num' ? c.value : c.kind === 'wild' || c.kind === 'wild4' ? 50 : 20), 0)); + }); + + test('target score: rounds continue until someone reaches it', () => { + let s = rig(make({ mode: 'uno-style', unoTargetScore: 200 }, ['A', 'B']), { hands: { A: ['R5a'], B: ['W1', 'W2'] }, discard: ['R2a'] }); + s = play(s, 'A', ['R5a']); + expect(s.phase.kind).toBe('roundEnd'); + expect(s.scores.A).toBe(100); + expect(oneCard.result(s)).toBeNull(); + expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 8000); + s = act(s, 'A', { type: 'ready' }); + expect(reject(s, 'A', { type: 'ready' })).not.toBeNull(); + s = act(s, 'B', { type: 'timeout' }); + expect(s.round).toBe(2); + expect(s.phase.kind).not.toBe('roundEnd'); + expect(cardTotal(s)).toBe(108); + s = rig(s, { hands: { A: ['W3'], B: ['W1', 'W2'] }, discard: ['R2a'] }); + s = play(s, 'A', ['W3'], { chooseColor: 'R' }); + expect(s.phase.kind).toBe('finished'); + expect(oneCard.result(s)!.scores).toEqual({ A: 200, B: 0 }); + expect(oneCard.result(s)!.summary).toContain('목표 200점'); + }); +}); + +// ---------------------------------------------------------------- timers, leaving + +describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => { + test('timeout while attacked → takes the attack; afterDraw → pass; wild4Response → accept', () => { + let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] }); + s = play(s, 'A', ['H2']); + expect(oneCard.onTimeout(s, 'B')).toEqual({ type: 'timeout' }); + s = act(s, 'B', oneCard.onTimeout(s, 'B')); + expect(s.hands.B).toHaveLength(4); + expect(s.current).toBe('C'); + let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] }); + t = act(t, 'A', { type: 'draw' }); + t = act(t, 'A', { type: 'timeout' }); + expect(t.current).toBe('B'); + expect(t.hands.A).toHaveLength(3); + let u = rig(make({ mode: 'uno-style', unoWild4Rule: 'challenge' }), { hands: { A: ['F1', 'G1a'], B: ['Y1a'], C: ['Y3a'] }, discard: ['R5a'] }); + u = play(u, 'A', ['F1'], { chooseColor: 'G' }); + expect(oneCard.deadline!(u)).toBe(u.turnStartedAt + 10_000); + u = act(u, 'B', { type: 'timeout' }); + expect(u.hands.B).toHaveLength(5); + }); + + test('normal-turn timeout draws one card and never plays one, even with a playable card', () => { + let s = rig(make({ playAfterDraw: 'on', voluntaryDraw: false }), { hands: { A: ['H9', 'C4'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] }); + s = act(s, 'A', { type: 'timeout' }); + expect(ids(s, 'A').sort()).toEqual(['C4', 'H10', 'H9']); + expect(s.current).toBe('B'); + }); + + test('a disconnected player keeps being auto-drawn on the normal timer; after 3 in a row the limit is 5 s', () => { + let s = make({}, ['A', 'B']); + const victim = s.current; + const other = s.seats.find((p) => p !== victim)!; + for (let k = 0; k < 3; k++) { + expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 20_000); + s = act(s, victim, { type: 'timeout' }); + // The opponent plays on (draws or plays). + if (s.current === other) { + const legal = oneCard.legalActions!(s, other).filter((a) => a.type === 'draw'); + s = act(s, other, legal[0] ?? { type: 'timeout' }); + } + if (s.oneCardWindow) s.oneCardWindow = null; + } + expect(s.away[victim]).toBe(true); + expect(s.current).toBe(victim); + expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 5000); + expect(oneCard.view(s, null).seats.find((x) => x.id === victim)!.away).toBe(true); + s = act(s, victim, { type: 'draw' }); + expect(s.away[victim]).toBe(false); + }); + + test('leaving: the current player leaves → next player; 2 players → the other one wins', () => { + let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] }); + expect(oneCard.onLeave!(s, 'A')).toEqual({ type: 'leave' }); + s = act(s, 'A', { type: 'leave' }); + expect(s.status.A).toBe('left'); + expect(s.current).toBe('B'); + expect(cardTotal(s)).toBe(54); + expect(oneCard.onLeave!(s, 'A')).toBeNull(); + s = act(s, 'B', { type: 'leave' }); + expect(s.phase.kind).toBe('finished'); + expect(oneCard.result(s)!.ranking).toEqual([['C'], ['B'], ['A']]); + }); +}); + +// ---------------------------------------------------------------- validation & leaks + +describe('검증과 정보 유출 (§8.3, §10)', () => { + test('not my turn / card not in hand / resent packet are refused and change nothing', () => { + const s = rig(make(), { hands: { A: ['H9', 'C4'], B: ['H10', 'S9'] }, discard: ['H5'] }); + const before = JSON.stringify(s); + expect(reject(s, 'B', { type: 'play', cardIds: ['H10'] })).toBe('지금은 내 차례가 아니에요.'); + expect(reject(s, 'A', { type: 'play', cardIds: ['H10'] })).toBe('손에 없는 카드예요.'); + expect(reject(s, 'A', { type: 'play', cardIds: ['H9', 'H9'] })).not.toBeNull(); + expect(reject(s, 'Z', { type: 'draw' })).toBe('이 게임의 플레이어가 아니에요.'); + expect(JSON.stringify(s)).toBe(before); + const t = play(s, 'A', ['H9']); + expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).not.toBeNull(); + t.current = 'A'; + expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).toBe('손에 없는 카드예요.'); + }); + + test('views: opponents and spectators see counts only; cardsDrawn events carry counts only', () => { + let s = make({}, ['A', 'B', 'C'], 9); + const chooser = SeededRng.fromSeed([1, 2, 3, 4]); + for (let k = 0; k < 120 && !oneCard.result(s); k++) { + for (const viewer of [...s.seats, null]) { + const json = JSON.stringify(oneCard.view(s, viewer)); + for (const owner of s.seats) if (owner !== viewer) for (const c of s.hands[owner]!) expect(json).not.toContain(`"${c.id}"`); + for (const c of s.drawPile) expect(json).not.toContain(`"${c.id}"`); + } + const actives = oneCard.activePlayers(s); + const p = actives[chooser.int(actives.length)]!; + const legal = oneCard.legalActions!(s, p); + const out = oneCard.apply(s, p, legal[chooser.int(legal.length)]!, { rng: rng(), now: k * 1000 }); + for (const e of out.events) { + if (e.type === 'cardsDrawn') expect(Object.keys(e).sort()).toEqual(['count', 'player', 'type', 'why']); + const json = JSON.stringify(e); + for (const owner of s.seats) for (const c of out.state.hands[owner]!) expect(json).not.toContain(`"${c.id}"`); + } + s = out.state; + } + const v = oneCard.view(s, 'A'); + expect(v.seats.every((x) => typeof x.cards === 'number')).toBe(true); + expect(oneCard.view(s, null).my).toBeNull(); + }); + + test('options: defaults, Korean metadata on every option, mode-dependent defaults resolved in the view', () => { + expect(oneCard.defaultOptions).toMatchObject({ mode: 'classic', handSize: 7, attack2: 2, attackA: 3, attackSpadeA: 5, attackBlackJoker: 5, attackColorJoker: 7, turnSeconds: 20 }); + const shape = (oneCard.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy(); + const c = oneCard.view(make(), 'A'); + expect([c.penalty, c.bankruptLimit, c.playAfterDraw]).toEqual([1, 20, false]); + const u = oneCard.view(make({ mode: 'uno-style' }), 'A'); + expect([u.penalty, u.bankruptLimit, u.playAfterDraw]).toEqual([2, null, true]); + expect(oneCard.optionsSchema.safeParse({ attackColorJoker: 14 }).success).toBe(false); + }); + + test('client actions only (system actions are not in the schema)', () => { + expect(oneCard.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + expect(oneCard.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: ['H9'], chooseSuit: 'S' }).success).toBe(true); + expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false); + }); +}); + +// ---------------------------------------------------------------- properties + +const TOKEN = /"[^"\\]*"/g; + +/** Checks the given viewers (default: every player and a spectator) against every player's secrets. */ +function checkLeaks(s: OneCardState, players: PlayerId[], viewers: (PlayerId | null)[] = [...players, null]): void { + // Card ids are plain strings, so every JSON string token of a view is compared with the secret ids. + const owners = new Map(); + for (const owner of players) + for (const secret of oneCard.secretsOf!(s, owner)) { + const k = `"${secret}"`; + const list = owners.get(k); + if (list) list.push(owner); + else owners.set(k, [owner]); + } + for (const viewer of viewers) + for (const tok of JSON.stringify(oneCard.view(s, viewer)).match(TOKEN) ?? []) { + const of = owners.get(tok); + if (of && of.some((o) => o !== viewer)) throw new Error(`leak ${tok} of ${of.join(',')} to ${viewer}`); + } +} + +/** Events go to everyone: no card id that is hidden after the action may appear in them. */ +function checkEvents(events: GameEvent[], s: OneCardState): void { + const json = JSON.stringify(events); + const hidden = new Set([...s.drawPile, ...s.seats.flatMap((p) => s.hands[p]!)].map((c) => `"${c.id}"`)); + for (const tok of json.match(TOKEN) ?? []) if (hidden.has(tok)) throw new Error(`event leaks ${tok}`); +} + +function invariants(s: OneCardState): void { + if (cardTotal(s) !== deckSize(s)) throw new Error(`cards not conserved: ${cardTotal(s)} vs ${deckSize(s)}`); + const seen = new Set(); + for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) { + if (seen.has(c.id)) throw new Error(`duplicate card ${c.id}`); + seen.add(c.id); + } + if (s.pendingAttack < 0) throw new Error('negative attack'); + for (const p of s.seats) if (s.status[p] !== 'playing' && s.hands[p]!.length > 0 && s.phase.kind !== 'roundEnd') throw new Error(`${p} out of play but holds cards`); + if (s.oneCardWindow && s.hands[s.oneCardWindow.player]!.length !== 1) throw new Error('one-card window without exactly one card'); + if ((s.phase.kind === 'turn' || s.phase.kind === 'afterDraw') && s.status[s.current] !== 'playing') throw new Error('current player is not playing'); +} + +interface FastReport { + steps: number; + finished: boolean; + trace: string; + final: OneCardState; +} + +/** + * Same chooser and action selection as `randomPlayout`, with cheaper checks so 1,000 games per mode stay fast: + * invariants and onTimeout legality every step; view / event leaks (rotating viewer) and JSON round trip on a schedule. + */ +function fastPlayout(players: PlayerId[], options: OneCardOptions, seed: RngState, maxSteps: number): FastReport { + const r = SeededRng.fromSeed(seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let now = 1_700_000_000_000; + let s = oneCard.setup({ players, options, rng: r, now }); + const trace: string[] = []; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + // A spectator and one rotating player every 10th step; everyone at the end. + if (step % 10 === 0) checkLeaks(s, players, [null, players[(step / 10) % players.length]!]); + if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable'); + for (const p of oneCard.timeoutPlayers!(s)) { + const v = oneCard.validate(s, p, oneCard.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`); + } + if (oneCard.result(s)) { + checkLeaks(s, players); + return { steps: step, finished: true, trace: trace.join(';'), final: s }; + } + const actives = oneCard.activePlayers(s); + if (!actives.length) throw new Error('no active players'); + const actor = actives[chooser.int(actives.length)]!; + const legal = oneCard.legalActions!(s, actor); + const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : oneCard.onTimeout(s, actor); + const v = oneCard.validate(s, actor, action); + if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`); + now += 1000; + const out = oneCard.apply(s, actor, action, { rng: r, now }); + // Events never carry card ids at all (played cards are sent as faces); a full check every 10th step. + if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id'); + if (step % 10 === 5) checkEvents(out.events, out.state); + s = out.state; + trace.push(`${actor}:${JSON.stringify(action)}`); + } + return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s }; +} + +const PRESETS: Record<'classic' | 'uno-style', Partial[]> = { + classic: [ + {}, + { voluntaryDraw: false, multiPlay: true }, + { kEffect: 'skip-two', qInTwoPlayer: 'as-skip', lastCardRule: 'special', afterJoker: 'any', threeDefense: 'off' }, + { endCondition: 'last-standing', bankruptLimit: 15, handSize: 5, deckCount: 2, playAfterDraw: 'on' }, + { multiPlay: true, lastCardRule: 'attack', bankruptLimit: 'none', autoDeclare: true, attackColorJoker: 13, attackSpadeA: 10 }, + ], + 'uno-style': [ + {}, + { voluntaryDraw: false, unoWild4Rule: 'challenge' }, + { unoStacking: true, unoWild4Rule: 'challenge', bankruptLimit: 25, voluntaryDraw: false }, + { endCondition: 'last-standing', unoWild4Rule: 'free', bankruptLimit: 18, playAfterDraw: 'off', handSize: 5 }, + { unoTargetScore: 200, voluntaryDraw: false, bankruptLimit: 20, autoDeclare: true }, + ], +}; + +describe('random playouts (1,000 seeds per mode)', () => { + for (const mode of ['classic', 'uno-style'] as const) { + test(`${mode}: 1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => { + const max = mode === 'classic' ? 9 : 10; + let finished = 0; + for (let i = 1; i <= 1000; i++) { + const n = 2 + (i % (max - 1)); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = oneCard.optionsSchema.parse({ mode, ...PRESETS[mode][i % PRESETS[mode].length]! }); + const a = fastPlayout(players, options, seedN(i), 30_000); + if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 30,000 steps`); + finished++; + const res = oneCard.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + if (i % 25 === 0) { + const b = fastPlayout(players, options, seedN(i), 30_000); + expect(b.trace).toBe(a.trace); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(finished).toBe(1000); + }, 120_000); + } + + test.each([ + ['classic', 2, {}], + ['classic', 3, { multiPlay: true, voluntaryDraw: false }], + ['classic', 6, { endCondition: 'last-standing', voluntaryDraw: false }], + ['uno-style', 2, {}], + ['uno-style', 3, { unoStacking: true, unoWild4Rule: 'challenge', voluntaryDraw: false }], + ['uno-style', 4, { unoTargetScore: 200, voluntaryDraw: false }], + ] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %s %i players', (mode, n, opts) => { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = oneCard.optionsSchema.parse({ mode, ...opts }); + const conserve = (st: unknown) => invariants(st as OneCardState); + for (let i = 1; i <= 3; i++) { + const a = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, invariant: conserve, maxApplyMs: 30 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, maxApplyMs: 30 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }, 60_000); +}); + diff --git a/packages/games/src/one-card/rules.ts b/packages/games/src/one-card/rules.ts new file mode 100644 index 0000000..70e67eb --- /dev/null +++ b/packages/games/src/one-card/rules.ts @@ -0,0 +1,165 @@ +/** Card legality for both modes (docs/games/one-card.md §5.2, §5.6). Pure helpers over the state. */ +import type { PlayerId } from '@bg/engine'; +import { COLOR_KO, SUIT_SYMBOL, colorOf, isJoker, isSpadeAce, isStd, rankLabel, type Card, type Color } from './cards'; +import type { OneCardOptions, OneCardState } from './types'; + +export const resolvedPenalty = (o: OneCardOptions): number => (o.oneCardPenalty === 'auto' ? (o.mode === 'classic' ? 1 : 2) : o.oneCardPenalty); +export const resolvedBankrupt = (o: OneCardOptions): number | null => + o.bankruptLimit === 'auto' ? (o.mode === 'classic' ? 20 : null) : o.bankruptLimit === 'none' ? null : o.bankruptLimit; +export const resolvedPlayAfterDraw = (o: OneCardOptions): boolean => (o.playAfterDraw === 'auto' ? o.mode === 'uno-style' : o.playAfterDraw === 'on'); + +export const top = (s: OneCardState): Card => s.discard[s.discard.length - 1]!; + +/** Classic: the first non-joker from the top (the start card is never a joker). */ +export function referenceCard(s: OneCardState): Card { + for (let i = s.discard.length - 1; i >= 0; i--) if (!isJoker(s.discard[i]!)) return s.discard[i]!; + return top(s); +} + +/** Attack size of one classic card (0 = not an attack card). */ +export function attackOf(o: OneCardOptions, c: Card): number { + if (c.kind === 'joker') return c.joker === 'black' ? o.attackBlackJoker : o.attackColorJoker; + if (c.kind !== 'std') return 0; + if (c.rank === 2) return o.attack2; + if (c.rank === 1) return c.suit === 'S' ? o.attackSpadeA : o.attackA; + return 0; +} + +/** §5.2 A: may `c` be played on top of the attack card `t`? */ +export function canCounter(t: Card, c: Card, twoDecks: boolean): boolean { + if (t.kind === 'joker') { + if (c.kind !== 'joker') return false; + if (t.joker === 'black') return c.joker === 'color' || twoDecks; + return twoDecks && c.joker === 'color'; + } + if (!isStd(t)) return false; + if (isJoker(c)) return true; + if (!isStd(c)) return false; + if (t.rank === 2) return c.rank === 2 || (c.rank === 1 && c.suit === t.suit); + if (t.rank === 1 && t.suit !== 'S') return c.rank === 1; + if (isSpadeAce(t)) return twoDecks && isSpadeAce(c); + return false; +} + +/** Same-suit 3 on a 2 / A attack (threeDefense). */ +export function canBlockWithThree(o: OneCardOptions, t: Card, c: Card): boolean { + return o.threeDefense === 'same-suit' && isStd(t) && (t.rank === 1 || t.rank === 2) && isStd(c) && c.rank === 3 && c.suit === t.suit; +} + +/** Classic normal turn (§5.2 B). */ +export function classicMatches(s: OneCardState, c: Card): boolean { + if (isJoker(c)) return true; + if (!isStd(c)) return false; + const t = top(s); + if (isJoker(t) && s.options.afterJoker === 'any') return true; + const ref = referenceCard(s); + if (!isStd(ref)) return true; + if (s.declaredSuit) return c.suit === s.declaredSuit || c.rank === 7; + return c.suit === ref.suit || c.rank === ref.rank; +} + +/** §5.2 D: cards that may not be the last card(s) of a hand. */ +export function forbiddenAsLast(o: OneCardOptions, c: Card): boolean { + switch (o.lastCardRule) { + case 'none': + return false; + case 'joker': + return isJoker(c); + case 'attack': + return isJoker(c) || (isStd(c) && (c.rank === 1 || c.rank === 2)); + case 'special': + return isJoker(c) || (isStd(c) && [1, 2, 3, 7, 11, 12, 13].includes(c.rank)); + } +} + +/** Colour cards: the colour to follow (null when a starting wild left it open). */ +export function currentColor(s: OneCardState): Color | null { + return s.declaredColor ?? colorOf(top(s)); +} + +function unoSymbolMatch(t: Card, c: Card): boolean { + if (t.kind === 'num' && c.kind === 'num') return t.value === c.value; + return (t.kind === 'skip' || t.kind === 'reverse' || t.kind === 'draw2') && t.kind === c.kind; +} + +/** Colour cards normal turn (§5.6), including the strict +4 rule against `hand`. */ +export function unoMatches(s: OneCardState, c: Card, hand: readonly Card[]): boolean { + const color = currentColor(s); + if (c.kind === 'wild') return true; + if (c.kind === 'wild4') { + if (s.options.unoWild4Rule !== 'strict' || color === null) return true; + return !hand.some((h) => h.id !== c.id && colorOf(h) === color); + } + if (color === null) return true; + return colorOf(c) === color || unoSymbolMatch(top(s), c); +} + +/** Colour cards stacking (§5.6 unoStacking): +2 on +2, +4 on +2 or +4. */ +export function unoCanStack(t: Card, c: Card): boolean { + if (t.kind === 'draw2') return c.kind === 'draw2' || c.kind === 'wild4'; + if (t.kind === 'wild4') return c.kind === 'wild4'; + return false; +} + +/** + * May `c` start a play by `p` right now (phase turn / afterDraw / wild4Response stacking)? + * The last-card rule is checked against a single-card play here; multi-play callers re-check. + */ +export function canStartWith(s: OneCardState, p: PlayerId, c: Card): boolean { + const hand = s.hands[p] ?? []; + if (s.mode === 'classic') { + let ok: boolean; + if (s.pendingAttack > 0) ok = canCounter(top(s), c, s.decks === 2) || canBlockWithThree(s.options, top(s), c); + else ok = classicMatches(s, c); + if (!ok) return false; + if (hand.length === 1 && forbiddenAsLast(s.options, c)) return false; + return true; + } + if (s.pendingAttack > 0) return s.options.unoStacking && unoCanStack(top(s), c); + return unoMatches(s, c, hand); +} + +/** Card ids `p` may play now (first card of a play), respecting phase. */ +export function legalCardIds(s: OneCardState, p: PlayerId): string[] { + if (s.current !== p && !(s.phase.kind === 'wild4Response' && s.phase.target === p)) return []; + const hand = s.hands[p] ?? []; + switch (s.phase.kind) { + case 'turn': + if (s.current !== p) return []; + return hand.filter((c) => canStartWith(s, p, c)).map((c) => c.id); + case 'afterDraw': { + const id = s.phase.drawnCardId; + const c = hand.find((x) => x.id === id); + return c && canStartWith(s, p, c) ? [id] : []; + } + case 'wild4Response': + if (s.phase.target !== p || !s.options.unoStacking) return []; + return hand.filter((c) => c.kind === 'wild4').map((c) => c.id); + default: + return []; + } +} + +/** Beginner hint for why a card fits (§9 "힌트"). */ +export function hintFor(s: OneCardState, c: Card): string { + const t = top(s); + if (s.mode === 'classic') { + if (s.pendingAttack > 0) { + if (canBlockWithThree(s.options, t, c) && !canCounter(t, c, s.decks === 2)) return '같은 무늬 3으로 공격을 막아요'; + return '공격을 받아쳐요'; + } + if (isJoker(c)) return '조커는 언제든 낼 수 있어요'; + if (isJoker(t) && s.options.afterJoker === 'any') return '조커 다음이라 아무 카드나 돼요'; + const ref = referenceCard(s); + if (!isStd(c) || !isStd(ref)) return ''; + if (s.declaredSuit) return c.suit === s.declaredSuit ? `지정된 ${SUIT_SYMBOL[s.declaredSuit]} 무늬예요` : '7은 7 위에 낼 수 있어요'; + if (c.suit === ref.suit) return `${SUIT_SYMBOL[c.suit]} 무늬가 같아요`; + return `${rankLabel(c.rank)} 숫자가 같아요`; + } + if (s.pendingAttack > 0) return '받아쳐서 다음 사람에게 넘겨요'; + if (c.kind === 'wild' || c.kind === 'wild4') return '색을 바꾸는 카드예요'; + const color = currentColor(s); + if (color === null) return '아무 색이나 낼 수 있어요'; + if (colorOf(c) === color) return `${COLOR_KO[color]} 색이 같아요`; + return '숫자·기호가 같아요'; +} diff --git a/packages/games/src/one-card/types.ts b/packages/games/src/one-card/types.ts new file mode 100644 index 0000000..31ad55f --- /dev/null +++ b/packages/games/src/one-card/types.ts @@ -0,0 +1,263 @@ +/** 원카드 options, actions, state and view types (docs/games/one-card.md §2, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; +import type { Card, Color, Suit } from './cards'; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const oneCardOptionsSchema = z.object({ + mode: z + .enum(['classic', 'uno-style']) + .default('classic') + .meta({ title: '게임 모드', labels: { classic: '원카드(트럼프)', 'uno-style': '색깔 카드' } }), + handSize: lit(5, 6, 7).default(7).meta({ title: '처음 받는 카드 수', labels: { 5: '5장', 6: '6장', 7: '7장' } }), + deckCount: lit<'auto' | 1 | 2>('auto', 1, 2) + .default('auto') + .meta({ title: '(원카드) 덱 수', labels: { auto: '자동(6명부터 2벌)', 1: '1벌', 2: '2벌' } }), + attack2: lit(1, 2, 3).default(2).meta({ title: '(원카드) 2 공격 장수', labels: { 1: '1장', 2: '2장', 3: '3장' } }), + attackA: lit(2, 3, 4, 5).default(3).meta({ title: '(원카드) A 공격 장수(♠ 제외)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장' } }), + attackSpadeA: z.number().int().min(3).max(10).default(5).meta({ title: '(원카드) ♠A 공격 장수(3~10)' }), + attackBlackJoker: z.number().int().min(5).max(10).default(5).meta({ title: '(원카드) 흑백 조커 공격 장수(5~10)' }), + attackColorJoker: z.number().int().min(5).max(13).default(7).meta({ title: '(원카드) 컬러 조커 공격 장수(5~13)' }), + threeDefense: z + .enum(['off', 'same-suit']) + .default('same-suit') + .meta({ title: '(원카드) 3으로 막기', labels: { off: '안 씀', 'same-suit': '같은 무늬 3으로 2·A 공격 막기' } }), + kEffect: z + .enum(['extra-turn', 'skip-two', 'none']) + .default('extra-turn') + .meta({ title: '(원카드) K 효과', labels: { 'extra-turn': '한 번 더', 'skip-two': '두 명 건너뛰기', none: '효과 없음' } }), + qInTwoPlayer: z + .enum(['no-effect', 'as-skip']) + .default('no-effect') + .meta({ title: '(원카드) 2명일 때 Q', labels: { 'no-effect': '효과 없음', 'as-skip': 'J처럼 한 번 더' } }), + lastCardRule: z + .enum(['none', 'joker', 'attack', 'special']) + .default('joker') + .meta({ + title: '(원카드) 마지막 카드로 낼 수 없는 카드', + labels: { none: '제한 없음', joker: '조커', attack: '공격 카드(2·A·조커)', special: '특수 카드 모두' }, + }), + afterJoker: z + .enum(['previous', 'any']) + .default('previous') + .meta({ title: '(원카드) 조커 다음에 낼 카드', labels: { previous: '조커 밑 카드 기준', any: '아무 카드' } }), + multiPlay: z.boolean().default(false).meta({ title: '(원카드) 같은 숫자 여러 장 한꺼번에 내기' }), + oneCardPenalty: lit<'auto' | 1 | 2 | 3>('auto', 1, 2, 3) + .default('auto') + .meta({ title: '원카드를 못 외치고 잡히면 먹는 장수', labels: { auto: '모드 기본(원카드 1장, 색깔 카드 2장)', 1: '1장', 2: '2장', 3: '3장' } }), + autoDeclare: z.boolean().default(false).meta({ title: '1장 남으면 자동으로 원카드 외치기' }), + bankruptLimit: lit<'auto' | 15 | 18 | 20 | 25 | 'none'>('auto', 15, 18, 20, 25, 'none') + .default('auto') + .meta({ + title: '파산 장수(이만큼 쌓이면 탈락)', + labels: { auto: '모드 기본(원카드 20장, 색깔 카드 없음)', 15: '15장', 18: '18장', 20: '20장', 25: '25장', none: '파산 없음' }, + }), + voluntaryDraw: z.boolean().default(true).meta({ title: '낼 카드가 있어도 1장 먹고 넘기기' }), + playAfterDraw: lit('auto', 'on', 'off') + .default('auto') + .meta({ title: '먹은 카드를 바로 내기', labels: { auto: '모드 기본(원카드 안 됨, 색깔 카드 됨)', on: '바로 낼 수 있음', off: '안 됨' } }), + endCondition: z + .enum(['first-out', 'last-standing']) + .default('first-out') + .meta({ title: '끝나는 때', labels: { 'first-out': '1등이 나오면', 'last-standing': '꼴찌가 정해질 때까지' } }), + turnSeconds: lit(10, 15, 20, 30, 60) + .default(20) + .meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }), + unoStacking: z.boolean().default(false).meta({ title: '(색깔 카드) +2·+4 받아치기' }), + unoWild4Rule: z + .enum(['strict', 'challenge', 'free']) + .default('strict') + .meta({ + title: '(색깔 카드) 색 바꾸기+4 내는 조건', + labels: { strict: '지금 색 카드가 없을 때만', challenge: '아무 때나(다음 사람이 의심하기 가능)', free: '제한 없음' }, + }), + unoTargetScore: lit(0, 200, 500) + .default(0) + .meta({ title: '(색깔 카드) 목표 점수(여러 판)', labels: { 0: '없음(한 판)', 200: '200점', 500: '500점' } }), +}); +export type OneCardOptions = z.infer; +export type OneCardMode = OneCardOptions['mode']; + +export const oneCardActionSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('play'), + cardIds: z.array(z.string().max(8)).min(1).max(8), + chooseSuit: z.enum(['S', 'H', 'D', 'C']).optional(), + chooseColor: z.enum(['R', 'Y', 'G', 'B']).optional(), + declareOneCard: z.boolean().optional(), + }), + z.object({ type: z.literal('draw') }), + z.object({ type: z.literal('pass') }), + z.object({ type: z.literal('declareOneCard') }), + z.object({ type: z.literal('challengeWild4') }), + z.object({ type: z.literal('acceptWild4') }), + z.object({ type: z.literal('ready') }), +]); +/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */ +export type OneCardAction = z.infer | { type: 'timeout' } | { type: 'leave' }; +export type PlayAction = Extract; + +export type PlayerStatus = 'playing' | 'out' | 'bankrupt' | 'left'; + +export type Phase = + | { kind: 'turn' } + | { kind: 'afterDraw'; drawnCardId: string } + /** `illegal`: the +4 was played while holding a card of `prevColor` (secret until challenged). */ + | { kind: 'wild4Response'; by: PlayerId; target: PlayerId; prevColor: Color | null; illegal: boolean } + | { kind: 'roundEnd' } + | { kind: 'finished' }; + +/** Public log line (structured; the screen turns it into Korean with nicknames). */ +export type LogEntry = + /** Played cards without ids: ids are reused after a reshuffle and must not hint at hidden cards. */ + | { seq: number; t: 'play'; p: PlayerId; cards: CardFace[]; suit?: Suit; color?: Color } + | { seq: number; t: 'draw'; p: PlayerId; n: number; why: 'normal' | 'attack' | 'penalty' | 'draw2' | 'wild4' | 'challenge' | 'start' } + | { seq: number; t: 'pass'; p: PlayerId } + | { seq: number; t: 'blocked'; p: PlayerId } + | { seq: number; t: 'declare'; p: PlayerId } + | { seq: number; t: 'caught'; p: PlayerId; by: PlayerId; n: number } + | { seq: number; t: 'bankrupt'; p: PlayerId } + | { seq: number; t: 'out'; p: PlayerId; place: number } + | { seq: number; t: 'left'; p: PlayerId } + | { seq: number; t: 'timeout'; p: PlayerId } + | { seq: number; t: 'reshuffle'; n: number } + | { seq: number; t: 'challenge'; p: PlayerId; by: PlayerId; illegal: boolean } + | { seq: number; t: 'skip'; p: PlayerId } + | { seq: number; t: 'reverse' } + | { seq: number; t: 'round'; round: number; winner: PlayerId | null; points: number }; + +/** A card without its id (shown to the +4 challenger, so leak checks on ids stay meaningful). */ +export type CardFace = Card extends infer C ? (C extends Card ? Omit : never) : never; + +export interface PlayerResult { + player: PlayerId; + rank: number; + status: PlayerStatus; + cardsLeft: number; + /** Classic: remaining card points (lower is better). Colour cards: points won this game. */ + points: number; +} + +export interface OneCardFinal { + ranking: PlayerId[][]; + players: PlayerResult[]; + scores?: Record; + summary: string; +} + +export interface RoundRecord { + round: number; + winner: PlayerId | null; + points: number; +} + +export interface OneCardState { + mode: OneCardMode; + options: OneCardOptions; + /** Decks actually in use (classic). */ + decks: 1 | 2; + seats: PlayerId[]; + hands: Record; + /** Secret; [0] is the top. */ + drawPile: Card[]; + /** Public; the last card is the top. */ + discard: Card[]; + declaredSuit: Suit | null; + declaredColor: Color | null; + direction: 1 | -1; + current: PlayerId; + phase: Phase; + pendingAttack: number; + extraTurn: boolean; + oneCardWindow: { player: PlayerId; declared: boolean; openedSeq: number } | null; + status: Record; + finishOrder: PlayerId[]; + bankruptOrder: PlayerId[]; + leftOrder: PlayerId[]; + turnStartedAt: number; + /** Applied actions. */ + seq: number; + /** Turn counter (changes whenever a new turn starts). */ + turnNo: number; + timeoutsInRow: Record; + away: Record; + /** Colour-card multi-game scoring (§6). */ + round: number; + roundStarter: PlayerId; + scores: Record; + rounds: RoundRecord[]; + ready: PlayerId[]; + /** Hand shown to the challenger after a +4 challenge (only in that player's view). */ + reveal: { to: PlayerId; by: PlayerId; cards: CardFace[]; illegal: boolean; seq: number } | null; + log: LogEntry[]; + final: OneCardFinal | null; +} + +// ---------------------------------------------------------------- view + +export interface SeatView { + id: PlayerId; + cards: number; + status: PlayerStatus; + isTurn: boolean; + away: boolean; + /** One-card window open for this player (declared = safe). */ + oneCard: 'open' | 'declared' | null; + score: number; + ready: boolean; +} + +export interface OneCardView { + mode: OneCardMode; + options: OneCardOptions; + decks: 1 | 2; + penalty: number; + bankruptLimit: number | null; + playAfterDraw: boolean; + me: PlayerId | null; + seats: SeatView[]; + current: PlayerId | null; + direction: 1 | -1; + phase: Phase['kind']; + turnNo: number; + extraTurn: boolean; + /** Last few discards, top last. */ + recent: Card[]; + /** Classic: the card below jokers that decides what may follow (afterJoker = previous). */ + reference: Card | null; + declaredSuit: Suit | null; + declaredColor: Color | null; + /** Colour cards: colour to follow (null = any). */ + currentColor: Color | null; + pendingAttack: number; + drawPileCount: number; + discardCount: number; + oneCardWindow: { player: PlayerId; declared: boolean } | null; + wild4: { by: PlayerId; target: PlayerId } | null; + my: { + hand: Card[]; + /** Cards that may be played now (first card of a play). */ + legalCardIds: string[]; + /** Why each legal card fits (beginner hint). */ + hints: Record; + canDraw: boolean; + /** Cards a draw takes now. */ + drawCount: number; + canPass: boolean; + drawnCardId: string | null; + canDeclare: boolean; + canChallenge: boolean; + canReady: boolean; + /** Must follow the multi-play rule (same rank). */ + multiPlay: boolean; + } | null; + reveal: { by: PlayerId; cards: CardFace[]; illegal: boolean } | null; + turnStartedAt: number; + deadlineTotalMs: number; + round: number; + rounds: RoundRecord[]; + log: LogEntry[]; + result: OneCardFinal | null; +} diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index e91dbf2..1ab0053 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -18,6 +18,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true }, { id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true }, { id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true }, + { id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);