diff --git a/apps/web/src/games/ninety-nine/index.tsx b/apps/web/src/games/ninety-nine/index.tsx new file mode 100644 index 0000000..ac33bf3 --- /dev/null +++ b/apps/web/src/games/ninety-nine/index.tsx @@ -0,0 +1,855 @@ +/** 구십구 화면 (docs/games/ninety-nine.md §9). */ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; +import type { CardFace, CardKind, ChoiceView, HandCardView, LogEntry, NNOptions, NinetyNineView, SeatView } from '@bg/games/ninety-nine'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } 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'; + +const MODE_KO: Record = { 'kr-product': '전용 카드', classic: '트럼프 카드' }; +const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; +const SUIT_SYMBOL: Record = { S: '♠', H: '♥', D: '◆', C: '♣' }; +const TRUMP_RANK: Record = { '10': 'T' }; + +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 resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives); + +/** Short name, e.g. "±9", "조커", "♥K". */ +function faceName(f: CardFace): string { + return f.suit ? `${SUIT_SYMBOL[f.suit]}${f.label}` : f.label; +} +/** Screen-reader name, e.g. "하트 K", "±9 카드". */ +function ariaFace(f: CardFace): string { + if (f.suit) return `${SUIT_KO[f.suit]} ${f.label}`; + return f.label === '조커' ? '조커' : `${f.label} 카드`; +} +const fmt = (n: number) => String(n); +/** "−9" etc. use a real minus sign; "+9 → 95". */ +const arrow = (c: ChoiceView) => `${c.label} → ${c.legal ? fmt(c.total) : c.total > 99 ? '99 초과' : '0 미만'}`; + +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +/** Counts up/down to `target` (instant when reduced motion is preferred). */ +function useCountUp(target: number): number { + const [shown, setShown] = useState(target); + const from = useRef(target); + useEffect(() => { + const start = from.current; + from.current = target; + if (start === target || prefersReducedMotion()) { + setShown(target); + return; + } + let raf = 0; + const t0 = performance.now(); + const step = (t: number) => { + const k = Math.min(1, (t - t0) / 420); + setShown(Math.round(start + (target - start) * (1 - (1 - k) * (1 - k)))); + if (k < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target]); + return shown; +} + +/** Green → yellow → red as the total nears 99. */ +const gaugeColor = (t: number) => `hsl(${Math.max(0, 130 - (t / 99) * 130)} 78% 46%)`; + +const CSS = ` +.nn { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; } +.nn-opps { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 6px; margin: 0; list-style: none; scrollbar-width: thin; } +.nn-seat { flex: none; min-width: 118px; max-width: 176px; 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; transition: filter .4s ease, opacity .4s ease; } +.nn-seat.turn { border-color: var(--focus); } +.nn-seat.out { filter: grayscale(1); opacity: .6; } +.nn-seat .nm { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.nn-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; } +.nn-count i { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #7c2d12; border: 1.5px solid #fdba74; } +.nn-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; line-height: 1.6; color: #fff; } +.nn-hearts { display: inline-flex; gap: 1px; font-size: 1rem; line-height: 1; } +.nn-heart { color: #e11d48; display: inline-block; } +.nn-heart.lost { color: var(--text-sub); opacity: .55; animation: nn-break .7s ease; } +.nn-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 18px; display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: center; color: #fff; } +.nn-left, .nn-right { display: flex; flex-direction: column; gap: 8px; min-width: 0; text-shadow: 0 1px 2px rgba(0,0,0,.5); } +.nn-left { align-items: flex-start; } +.nn-right { align-items: flex-end; } +.nn-dial { position: relative; width: var(--dial); height: var(--dial); display: grid; place-items: center; } +.nn-dial svg { position: absolute; inset: 0; transform: rotate(-90deg); } +.nn-dial svg circle { transition: stroke-dashoffset .4s ease, stroke .4s ease; } +.nn-total { font-size: calc(var(--dial) * .42); font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; color: #fff; text-shadow: 0 2px 3px rgba(0,0,0,.35); } +.nn-total-cap { position: absolute; bottom: calc(var(--dial) * .2); font-size: .8rem; font-weight: 700; opacity: .9; } +.nn-dir { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; background: rgba(0,0,0,.3); border-radius: 999px; padding: 3px 12px 3px 6px; } +.nn-dir b { display: inline-block; font-size: 1.6rem; line-height: 1; transition: transform .45s ease; } +.nn-dir[data-dir='-1'] b { transform: scaleX(-1) rotate(-30deg); } +.nn-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.32); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .9rem; } +.nn-rule { font-weight: 800; color: #fde68a; } +.nn-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; align-items: flex-end; } +.nn-recent li { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.28); border-radius: 10px; padding: 2px 8px; font-size: .88rem; max-width: 100%; } +.nn-recent li:last-child { background: rgba(0,0,0,.5); font-weight: 800; animation: nn-pop .3s ease; } +.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; } +.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; } +.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; } +.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; } +.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; } +.nn-mini[data-red='true'] { color: #dc2626; } +.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; } +.nn-hand-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; } +.nn-hand { display: flex; gap: 10px; justify-content: center; padding-top: 30px; flex-wrap: wrap; row-gap: 34px; } +.nn-card { position: relative; flex: none; width: var(--cw); height: calc(var(--cw) * 1.4); padding: 0; border: 0; background: none; border-radius: 10px; + transition: transform .14s ease, opacity .14s ease; cursor: pointer; } +.nn-card[data-legal='false'] { opacity: .5; filter: grayscale(.5); } +.nn-card[data-legal='true'] { box-shadow: 0 0 0 3px var(--success); } +.nn-card[aria-pressed='true'] { transform: translateY(-10px); box-shadow: 0 0 0 4px var(--primary); z-index: 2; } +.nn-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; z-index: 3; } +.nn-prev { position: absolute; left: 50%; top: -27px; transform: translateX(-50%); white-space: nowrap; border-radius: 999px; padding: 1px 9px; + font-size: .82rem; font-weight: 800; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); pointer-events: none; } +.nn-prev.ok { background: var(--success-soft); color: var(--success); border-color: var(--success); } +.nn-prev.no { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); } +.nn-face { display: block; width: 100%; height: 100%; } +.nn-face svg, .nn-face > span:not(.nn-art) { width: 100% !important; height: 100% !important; display: block; } +.nn-art { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 10px; + background: #fffef9; border: 2px solid #9ca3af; color: #1f2937; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.25); } +.nn-art::before { content: ''; position: absolute; inset: 6px; border-radius: 7px; border: 2px solid var(--band); opacity: .55; } +.nn-art .big { font-size: calc(var(--cw, 90px) * .44); font-weight: 900; line-height: 1; color: var(--band); letter-spacing: -.03em; } +.nn-art .sub { margin-top: 4px; font-size: calc(var(--cw, 90px) * .13); font-weight: 800; color: var(--band); opacity: .9; } +.nn-art .corner { position: absolute; top: 5px; left: 8px; font-size: calc(var(--cw, 90px) * .17); font-weight: 900; color: var(--band); } +.nn-art .corner.br { top: auto; left: auto; bottom: 5px; right: 8px; transform: rotate(180deg); } +.nn-art[data-k='add'] { --band: #1d4ed8; } +.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; } +.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); } +.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); } +.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); } +.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; } +.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; } +.nn-joker { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; } +.nn-joker .val { min-width: 84px; text-align: center; font-size: 2rem; font-weight: 900; font-variant-numeric: tabular-nums; border-radius: 12px; + border: 2px solid var(--border); padding: 2px 8px; background: var(--surface-2); color: var(--text); } +.nn-joker .val:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.nn-joker .step { width: 52px; min-width: 52px; padding: 0; font-size: 1.5rem; font-weight: 900; } +.nn-joker .quick { min-width: 60px; } +.nn-quick { display: inline-flex; gap: 8px; } +.nn-joker .btn[aria-pressed='true'] { border-color: var(--primary); background: var(--primary); color: var(--primary-text); } +.nn-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 6px; } +.nn-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 240px; overflow: auto; } +.nn-rules td, .nn-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } +.nn-rules .nn-face { width: 34px; height: 48px; --cw: 34px; } +.nn-rules .nn-art::before { inset: 3px; } +@keyframes nn-pop { 0% { transform: scale(.7); } 70% { transform: scale(1.08); } 100% { transform: scale(1); } } +@keyframes nn-break { 0% { transform: scale(1); color: #e11d48; opacity: 1; } 40% { transform: scale(1.5) rotate(-15deg); } 100% { transform: scale(1); } } +@media (max-width: 719px) { + .nn-table { grid-template-columns: 1fr auto; grid-template-areas: 'dial left' 'right right'; padding: 10px; gap: 8px 10px; border-width: 6px; } + .nn-dial { grid-area: dial; } + .nn-left { grid-area: left; } + .nn-right { grid-area: right; align-items: stretch; } + .nn-recent { flex-direction: row; flex-wrap: wrap; justify-content: center; } + .nn-recent li:not(:nth-last-child(-n+3)) { display: none; } + .nn-seat { min-width: 100px; padding: 5px 6px; } + .nn-hand { gap: 8px; } + .nn-quick { flex-basis: 100%; justify-content: center; } +} +@media (prefers-reduced-motion: reduce) { + .nn-card, .nn-dir b, .nn-dial svg circle, .nn-seat { transition: none; } +} +`; + +// ---------------------------------------------------------------- cards + +const KR_SUB: Partial> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' }; + +function trumpId(f: CardFace): string { + return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`; +} + +function Face({ face, kind, jokerRange }: { face: CardFace; kind: CardKind; jokerRange?: { min: number; max: number } }) { + if (face.suit) { + return ( + + + + ); + } + const sub = kind === 'joker' ? (jokerRange ? `${jokerRange.min}~${jokerRange.max}` : '원하는 수') : (KR_SUB[kind] ?? ''); + const big = kind === 'joker' ? '★' : face.label; + return ( + + + {kind === 'joker' ? '조커' : face.label} + {big} + {sub && {sub}} + {kind === 'joker' ? '조커' : face.label} + + + ); +} + +function Mini({ face, kind }: { face: CardFace; kind: CardKind }) { + return ( + + {faceName(face)} + + ); +} + +// ---------------------------------------------------------------- seats + +function Hearts({ s, start }: { s: SeatView; start: number }) { + if (start <= 0) return null; + return ( + + {Array.from({ length: start }, (_, i) => ( + = s.lives ? ' lost' : ''}`}> + {i >= s.lives ? '♡' : '♥'} + + ))} + + ); +} + +function seatLabel(s: SeatView, v: NinetyNineView, players: Record, me: string | null): string { + const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`]; + if (!s.alive) parts.push(s.left ? '나감' : '탈락'); + else parts.push(`카드 ${s.cards}장`); + if (v.livesStart > 0) parts.push(`목숨 ${s.lives}개`); + if (s.onBoard && s.alive) parts.push('목숨 없이 마지막 기회'); + if (s.away) parts.push('자리 비움'); + if (s.isTurn) parts.push('차례'); + return parts.join(', '); +} + +function SeatChip({ s, v, players, me, deadline }: { s: SeatView; v: NinetyNineView; players: Record; me: string | null; deadline: number | null }) { + const u = userOf(players, s.id); + const rank = v.result?.ranking.find((r) => r.player === s.id)?.rank; + return ( +
  • +
    + + {u.nickname} + {s.isTurn && } +
    +
    + {s.alive ? ( + + + {s.cards} + + ) : ( + + {s.left ? '나감' : '탈락'} + + )} + + {s.onBoard && s.alive && 마지막 기회} + {s.away && 자리 비움} + {rank && {rank}등} +
    +
  • + ); +} + +// ---------------------------------------------------------------- table + +function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Record; me: string | null; deadline: number | null }) { + const shown = useCountUp(v.total); + const dial = 'clamp(150px, 20vw, 196px)'; + const r = 44; + const c = 2 * Math.PI * r; + const frac = Math.min(1, v.total / 99); + const cur = v.current; + return ( +
    +
    + + ↻ + {v.direction === 1 ? '시계 방향' : '반대 방향'} + + {cur && ( + + {cur === me ? '내 차례' : `${nick(players, cur)}님 차례`} + + + )} + + 더미 {v.drawPileCount}장 + + {v.livesStart > 0 && {v.round}판째} +
    +
    + + + + + + {shown} + + + / 99 + +
    +
    + 99를 넘기면 안 돼요! + {v.recent.length > 0 ? ( +
      + {v.recent.slice(-4).map((e) => ( +
    1. + {nick(players, e.p)} + + {e.choice && e.kind !== 'joker' ? {e.choice} : null} + → + {e.total} +
    2. + ))} +
    + ) : ( + {v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'} + )} +
    +
    + ); +} + +// ---------------------------------------------------------------- my hand + +function cardAria(c: HandCardView, myTurn: boolean): string { + const name = ariaFace(c.face); + if (c.joker) return `${name}, ${c.joker.min}부터 ${c.joker.max} 사이 수로 만들어요`; + const parts = c.choices.map((x) => (c.choices.length > 1 ? `${x.label}면 ` : '내면 ') + (x.legal ? `${x.total}` : x.total > 99 ? `${x.total}이라 낼 수 없음` : '0 미만이라 낼 수 없음')); + return `${name}, ${parts.join(', ')}${myTurn ? '' : ' (내 차례가 아니에요)'}`; +} + +function previewOf(c: HandCardView): { text: string; cls: string } { + if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' }; + if (c.badge) return { text: c.badge, cls: 'no' }; + const ok = c.choices.filter((x) => x.legal); + if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' }; + return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' }; +} + +function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) { + const { min, max } = c.joker!; + const [n, setN] = useState(() => Math.min(max, Math.max(min, total))); + const clamp = (x: number) => Math.min(max, Math.max(min, x)); + const quick = [...new Set([min, 90, 99].filter((x) => x >= min && x <= max))]; + const onKey = (e: KeyboardEvent) => { + const d: Record = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 }; + if (e.key in d) setN(clamp(n + d[e.key]!)); + else if (e.key === 'Home') setN(min); + else if (e.key === 'End') setN(max); + else if (e.key === 'Enter') onPlay(n); + else return; + e.preventDefault(); + }; + return ( +
    +
    + + + {n} + + + + {quick.map((q) => ( + + ))} + +
    +
    + +
    +
    + ); +} + +function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => void; pending: boolean }) { + const my = v.my!; + const [sel, setSel] = useState(null); + const [note, setNote] = useState(''); + const myTurn = my.myTurn; + useEffect(() => { + setSel(null); + setNote(''); + }, [v.turnNo]); + const chosen = my.hand.find((c) => c.id === sel) ?? null; + const send = (c: HandCardView, extra: Record = {}) => { + if (pending) return; + act({ type: 'play', cardId: c.id, ...extra }); + setSel(null); + }; + const single = (c: HandCardView) => c.choices.length === 1 && !c.joker; + const tap = (c: HandCardView) => { + if (!myTurn) { + setNote('지금은 내 차례가 아니에요. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.'); + setSel(sel === c.id ? null : c.id); + return; + } + if (!c.legal) { + setNote(`${ariaFace(c.face)}은(는) ${c.badge === '0 미만' ? '0보다 작아져서' : '99를 넘어서'} 낼 수 없어요.`); + setSel(null); + return; + } + setNote(''); + if (sel === c.id) { + if (single(c)) send(c); + else setSel(null); + return; + } + setSel(c.id); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSel(null); + return; + } + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const btns = Array.from(e.currentTarget.querySelectorAll('button.nn-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(); + }; + const n = my.hand.length; + const cw = n <= 3 ? 'clamp(84px, 24vw, 104px)' : n === 4 ? 'clamp(72px, 20vw, 96px)' : 'clamp(62px, 16.5vw, 90px)'; + const meSeat = v.seats.find((s) => s.id === v.me); + const legalCount = my.hand.filter((c) => c.legal).length; + return ( +
    +
    + + 내 카드 {n}장{myTurn ? ` · 낼 수 있는 카드 ${legalCount}장` : ''} + + {meSeat && v.livesStart > 0 && ( + + 목숨 + + )} +
    +
    + {my.hand.map((c) => { + const p = previewOf(c); + return ( + + ); + })} + {n === 0 && 손에 카드가 없어요.} +
    + {note && ( +

    + {note} +

    + )} + {chosen && myTurn && chosen.legal && ( +
    + {chosen.joker ? ( + send(chosen, { jokerValue })} /> + ) : ( +
    + {chosen.choices.map((x) => ( + + ))} +
    + )} +
    + )} + {myTurn && !chosen &&

    카드를 누르면 낼 때 합계가 보여요. 한 번 더 누르면 내요.

    } +
    + ); +} + +// ---------------------------------------------------------------- log + +function logText(e: LogEntry, players: Record): string { + const n = (id: string) => nick(players, id); + switch (e.t) { + case 'play': { + const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`; + return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`; + } + case 'noCard': + return `${n(e.p)}님은 낼 수 있는 카드가 없어요`; + case 'life': + return `${n(e.p)}님이 목숨 1개를 잃었어요(남은 목숨 ${e.left}개)`; + case 'out': + return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`; + case 'round': + return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`; + case 'reshuffle': + return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`; + case 'timeout': + return `${n(e.p)}님 시간 초과 · 가장 작은 수가 되는 카드를 자동으로 냈어요`; + } +} + +// ---------------------------------------------------------------- board + +function Board({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [announce, setAnnounce] = useState(''); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + const last = v.log[v.log.length - 1]; + useEffect(() => { + if (mine && !prevMine.current) { + haptic(60); + sounds.myTurn(); + setAnnounce(`내 차례예요. 지금 합계 ${v.total}`); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (last && !mine) setAnnounce(logText(last, players)); + }, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps + + 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 meSeat = v.seats.find((s) => s.id === me); + return ( +
    + +
      + {others.map((s) => ( + + ))} +
    + + {v.my && meSeat?.alive && v.phase === 'playing' && } + {meSeat && !meSeat.alive && v.phase === 'playing' && ( +
    + {meSeat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'} +
    + )} +

    + {announce} +

    + + ); +} + +// ---------------------------------------------------------------- side + +function rulesLine(o: NNOptions): string { + const lives = resolvedLives(o); + const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락']; + if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로'); + parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0'); + if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`); + else parts.push(`A는 1 또는 ${o.aceHigh}`); + return parts.join(' · '); +} + +function Side({ view: v, me, players, deadline }: BoardProps) { + return ( + <> + +
    +
    + + 구십구 · {MODE_KO[v.mode]} + {v.livesStart > 0 ? ` · ${v.round}판` : ''} + {v.phase === 'finished' ? ' · 게임 끝' : ''} + + {v.current && } +
    + {rulesLine(v.options)} +
      + {v.seats.map((s) => ( +
    1. + + + + + {s.isTurn ? '▶ ' : ''} + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {!s.alive ? (s.left ? ' · 나감' : ' · 탈락') : s.away ? ' · 자리 비움' : ''} + + + {s.alive && ( + + + {s.cards} + + )} +
    2. + ))} +
    +
    + {v.result && ( +
    + 최종 순위 + {v.result.ranking.map((r) => ( + + {r.rank}등 {nick(players, r.player)} + + {r.eliminatedAtRound === null ? ' · 끝까지 살아남음' : r.left ? ' · 나감' : v.livesStart > 0 ? ` · ${r.eliminatedAtRound}판에서 탈락` : ' · 탈락'} + {v.livesStart > 0 ? ` · 잃은 목숨 ${r.livesLost}개` : ''} + + + ))} + {v.result.summary} +
    + )} + {v.log.length > 0 && ( +
    + 기록 +
      + {[...v.log].reverse().map((e, i) => ( +
    1. + {logText(e, players)} +
    2. + ))} +
    +
    + )} +
    + 특수 카드 + +
    + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: NinetyNineView, me: string | null): boolean { + return !!me && v.phase === 'playing' && v.current === me; +} + +function statusText(v: NinetyNineView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (isMyTurn(v, me)) return `내 차례예요 · 지금 합계 ${v.total}`; + const meSeat = v.seats.find((s) => s.id === me); + const who = v.current ? `${nick(players, v.current)}님 차례예요 · 합계 ${v.total}` : '잠시만 기다려 주세요'; + return meSeat && !meSeat.alive && !meSeat.left ? `탈락했어요 · ${who}` : who; +} + +// ---------------------------------------------------------------- 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 Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const kr = o.mode === 'kr-product'; + const lives = resolvedLives(o); + return ( +
    + + set({ mode })} /> + + + set({ handSize })} /> + + 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}> + set({ lives: v })} + /> + + + set({ turnSeconds })} /> + + {kr && ( + + )} + + {more && ( + <> + + set({ refill })} /> + + + set({ onTheBoard })} /> + + + set({ afterElimination })} /> + + + set({ floorAtZero })} /> + + {kr ? ( + <> + + set({ specialCounts })} /> + + + set({ jokerRange })} /> + + + ) : ( + <> + + set({ aceHigh })} /> + + + set({ deckCount })} /> + + + )} + + )} +
    + ); +} + +function optionsSummary(o: NNOptions): string { + const lives = resolvedLives(o); + const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음']; + if (!o.refill) parts.push('보충 없음'); + if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99'); + parts.push(`${o.turnSeconds}초`); + return parts.join(' · '); +} + +// ---------------------------------------------------------------- rules + +function SpecialTable({ o }: { o: NNOptions }) { + const kr = o.mode === 'kr-product'; + const C = (label: string, kind: CardKind, suit?: CardFace['suit']) => ; + const floor = o.floorAtZero === 'forbid' ? '0보다 작아지게는 못 빼요.' : '0보다 작아지면 0이 돼요.'; + const rows: [ReactNode, string, string][] = kr + ? [ + [C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'], + [C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`], + [C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`], + [C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'], + [C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`], + ] + : [ + [C('A', 'ace', 'S'), 'A', `1 또는 ${o.aceHigh}을 더해요(고를 수 있어요).`], + [C('4', 'reverse', 'H'), '4', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'], + [C('9', 'hold', 'C'), '9', '합계는 그대로 넘겨요.'], + [C('10', 'minus', 'D'), '10', `10을 빼요. ${floor}`], + [C('J', 'add', 'S'), 'J·Q', '10을 더해요.'], + [C('K', 'to99', 'H'), 'K', '합계를 바로 99로 만들어요.'], + ]; + return ( +
    + + {rows.map(([pic, name, desc]) => ( + + + + + + ))} + +
    {pic} + {name} + {desc}
    + ); +} + +function Rules({ options }: { options?: NNOptions }) { + const o: NNOptions = { + mode: 'kr-product', + handSize: 3, + refill: true, + lives: 'auto', + onTheBoard: false, + afterElimination: 'continue', + floorAtZero: 'forbid', + specialCounts: 4, + jokerRange: '60-99', + aceHigh: 11, + deckCount: 'auto', + turnSeconds: 15, + ...options, + }; + const lives = resolvedLives(o); + return ( +
    +

    + 99를 넘기면 안 돼요! 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요. +

    +

    + 처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음). +

    +

    + 낼 카드가 없으면:{' '} + {lives > 0 + ? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.` + : o.afterElimination === 'reset' + ? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.' + : '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '} + 마지막까지 남은 1명이 이겨요. +

    + +

    + 시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요. +

    +
    + ); +} + +const ui: GameUI = { + Board, + Side, + 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 e5c2088..6103b01 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -24,6 +24,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { quoridor: () => import('./quoridor'), 'las-vegas': () => import('./las-vegas'), 'cockroach-poker': () => import('./cockroach-poker'), + 'ninety-nine': () => import('./ninety-nine'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index a6ccd5d..bb3ffd6 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -21,6 +21,7 @@ export const GAME_ICON: Record = { quoridor: '🧱', 'las-vegas': '⚅', 'cockroach-poker': '🐞', + 'ninety-nine': '💯', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index df897a5..80b7884 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -577,6 +577,70 @@ async function cockroachStep(p: Page, stats: Record): Promise(); +/** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */ +async function ninetyNineStep(p: Page, stats: Record): Promise { + const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); + if (!status.includes('내 차례예요')) return false; + const total = Number(/합계 (\d+)/.exec(status)?.[1] ?? 0); + const phone = tag(p) === 'phone'; + const press = async (l: ReturnType) => (phone ? l.tap() : l.click()); + const cards = p.locator('button.nn-card[data-legal="true"]'); + const n = await cards.count(); + if (!n) return false; + const info: { i: number; label: string; prev: number }[] = []; + for (let i = 0; i < n; i++) { + const label = (await cards.nth(i).getAttribute('aria-label')) ?? ''; + const prev = Number(/→ (\d+)/.exec(await cards.nth(i).locator('.nn-prev').innerText())?.[1] ?? -1); + info.push({ i, label, prev }); + } + const joker = info.find((c) => c.label.startsWith('조커')); + const pm = info.find((c) => c.label.startsWith('±')); + const zero = info.find((c) => c.label.startsWith('0 카드')); + const nums = info.filter((c) => c.prev >= 0 && !c.label.startsWith('0 카드')).sort((a, b) => a.prev - b.prev); + if (joker && (total >= 40 || !nums.length)) { + await press(cards.nth(joker.i)); + await press(p.getByRole('button', { name: '99', exact: true })); + if (!nnShots.has(`joker-${tag(p)}`)) { + nnShots.add(`joker-${tag(p)}`); + await p.screenshot({ path: join(SHOTS, `ninety-nine-joker-${tag(p)}.png`) }); + } + await press(p.getByRole('button', { name: '1 작게' })); + await press(p.getByRole('button', { name: /^조커 내기 → 98$/ })); + stats.joker = (stats.joker ?? 0) + 1; + } else if (pm) { + await press(cards.nth(pm.i)); + if (!nnShots.has(`pm-${tag(p)}`)) { + nnShots.add(`pm-${tag(p)}`); + await p.screenshot({ path: join(SHOTS, `ninety-nine-plusminus-${tag(p)}.png`) }); + } + const plus = p.locator('.nn-pick button', { hasText: /^\+/ }); + const minus = p.locator('.nn-pick button', { hasText: /^−/ }); + const usePlus = (await plus.isEnabled()) && (total < 70 || !(await minus.isEnabled())); + await press(usePlus ? plus : minus); + stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1; + } else if (zero && (total >= 60 || !nums.length)) { + await press(cards.nth(zero.i)); + await press(cards.nth(zero.i)); + stats.reverse = (stats.reverse ?? 0) + 1; + } else { + // Small cards while the total is low (longer game, more cards seen), then the biggest one. + const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!; + const card = cards.nth(pick.i); + if (phone) { + await card.tap(); + await card.tap(); + } else { + await card.focus(); + await p.keyboard.press('Enter'); + await p.keyboard.press('Enter'); + } + stats.number = (stats.number ?? 0) + 1; + } + await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status, { timeout: 5000 }).catch(() => {}); + return true; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -1487,6 +1551,61 @@ const PLANS: Record = { console.log(` cockroach-poker: finished after ${actions} UI actions ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([k]) => !k.startsWith('shot'))))}`); }, }, + 'ninety-nine': { + // PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the + // game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction, + // otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated. + players: 3, + lobby: async (host) => { + await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click(); + await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); + await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page, Page]; + const stats: Record = {}; + let turns = 0; + let shot = false; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + const until = Date.now() + 240_000; + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + if (await ninetyNineStep(p, stats)) { + acted = true; + turns++; + break; + } + } + if (!shot && turns >= 5) { + shot = true; + await host.waitForTimeout(500); + await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(120); + } + if (!(await over())) throw new Error(`game did not finish (${turns} turns: ${JSON.stringify(stats)})`); + const outs = await host.locator('.nn-log li', { hasText: '탈락했어요' }).count(); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); + await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') }); + const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0); + if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`); + console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index bb25dae..df1d95c 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -21,6 +21,7 @@ import { splendor } from './splendor'; import { quoridor } from './quoridor'; import { lasVegas } from './las-vegas'; import { cockroachPoker } from './cockroach-poker'; +import { ninetyNine } from './ninety-nine'; export const GAMES: Record = { [omok.id]: omok, @@ -45,4 +46,5 @@ export const GAMES: Record = { [quoridor.id]: quoridor, [lasVegas.id]: lasVegas, [cockroachPoker.id]: cockroachPoker, + [ninetyNine.id]: ninetyNine, }; diff --git a/packages/games/src/ninety-nine/cards.ts b/packages/games/src/ninety-nine/cards.ts new file mode 100644 index 0000000..135d8b5 --- /dev/null +++ b/packages/games/src/ninety-nine/cards.ts @@ -0,0 +1,139 @@ +/** 구십구 cards and what each one does to the total (docs/games/ninety-nine.md §3, §5.2). */ +import type { CardFace, ChoiceView, NNCard, NNOptions, PlayAction, Suit } from './types'; + +export const SUITS: Suit[] = ['S', 'H', 'D', 'C']; + +/** Dedicated deck: 1–8 ×7, 10 ×21, ±9 / ±10 / 0 × `specialCounts`, joker ×2 (91 cards by default). */ +export function krDeck(specialCounts: number): NNCard[] { + const out: NNCard[] = []; + let n = 0; + const id = () => `k${n++}`; + for (let v = 1; v <= 8; v++) for (let i = 0; i < 7; i++) out.push({ id: id(), kind: 'add', v, face: { label: String(v) } }); + for (let i = 0; i < 21; i++) out.push({ id: id(), kind: 'add', v: 10, face: { label: '10' } }); + for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 9, face: { label: '±9' } }); + for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 10, face: { label: '±10' } }); + for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'reverse', face: { label: '0' } }); + for (let i = 0; i < 2; i++) out.push({ id: id(), kind: 'joker', face: { label: '조커' } }); + return out; +} + +const RANK_LABEL: Record = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' }; + +/** Classic card from rank 1..13 (A=1, J=11, Q=12, K=13), Bicycle effects. */ +function classicCard(id: string, suit: Suit, rank: number): NNCard { + const face: CardFace = { label: RANK_LABEL[rank] ?? String(rank), suit }; + if (rank === 1) return { id, kind: 'ace', face }; + if (rank === 4) return { id, kind: 'reverse', face }; + if (rank === 9) return { id, kind: 'hold', face }; + if (rank === 10) return { id, kind: 'minus', v: 10, face }; + if (rank === 13) return { id, kind: 'to99', face }; + return { id, kind: 'add', v: rank >= 11 ? 10 : rank, face }; +} + +/** 52 trump cards per deck (no jokers); the second deck adds a "b" suffix. */ +export function classicDeck(decks: 1 | 2): NNCard[] { + const out: NNCard[] = []; + for (let d = 0; d < decks; d++) { + for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push(classicCard(`t${suit}${rank}${d ? 'b' : ''}`, suit, rank)); + } + return out; +} + +export const jokerRange = (o: Pick): { min: number; max: number } => (o.jokerRange === '0-99' ? { min: 0, max: 99 } : { min: 60, max: 99 }); + +export const OVER = '99를 넘어서 낼 수 없어요'; +export const UNDER = '0보다 작아져서 뺄 수 없어요'; + +function choice(label: string, total: number, o: NNOptions, extra: Partial = {}, minus = false): ChoiceView { + let t = total; + if (minus && t < 0 && o.floorAtZero === 'clamp') t = 0; + if (t > 99) return { label, total: t, legal: false, reason: OVER, ...extra }; + if (t < 0) return { label, total: t, legal: false, reason: UNDER, ...extra }; + return { label, total: t, legal: true, ...extra }; +} + +/** Every way to play `c` on total `s` (empty for the joker, which picks a number). */ +export function choicesFor(c: NNCard, s: number, o: NNOptions): ChoiceView[] { + switch (c.kind) { + case 'add': + return [choice(`+${c.v}`, s + c.v, o)]; + case 'plusMinus': + return [choice(`+${c.v}`, s + c.v, o, { sign: '+' }), choice(`−${c.v}`, s - c.v, o, { sign: '-' }, true)]; + case 'minus': + return [choice(`−${c.v}`, s - c.v, o, {}, true)]; + case 'reverse': + return [choice('방향 바꾸기', s, o)]; + case 'hold': + return [choice('그대로', s, o)]; + case 'to99': + return [choice('99로', 99, o)]; + case 'ace': + return [choice('+1', s + 1, o, { aceValue: 1 }), choice(`+${o.aceHigh}`, s + o.aceHigh, o, { aceValue: o.aceHigh })]; + case 'joker': + return []; + } +} + +/** Some way to play `c` keeps the total within 0..99. */ +export function playable(c: NNCard, s: number, o: NNOptions): boolean { + return c.kind === 'joker' || choicesFor(c, s, o).some((x) => x.legal); +} + +/** The total a play makes, or the Korean reason it is not allowed (§8.2). */ +export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction): { ok: true; total: number; label: string } | { ok: false; reason: string } { + const extra = (k: 'sign' | 'aceValue' | 'jokerValue') => a[k] !== undefined; + if (c.kind === 'joker') { + if (extra('sign') || extra('aceValue')) return { ok: false, reason: '조커는 숫자만 골라 주세요' }; + const r = jokerRange(o); + if (a.jokerValue === undefined) return { ok: false, reason: `조커로 만들 수(${r.min}~${r.max})를 골라 주세요` }; + if (!Number.isInteger(a.jokerValue) || a.jokerValue < r.min || a.jokerValue > r.max) return { ok: false, reason: `조커는 ${r.min}부터 ${r.max} 사이 숫자만 고를 수 있어요` }; + return { ok: true, total: a.jokerValue, label: String(a.jokerValue) }; + } + if (extra('jokerValue')) return { ok: false, reason: '숫자는 조커를 낼 때만 고를 수 있어요' }; + const options = choicesFor(c, s, o); + let pick: ChoiceView | undefined; + if (c.kind === 'plusMinus') { + if (extra('aceValue')) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' }; + if (!a.sign) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' }; + pick = options.find((x) => x.sign === a.sign); + } else if (c.kind === 'ace') { + if (extra('sign')) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` }; + if (a.aceValue === undefined) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` }; + pick = options.find((x) => x.aceValue === a.aceValue); + if (!pick) return { ok: false, reason: `A는 1이나 ${o.aceHigh}로만 낼 수 있어요` }; + } else { + if (extra('sign') || extra('aceValue')) return { ok: false, reason: '이 카드는 고를 것 없이 바로 내요' }; + pick = options[0]; + } + if (!pick) return { ok: false, reason: '낼 수 없는 카드예요' }; + if (!pick.legal) return { ok: false, reason: pick.reason ?? OVER }; + return { ok: true, total: pick.total, label: pick.label }; +} + +/** §8.5 tie-break group: no change → plain numbers → ± cards → K / joker. */ +const GROUP: Record = { reverse: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 }; + +/** + * Timer move (§8.5): the legal (card, choice) with the smallest resulting total; ties by the group order above, + * then card id. Uses only the player's own hand and the public total. + */ +export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction | null { + let best: { a: PlayAction; total: number; g: number; id: string } | null = null; + for (const c of hand) { + const cands: { a: PlayAction; total: number }[] = []; + if (c.kind === 'joker') { + const r = jokerRange(o); + cands.push({ a: { type: 'play', cardId: c.id, jokerValue: r.min }, total: r.min }); + } else { + for (const x of choicesFor(c, s, o)) { + if (!x.legal) continue; + cands.push({ a: { type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) }, total: x.total }); + } + } + for (const k of cands) { + const g = GROUP[c.kind]; + if (!best || k.total < best.total || (k.total === best.total && (g < best.g || (g === best.g && c.id < best.id)))) best = { a: k.a, total: k.total, g, id: c.id }; + } + } + return best?.a ?? null; +} diff --git a/packages/games/src/ninety-nine/index.ts b/packages/games/src/ninety-nine/index.ts new file mode 100644 index 0000000..e11b868 --- /dev/null +++ b/packages/games/src/ninety-nine/index.ts @@ -0,0 +1,459 @@ +/** 구십구 (docs/games/ninety-nine.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 { choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards'; +import { + ninetyNineActionSchema, + ninetyNineOptionsSchema, + type HandCardView, + type LogEntry, + type NNAction, + type NNCard, + type NNOptions, + type NinetyNineState, + type NinetyNineView, + type PlayAction, + type PlayerResult, +} from './types'; + +export * from './cards'; +export * from './types'; + +/** Deadline once a player has timed out this many turns in a row (§8.5). */ +const AWAY_AFTER = 3; +const AWAY_MS = 3000; +const LOG_MAX = 40; +const LOG_VIEW = 20; +const RECENT_VIEW = 6; + +interface Ctx { + rng: Rng; + now: number; + events: GameEvent[]; +} + +// ---------------------------------------------------------------- helpers + +/** Copy for `apply`: containers are copied, card objects / log entries / options are shared (never mutated). */ +function cloneState(s: NinetyNineState): NinetyNineState { + 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], + alive: rec(s.alive), + lives: rec(s.lives), + livesLost: rec(s.livesLost), + onBoard: rec(s.onBoard), + eliminationOrder: [...s.eliminationOrder], + eliminatedAtRound: rec(s.eliminatedAtRound), + leftOrder: [...s.leftOrder], + timeoutsInRow: rec(s.timeoutsInRow), + away: rec(s.away), + log: [...s.log], + }; +} + +export const resolvedLives = (o: Pick): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives); +export const resolvedDecks = (o: Pick, players: number): 1 | 2 => + o.mode !== 'classic' ? 1 : o.deckCount === 'auto' ? (players >= 7 ? 2 : 1) : o.deckCount; +export const fullDeck = (s: Pick): NNCard[] => + s.mode === 'classic' ? classicDeck(s.decks) : krDeck(s.options.specialCounts); +/** Every card is in exactly one place (test invariant). */ +export const cardTotal = (s: NinetyNineState): number => + s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0); + +const aliveSeats = (s: NinetyNineState): PlayerId[] => s.seats.filter((p) => s.alive[p]); + +function log(s: NinetyNineState, 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); +} + +/** Next player still in the game from `from` (who may be out) in direction `dir`. */ +function nextAlive(s: NinetyNineState, from: PlayerId, dir: 1 | -1): PlayerId { + const n = s.seats.length; + let idx = s.seats.indexOf(from); + for (let k = 0; k < n; k++) { + idx = (idx + dir + n) % n; + if (s.alive[s.seats[idx]!]) return s.seats[idx]!; + } + return from; +} + +const hasPlay = (s: NinetyNineState, p: PlayerId): boolean => (s.hands[p] ?? []).some((c) => playable(c, s.total, s.options)); + +/** Collects every card, shuffles, deals `handSize` to everyone still in from `starter` clockwise; total 0 (§5.3). */ +function dealRound(s: NinetyNineState, starter: PlayerId, ctx: Ctx): void { + const pool = [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p] ?? [])]; + s.drawPile = ctx.rng.shuffle(pool); + s.discard = []; + s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as NNCard[]])); + s.total = 0; + s.direction = 1; + const order: PlayerId[] = []; + for (let k = 0, p = starter; k < aliveSeats(s).length; k++, p = nextAlive(s, p, 1)) order.push(p); + for (let r = 0; r < s.options.handSize; r++) for (const p of order) { + const c = s.drawPile.shift(); + if (c) s.hands[p]!.push(c); + } + s.round += 1; + ctx.events.push({ type: 'roundStarted', round: s.round, starter }); + log(s, { t: 'round', round: s.round, starter }); +} + +function eliminate(s: NinetyNineState, p: PlayerId, why: 'noCard' | 'left', ctx: Ctx): void { + s.alive[p] = false; + s.eliminationOrder.push(p); + s.eliminatedAtRound[p] = s.round; + // The hand is not revealed (§8.3): only the count moves to the discard pile. + s.discard.push(...s.hands[p]!); + s.hands[p] = []; + ctx.events.push({ type: 'eliminated', player: p, why }); + log(s, { t: 'out', p, why }); +} + +function finish(s: NinetyNineState, ctx: Ctx): void { + const winners = aliveSeats(s); + const order = [...winners, ...[...s.eliminationOrder].reverse()]; + const ranking: PlayerResult[] = order.map((p, i) => ({ + player: p, + rank: i + 1, + livesLost: s.livesLost[p] ?? 0, + eliminatedAtRound: s.eliminatedAtRound[p] ?? null, + left: s.leftOrder.includes(p), + })); + const allLeft = s.eliminationOrder.length > 0 && s.eliminationOrder.every((p) => s.leftOrder.includes(p)); + const summary = allLeft + ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' + : `끝까지 살아남은 사람이 이겼어요! 마지막 합계 ${s.total}${s.livesStart > 0 ? ` · ${s.round}판` : ''}`; + s.final = { ranking, winner: winners, lastTotal: s.total, summary }; + s.phase = 'finished'; + ctx.events.push({ type: 'gameEnd', winner: winners }); +} + +/** + * Player `p` could not play (§5.3). Returns who must be checked next, or null when the game is over. + */ +function lose(s: NinetyNineState, p: PlayerId, ctx: Ctx): PlayerId | null { + ctx.events.push({ type: 'noCard', player: p, total: s.total }); + log(s, { t: 'noCard', p }); + if (s.livesStart > 0) { + let out = false; + if (s.lives[p]! > 0) { + s.lives[p]! -= 1; + s.livesLost[p]! += 1; + ctx.events.push({ type: 'lifeLost', player: p, left: s.lives[p] }); + log(s, { t: 'life', p, left: s.lives[p]! }); + if (s.lives[p] === 0) { + if (s.options.onTheBoard) s.onBoard[p] = true; + else out = true; + } + } else out = true; // on the board with no lives left: one more loss is out + const next = nextAlive(s, p, s.direction); + if (out) eliminate(s, p, 'noCard', ctx); + if (aliveSeats(s).length <= 1) { + finish(s, ctx); + return null; + } + const starter = out ? next : p; + dealRound(s, starter, ctx); + return starter; + } + const next = nextAlive(s, p, s.direction); + eliminate(s, p, 'noCard', ctx); + if (aliveSeats(s).length <= 1) { + finish(s, ctx); + return null; + } + if (s.options.afterElimination === 'reset') dealRound(s, next, ctx); + return next; +} + +/** Hands the turn to `p`; anyone with nothing playable loses at once, possibly several in a row (§8.2). */ +function startTurn(s: NinetyNineState, p: PlayerId | null, ctx: Ctx): void { + let who = p; + // Each loss costs a life or a seat, so this always ends. + while (who !== null && s.phase === 'playing') { + if (aliveSeats(s).length <= 1) { + finish(s, ctx); + return; + } + if (hasPlay(s, who)) { + s.current = who; + s.turnNo += 1; + return; + } + who = lose(s, who, ctx); + } +} + +function reshuffle(s: NinetyNineState, ctx: Ctx): void { + if (s.discard.length === 0) return; + const n = s.discard.length; + s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(s.discard)]; + s.discard = []; + ctx.events.push({ type: 'reshuffled', count: n }); + log(s, { t: 'reshuffle', n }); +} + +function playCard(s: NinetyNineState, p: PlayerId, a: PlayAction, ctx: Ctx, auto: boolean): void { + const hand = s.hands[p]!; + const card = hand.find((c) => c.id === a.cardId)!; + const r = resolvePlay(card, s.total, s.options, a); + if (!r.ok) throw new Error(`illegal play reached apply: ${r.reason}`); + const from = s.total; + s.hands[p] = hand.filter((c) => c.id !== card.id); + s.discard.push(card); + s.total = r.total; + const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' ? r.label : undefined; + ctx.events.push({ type: 'cardPlayed', player: p, face: card.face, kind: card.kind, total: s.total, ...(choice ? { choice } : {}) }); + log(s, { t: 'play', p, face: card.face, kind: card.kind, from, total: s.total, ...(choice ? { choice } : {}), ...(auto ? { auto: true } : {}) }); + if (card.kind === 'reverse') { + s.direction = s.direction === 1 ? -1 : 1; + ctx.events.push({ type: 'directionChanged', direction: s.direction }); + } + if (s.options.refill) { + if (s.drawPile.length === 0) reshuffle(s, ctx); + const c = s.drawPile.shift(); + if (c) { + s.hands[p]!.push(c); + ctx.events.push({ type: 'cardDrawn', player: p }); + } + } + startTurn(s, nextAlive(s, p, s.direction), ctx); +} + +function leave(s: NinetyNineState, p: PlayerId, ctx: Ctx): void { + s.leftOrder.push(p); + s.timeoutsInRow[p] = 0; + const wasCurrent = s.current === p; + const next = nextAlive(s, p, s.direction); + eliminate(s, p, 'left', ctx); + if (aliveSeats(s).length <= 1) { + finish(s, ctx); + return; + } + // Leaving is not losing a round: the total stays and the next player goes on. + if (wasCurrent) startTurn(s, next, ctx); +} + +function validatePlay(s: NinetyNineState, actor: PlayerId, a: PlayAction): Validation { + if (s.current !== actor) return fail('지금은 내 차례가 아니에요'); + const card = s.hands[actor]!.find((c) => c.id === a.cardId); + if (!card) return fail('손에 없는 카드예요'); + const r = resolvePlay(card, s.total, s.options, a); + return r.ok ? ok : fail(r.reason); +} + +function deadlineMs(s: NinetyNineState): number | null { + if (s.phase !== 'playing') return null; + return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000; +} + +function handView(s: NinetyNineState, p: PlayerId, myTurn: boolean): HandCardView[] { + const o = s.options; + return (s.hands[p] ?? []).map((c) => { + const choices = choicesFor(c, s.total, o); + const legal = playable(c, s.total, o); + let badge: string | null = null; + if (!legal) badge = choices.some((x) => x.total > 99) ? '99 초과' : '0 미만'; + return { id: c.id, kind: c.kind, face: c.face, choices, joker: c.kind === 'joker' ? jokerRange(o) : null, legal: legal && myTurn, badge }; + }); +} + +// ---------------------------------------------------------------- definition + +type Def = GameDefinition; + +export const ninetyNine: Def = { + id: 'ninety-nine', + nameKo: '구십구', + minPlayers: 2, + maxPlayers: 10, + stateVersion: 1, + defaultOptions: ninetyNineOptionsSchema.parse({}), + optionsSchema: ninetyNineOptionsSchema as unknown as Def['optionsSchema'], + actionSchema: ninetyNineActionSchema as unknown as Def['actionSchema'], + + setup({ players, options, rng, now }) { + if (players.length < 2 || players.length > 10) throw new Error(`ninety-nine: ${players.length} players not allowed`); + const livesStart = resolvedLives(options); + const rec = (v: T) => Object.fromEntries(players.map((p) => [p, v])) as Record; + const s: NinetyNineState = { + mode: options.mode, + options, + livesStart, + decks: resolvedDecks(options, players.length), + seats: [...players], + hands: {}, + drawPile: [], + discard: [], + total: 0, + direction: 1, + current: players[0]!, + alive: rec(true), + lives: rec(livesStart), + livesLost: rec(0), + onBoard: rec(false), + eliminationOrder: [], + eliminatedAtRound: rec(null), + leftOrder: [], + round: 1, + phase: 'playing', + turnStartedAt: now, + seq: 0, + turnNo: 0, + timeoutsInRow: rec(0), + away: rec(false), + log: [], + final: null, + }; + const ctx: Ctx = { rng, now, events: [] }; + // §4: shuffle the deck, then pick the first player with the same RNG. + const deck = rng.shuffle(fullDeck(s)); + const starter = players[rng.int(players.length)]!; + s.drawPile = deck; + const order: PlayerId[] = []; + for (let k = 0, p = starter; k < players.length; k++, p = nextAlive(s, p, 1)) order.push(p); + s.hands = Object.fromEntries(players.map((p) => [p, [] as NNCard[]])); + for (let r = 0; r < options.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!); + // Someone may already have nothing playable (e.g. only trump 10s at 0). + startTurn(s, starter, ctx); + return s; + }, + + validate(s, actor, a) { + if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요'); + if (a.type === 'leave') return s.alive[actor] ? ok : fail('이미 게임에서 빠졌어요'); + if (!s.alive[actor]) return fail('탈락해서 카드를 낼 수 없어요'); + if (a.type === 'timeout') return s.current === actor ? ok : fail('시간 초과 대상이 아니에요'); + if (a.type === 'play') return validatePlay(s, actor, a); + return fail('알 수 없는 행동이에요'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = cloneState(prev); + const ctx: Ctx = { rng, now, events: [] }; + const before = s.turnNo; + s.seq += 1; + switch (a.type) { + case 'play': + s.timeoutsInRow[actor] = 0; + s.away[actor] = false; + playCard(s, actor, a, ctx, false); + break; + case 'timeout': { + ctx.events.push({ type: 'timeout', player: actor }); + log(s, { t: 'timeout', p: actor }); + 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 }); + } + const auto = safestPlay(s.hands[actor]!, s.total, s.options); + if (!auto) throw new Error('current player has no legal play'); + playCard(s, actor, auto, ctx, true); + break; + } + case 'leave': + leave(s, actor, ctx); + break; + } + if (s.turnNo !== before) s.turnStartedAt = now; + return { state: s, events: ctx.events }; + }, + + view(s, viewer) { + const me = viewer && s.seats.includes(viewer) ? viewer : null; + const live = s.phase === 'playing'; + const myTurn = live && me !== null && s.current === me; + let from = s.log.length; + while (from > 0 && s.log[from - 1]!.t !== 'round') from--; + const plays = s.log.slice(from).filter((e): e is Extract => e.t === 'play'); + return { + mode: s.mode, + options: s.options, + livesStart: s.livesStart, + jokerRange: jokerRange(s.options), + me, + seats: s.seats.map((p) => ({ + id: p, + cards: s.hands[p]?.length ?? 0, + lives: s.lives[p] ?? 0, + livesLost: s.livesLost[p] ?? 0, + alive: !!s.alive[p], + onBoard: !!s.onBoard[p], + left: s.leftOrder.includes(p), + away: !!s.away[p], + isTurn: live && s.current === p, + })), + total: s.total, + direction: s.direction, + current: live ? s.current : null, + phase: s.phase, + turnNo: s.turnNo, + round: s.round, + drawPileCount: s.drawPile.length, + discardCount: s.discard.length, + recent: plays.slice(-RECENT_VIEW), + log: s.log.slice(-LOG_VIEW), + eliminationOrder: s.eliminationOrder, + my: me ? { hand: s.alive[me] ? handView(s, me, myTurn) : [], myTurn } : null, + turnStartedAt: s.turnStartedAt, + deadlineTotalMs: deadlineMs(s) ?? 0, + result: s.final, + }; + }, + + activePlayers(s) { + return s.phase === 'playing' ? [s.current] : []; + }, + + deadline(s) { + const ms = deadlineMs(s); + return ms === null ? null : s.turnStartedAt + ms; + }, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + if (s.phase !== 'playing' || !s.seats.includes(player) || !s.alive[player]) return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (!s.final) return null; + const winners = s.final.winner; + const ranking: PlayerId[][] = [winners, ...s.final.ranking.filter((r) => !winners.includes(r.player)).map((r) => [r.player])]; + return { ranking, summary: s.final.summary, reason: 'normal' }; + }, + + /** Test helper. The joker offers a few representative numbers instead of all 40. */ + legalActions(s, p) { + if (s.phase !== 'playing' || s.current !== p) return []; + const out: NNAction[] = []; + for (const c of s.hands[p]!) { + if (c.kind === 'joker') { + const r = jokerRange(s.options); + const vals = [...new Set([r.min, r.max, Math.round((r.min + r.max) / 2), Math.min(r.max, Math.max(r.min, s.total))])]; + for (const jokerValue of vals) out.push({ type: 'play', cardId: c.id, jokerValue }); + continue; + } + for (const x of choicesFor(c, s.total, s.options)) { + if (!x.legal) continue; + out.push({ type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) }); + } + } + return out; + }, + + /** 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)]; + }, +}; diff --git a/packages/games/src/ninety-nine/ninety-nine.test.ts b/packages/games/src/ninety-nine/ninety-nine.test.ts new file mode 100644 index 0000000..2d31b97 --- /dev/null +++ b/packages/games/src/ninety-nine/ninety-nine.test.ts @@ -0,0 +1,599 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; +import { + cardTotal, + classicDeck, + fullDeck, + krDeck, + ninetyNine, + safestPlay, + type NNAction, + type NNCard, + type NNOptions, + type NinetyNineState, + type PlayAction, +} from './index'; + +// ---------------------------------------------------------------- helpers + +const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); +let clock = 1_000_000; + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], seed = 1): NinetyNineState { + const options = ninetyNine.optionsSchema.parse(opts); + return ninetyNine.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); +} + +/** + * Replaces hands (by face label, e.g. "7", "±9", "0", "조커", "K"), total and turn. Unused cards become the + * draw pile in deck order (or the discard pile with `emptyDraw`). + */ +function rig( + s: NinetyNineState, + r: { hands: Record; total: number; current?: string; direction?: 1 | -1; emptyDraw?: boolean }, +): NinetyNineState { + const pool = fullDeck(s); + const take = (label: string): NNCard => { + const i = pool.findIndex((c) => c.face.label === label); + if (i < 0) throw new Error(`no card ${label} left`); + return pool.splice(i, 1)[0]!; + }; + for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(take); + s.total = r.total; + s.current = r.current ?? s.seats[0]!; + s.direction = r.direction ?? 1; + s.drawPile = r.emptyDraw ? [] : pool; + s.discard = r.emptyDraw ? pool : []; + s.phase = 'playing'; + s.final = null; + return s; +} + +function actE(s: NinetyNineState, p: string, a: NNAction): { state: NinetyNineState; events: GameEvent[] } { + const v = ninetyNine.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return ninetyNine.apply(s, p, a, { rng: rng(), now: (clock += 1000) }); +} +const act = (s: NinetyNineState, p: string, a: NNAction) => actE(s, p, a).state; +const idOf = (s: NinetyNineState, p: string, label: string): string => { + const c = s.hands[p]!.find((x) => x.face.label === label); + if (!c) throw new Error(`${p} has no ${label}`); + return c.id; +}; +const play = (s: NinetyNineState, p: string, label: string, extra: Partial = {}): PlayAction => ({ type: 'play', cardId: idOf(s, p, label), ...extra }); +const reject = (s: NinetyNineState, p: string, a: NNAction): string | null => { + const v = ninetyNine.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const labelsOf = (s: NinetyNineState, p: string) => s.hands[p]!.map((c) => c.face.label); +const legalLabels = (s: NinetyNineState, p: string) => + [...new Set(ninetyNine.legalActions!(s, p).map((a) => (a.type === 'play' ? s.hands[p]!.find((c) => c.id === a.cardId)!.face.label : a.type)))].sort(); +const types = (events: GameEvent[]) => events.map((e) => e.type); + +// ---------------------------------------------------------------- deck and setup + +describe('덱 구성과 셋업 (§3, §4)', () => { + test('kr-product default: 91 cards (1–8 ×7, 10 ×21, ±9/±10/0 ×4, joker ×2); specialCounts=2 → 85', () => { + const d = krDeck(4); + expect(d).toHaveLength(91); + const count = (f: (c: NNCard) => boolean) => d.filter(f).length; + for (let v = 1; v <= 8; v++) expect(count((c) => c.kind === 'add' && c.v === v)).toBe(7); + expect(count((c) => c.kind === 'add' && c.v === 10)).toBe(21); + expect(count((c) => c.kind === 'add' && c.v === 9)).toBe(0); + expect(count((c) => c.kind === 'plusMinus' && c.v === 9)).toBe(4); + expect(count((c) => c.kind === 'plusMinus' && c.v === 10)).toBe(4); + expect(count((c) => c.kind === 'reverse')).toBe(4); + expect(count((c) => c.kind === 'joker')).toBe(2); + expect(krDeck(2)).toHaveLength(85); + expect(new Set(d.map((c) => c.id)).size).toBe(91); + expect(cardTotal(make({ specialCounts: 2 }))).toBe(85); + }); + + test('classic: 52 cards (A ace, 4 reverse, 9 hold, 10 minus, K to99); 7+ players with auto → 104', () => { + const d = classicDeck(1); + expect(d).toHaveLength(52); + expect(d.filter((c) => c.kind === 'to99')).toHaveLength(4); + expect(d.filter((c) => c.kind === 'ace')).toHaveLength(4); + expect(d.filter((c) => c.kind === 'add' && c.v === 10)).toHaveLength(8); // J, Q + expect(cardTotal(make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F']))).toBe(52); + const seven = make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']); + expect(cardTotal(seven)).toBe(104); + expect(new Set(fullDeck(seven).map((c) => c.id)).size).toBe(104); + expect(cardTotal(make({ mode: 'classic', deckCount: 2 }))).toBe(104); + expect(cardTotal(make({ mode: 'classic', deckCount: 1 }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']))).toBe(52); + }); + + test('same seed → same deal and first player; handSize cards each; total 0; lives per mode', () => { + const a = make({}, ['A', 'B', 'C', 'D'], 5); + const b = make({}, ['A', 'B', 'C', 'D'], 5); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + expect(JSON.stringify(make({}, ['A', 'B', 'C', 'D'], 6).hands)).not.toBe(JSON.stringify(a.hands)); + const starters = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).current)); + expect(starters.size).toBeGreaterThan(1); + for (const p of a.seats) expect(a.hands[p]).toHaveLength(3); + expect(a.total).toBe(0); + expect(a.livesStart).toBe(0); + expect(make({ mode: 'classic' }).livesStart).toBe(3); + expect(make({ mode: 'classic', lives: 0 }).livesStart).toBe(0); + expect(make({ handSize: 5 }).hands.A).toHaveLength(5); + }); +}); + +// ---------------------------------------------------------------- legality + +describe('낼 수 있는 카드 (§5.2, §10)', () => { + test('95 with [8, 5, 4]: only 4; it makes 99; the next player with [1, 2, 3] is out at once', () => { + let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['8', '5', '4'], B: ['1', '2', '3'], C: ['0', '10', '7'] }, total: 95 }); + expect(legalLabels(s, 'A')).toEqual(['4']); + expect(reject(s, 'A', play(s, 'A', '8'))).toBe('99를 넘어서 낼 수 없어요'); + const v = ninetyNine.view(s, 'A'); + expect(v.my!.hand.map((c) => [c.face.label, c.legal, c.badge, c.choices[0]!.total])).toEqual([ + ['8', false, '99 초과', 103], + ['5', false, '99 초과', 100], + ['4', true, null, 99], + ]); + const out = actE(s, 'A', play(s, 'A', '4')); + s = out.state; + expect(s.total).toBe(99); + expect(s.alive.B).toBe(false); + expect(s.eliminationOrder).toEqual(['B']); + expect(s.current).toBe('C'); + expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated']); + expect(s.hands.B).toEqual([]); + expect(cardTotal(s)).toBe(91); + }); + + test('±10 at 5: minus is refused with forbid, makes 0 with clamp; plus makes 15; the sign is required', () => { + const base = (floorAtZero: NNOptions['floorAtZero']) => rig(make({ floorAtZero }), { hands: { A: ['±10', '1'], B: ['2'], C: ['3'] }, total: 5 }); + let s = base('forbid'); + expect(reject(s, 'A', play(s, 'A', '±10', { sign: '-' }))).toBe('0보다 작아져서 뺄 수 없어요'); + expect(reject(s, 'A', play(s, 'A', '±10'))).toBe('더하기와 빼기 중 하나를 골라 주세요'); + expect(act(s, 'A', play(s, 'A', '±10', { sign: '+' })).total).toBe(15); + expect(ninetyNine.view(s, 'A').my!.hand[0]!.choices.map((c) => [c.label, c.total, c.legal])).toEqual([ + ['+10', 15, true], + ['−10', -5, false], + ]); + s = base('clamp'); + expect(act(s, 'A', play(s, 'A', '±10', { sign: '-' })).total).toBe(0); + // ±9 at 95: plus is over, minus is fine → still playable. + s = rig(make(), { hands: { A: ['±9'], B: ['1'], C: ['1'] }, total: 95 }); + expect(reject(s, 'A', play(s, 'A', '±9', { sign: '+' }))).toBe('99를 넘어서 낼 수 없어요'); + expect(act(s, 'A', play(s, 'A', '±9', { sign: '-' })).total).toBe(86); + }); + + test('joker at 20: 99 is fine; 59 and 100 are refused; 0~99 range allows small numbers', () => { + const s = rig(make(), { hands: { A: ['조커', '1'], B: ['2'], C: ['3'] }, total: 20 }); + expect(act(s, 'A', play(s, 'A', '조커', { jokerValue: 99 })).total).toBe(99); + const high = rig(make(), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 70 }); + expect(act(high, 'A', play(high, 'A', '조커', { jokerValue: 70 })).total).toBe(70); // the same number is fine + expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 59 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요'); + expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 100 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요'); + expect(ninetyNine.actionSchema.safeParse(play(s, 'A', '조커', { jokerValue: 100 })).success).toBe(false); + expect(reject(s, 'A', play(s, 'A', '조커'))).toContain('골라 주세요'); + expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 70.5 }))).toContain('60부터 99'); + const kid = rig(make({ jokerRange: '0-99' }), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 80 }); + expect(act(kid, 'A', play(kid, 'A', '조커', { jokerValue: 0 })).total).toBe(0); + expect(ninetyNine.view(kid, 'A').my!.hand[0]!.joker).toEqual({ min: 0, max: 99 }); + }); + + test('0 card: with 3 players B’s 0 turns the direction and A plays next; with 2 players it stays the opponent', () => { + let s = rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' }); + const out = actE(s, 'B', play(s, 'B', '0')); + s = out.state; + expect(s.total).toBe(30); + expect(s.direction).toBe(-1); + expect(s.current).toBe('A'); + expect(types(out.events)).toContain('directionChanged'); + s = act(s, 'A', play(s, 'A', '1')); + expect(s.current).toBe('C'); + let two = rig(make({}, ['A', 'B']), { hands: { A: ['1'], B: ['0', '3'] }, total: 30, current: 'B' }); + two = act(two, 'B', play(two, 'B', '0')); + expect(two.direction).toBe(-1); + expect(two.current).toBe('A'); + }); + + test('classic: K at 90 → 99; then only 4 / 9 / 10 / K; A is refused even as 1; A as 11 at 88 makes 99', () => { + const o = { mode: 'classic' as const, lives: 0 as const }; + let s = rig(make(o), { hands: { A: ['K', '2'], B: ['A', '4', '9', '10', 'K', '2', 'J'], C: ['3'] }, total: 90 }); + s = act(s, 'A', play(s, 'A', 'K')); + expect(s.total).toBe(99); + expect(s.current).toBe('B'); + expect(legalLabels(s, 'B')).toEqual(['10', '4', '9', 'K']); + expect(reject(s, 'B', play(s, 'B', 'A', { aceValue: 1 }))).toBe('99를 넘어서 낼 수 없어요'); + expect(act(s, 'B', play(s, 'B', '10')).total).toBe(89); + expect(act(s, 'B', play(s, 'B', '9')).total).toBe(99); + expect(act(s, 'B', play(s, 'B', 'K')).total).toBe(99); + s = rig(make(o), { hands: { A: ['A', '2'], B: ['3'], C: ['3'] }, total: 88 }); + expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 11 })).total).toBe(99); + expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 1 })).total).toBe(89); + expect(reject(s, 'A', play(s, 'A', 'A'))).toBe('A를 1로 낼지 11로 낼지 골라 주세요'); + expect(reject(s, 'A', play(s, 'A', 'A', { aceValue: 10 }))).toBe('A는 1이나 11로만 낼 수 있어요'); + const ten = rig(make({ ...o, aceHigh: 10 }), { hands: { A: ['A'], B: ['3'], C: ['3'] }, total: 80 }); + expect(act(ten, 'A', play(ten, 'A', 'A', { aceValue: 10 })).total).toBe(90); + // 10 at 5: forbid refuses, clamp makes 0. + const low = rig(make(o), { hands: { A: ['10', '2'], B: ['3'], C: ['3'] }, total: 5 }); + expect(reject(low, 'A', play(low, 'A', '10'))).toBe('0보다 작아져서 뺄 수 없어요'); + const clamp = rig(make({ ...o, floorAtZero: 'clamp' }), { hands: { A: ['10'], B: ['3'], C: ['3'] }, total: 5 }); + expect(act(clamp, 'A', play(clamp, 'A', '10')).total).toBe(0); + // 9 passes without changing anything; 4 reverses. + const hold = rig(make(o), { hands: { A: ['9'], B: ['3'], C: ['3'] }, total: 50 }); + const h = act(hold, 'A', play(hold, 'A', '9')); + expect([h.total, h.direction, h.current]).toEqual([50, 1, 'B']); + }); + + test('a cleared hand (refill off) has nothing to play and loses at the start of the turn', () => { + let s = rig(make({ refill: false }), { hands: { A: ['1'], B: ['2', '3'], C: ['4'] }, total: 10 }); + s = act(s, 'A', play(s, 'A', '1')); + expect(s.hands.A).toEqual([]); + s = act(s, 'B', play(s, 'B', '2')); + s = act(s, 'C', play(s, 'C', '4')); + expect(s.alive.A).toBe(false); + expect(s.current).toBe('B'); + }); +}); + +// ---------------------------------------------------------------- losing + +describe('패배 처리 (§5.3, §10)', () => { + test('lives=3: the loser loses one life, a new round starts at 0 with the loser first; at 0 lives → out, the next one starts', () => { + let s = rig(make({ lives: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 }); + const out = actE(s, 'A', play(s, 'A', '4')); + s = out.state; + expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'lifeLost', 'roundStarted']); + expect(s.lives.B).toBe(2); + expect(s.livesLost.B).toBe(1); + expect(s.alive.B).toBe(true); + expect([s.total, s.round, s.current, s.direction]).toEqual([0, 2, 'B', 1]); + for (const p of s.seats) expect(s.hands[p]).toHaveLength(3); + expect(s.discard).toEqual([]); + expect(cardTotal(s)).toBe(91); + // B on the last life loses again → out; C (next after B) starts round 3. + s = rig(s, { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 }); + s.lives.B = 1; + s = act(s, 'A', play(s, 'A', '4')); + expect(s.alive.B).toBe(false); + expect(s.lives.B).toBe(0); + expect(s.eliminationOrder).toEqual(['B']); + expect([s.total, s.round, s.current]).toEqual([0, 3, 'C']); + expect(s.hands.B).toEqual([]); + expect(s.hands.A).toHaveLength(3); + expect(s.eliminatedAtRound.B).toBe(2); + }); + + test('onTheBoard: at 0 lives the player stays in and is out only after one more loss', () => { + let s = rig(make({ lives: 1, onTheBoard: true }), { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 }); + s = act(s, 'A', play(s, 'A', '4')); + expect([s.alive.B, s.lives.B, s.onBoard.B, s.current]).toEqual([true, 0, true, 'B']); + s = rig(s, { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 }); + s = act(s, 'A', play(s, 'A', '4')); + expect(s.alive.B).toBe(false); + expect(s.current).toBe('C'); + }); + + test('chain (lives=0, continue): at 98 the next two have nothing → both out in one apply, in order; the third plays', () => { + let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['8'], B: ['2', '3', '5'], C: ['2', '4', '6'], D: ['1', '10'] }, total: 90 }); + const out = actE(s, 'A', play(s, 'A', '8')); + s = out.state; + expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated', 'noCard', 'eliminated']); + expect(out.events.filter((e) => e.type === 'eliminated').map((e) => e.player)).toEqual(['B', 'C']); + expect(s.eliminationOrder).toEqual(['B', 'C']); + expect([s.total, s.current]).toEqual([98, 'D']); + expect(cardTotal(s)).toBe(91); + }); + + test('reset after an elimination: total back to 0, everyone redealt, the next player starts', () => { + let s = rig(make({ afterElimination: 'reset' }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 }); + s = act(s, 'A', play(s, 'A', '4')); + expect(s.alive.B).toBe(false); + expect([s.total, s.current, s.round]).toEqual([0, 'C', 2]); + expect(s.hands.A).toHaveLength(3); + expect(s.hands.C).toHaveLength(3); + expect(cardTotal(s)).toBe(91); + }); + + test('last one standing wins: ranking by elimination order, Korean summary, lives lost in the result', () => { + let s = rig(make({}, ['A', 'B']), { hands: { A: ['4'], B: ['5', '6'] }, total: 95 }); + s = act(s, 'A', play(s, 'A', '4')); + expect(s.phase).toBe('finished'); + const r = ninetyNine.result(s)!; + expect(r.ranking).toEqual([['A'], ['B']]); + expect(r.summary).toBe('끝까지 살아남은 사람이 이겼어요! 마지막 합계 99'); + expect(s.final!.ranking.map((x) => [x.player, x.rank, x.eliminatedAtRound])).toEqual([ + ['A', 1, null], + ['B', 2, 1], + ]); + expect(ninetyNine.activePlayers(s)).toEqual([]); + expect(ninetyNine.deadline!(s)).toBeNull(); + expect(reject(s, 'A', { type: 'timeout' })).toBe('게임이 이미 끝났어요'); + }); +}); + +// ---------------------------------------------------------------- piles + +describe('더미 소진 (§5.1)', () => { + test('refill with an empty draw pile: the discards are shuffled into a new pile; the total stays', () => { + let s = rig(make(), { hands: { A: ['7', '1', '2'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true }); + const n = s.discard.length; + const out = actE(s, 'A', play(s, 'A', '7')); + s = out.state; + expect(types(out.events)).toEqual(['cardPlayed', 'reshuffled', 'cardDrawn']); + expect(out.events[1]!.count).toBe(n + 1); + expect(s.total).toBe(47); + expect(s.hands.A).toHaveLength(3); + expect(s.discard).toEqual([]); + expect(s.drawPile).toHaveLength(n); + expect(cardTotal(s)).toBe(91); + // Only the card just played is left: it is shuffled back and drawn again. + let dry = rig(make(), { hands: { A: ['7'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true }); + dry.discard = []; + dry = act(dry, 'A', play(dry, 'A', '7')); + expect(labelsOf(dry, 'A')).toEqual(['7']); + expect([dry.discard.length, dry.drawPile.length, dry.total]).toEqual([0, 0, 47]); + }); +}); + +// ---------------------------------------------------------------- timer / away / leave + +describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => { + test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0; [±9, 3, 0] at 50 → −9 (41)', () => { + let s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50 }); + expect(ninetyNine.onTimeout(s, 'A')).toEqual({ type: 'timeout' }); + expect(ninetyNine.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + const out = actE(s, 'A', { type: 'timeout' }); + expect(labelsOf(out.state, 'A')).not.toContain('0'); + expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, -1, 'C']); + expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, auto: true }); + s = rig(make(), { hands: { A: ['±9', '3', '0'], B: ['1'], C: ['1'] }, total: 50 }); + expect(act(s, 'A', { type: 'timeout' }).total).toBe(41); + // Joker only → the lowest joker number; tie at the same total → the no-change card first. + s = rig(make(), { hands: { A: ['조커'], B: ['1'], C: ['1'] }, total: 30 }); + expect(act(s, 'A', { type: 'timeout' }).total).toBe(60); + s = rig(make(), { hands: { A: ['조커', '0'], B: ['1'], C: ['1'] }, total: 60 }); + const t = act(s, 'A', { type: 'timeout' }); + expect([t.total, t.direction]).toEqual([60, -1]); + const cls = rig(make({ mode: 'classic' }), { hands: { A: ['K', 'A', '9'], B: ['2'], C: ['2'] }, total: 95 }); + expect(safestPlay(cls.hands.A!, 95, cls.options)).toEqual({ type: 'play', cardId: idOf(cls, 'A', '9') }); + }); + + test('a disconnected player keeps being auto-played; after 3 in a row the limit is 3 s; the game never stalls', () => { + let s = make({ turnSeconds: 15 }, ['A', 'B', 'C'], 3); + const first = s.current; + expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 15_000); + let steps = 0; + while (!ninetyNine.result(s) && steps < 2000) { + const p = ninetyNine.activePlayers(s)[0]!; + s = act(s, p, ninetyNine.onTimeout(s, p)); + steps++; + if (steps === 9 && s.phase === 'playing' && s.timeoutsInRow[s.current] === 3) { + expect(s.away[s.current]).toBe(true); + expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 3000); + } + } + expect(ninetyNine.result(s)).not.toBeNull(); + expect(first).toBeTruthy(); + // A real play clears the away flag. + let t = rig(make(), { hands: { A: ['1', '2'], B: ['1'], C: ['1'] }, total: 10 }); + t.away.A = true; + t.timeoutsInRow.A = 4; + t = act(t, 'A', play(t, 'A', '1')); + expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 0]); + }); + + test('leaving: the current player leaving passes the turn with the same total; 2 players → the other wins', () => { + let s = rig(make({ lives: 3 }), { hands: { A: ['1'], B: ['2'], C: ['3'] }, total: 40 }); + expect(ninetyNine.onLeave!(s, 'A')).toEqual({ type: 'leave' }); + s = act(s, 'A', { type: 'leave' }); + expect([s.alive.A, s.current, s.total, s.round]).toEqual([false, 'B', 40, 1]); + expect(ninetyNine.onLeave!(s, 'A')).toBeNull(); + s = act(s, 'C', { type: 'leave' }); + expect(s.phase).toBe('finished'); + expect(ninetyNine.result(s)!.ranking).toEqual([['B'], ['C'], ['A']]); + expect(s.final!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요'); + expect(ninetyNine.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + }); +}); + +// ---------------------------------------------------------------- validation and leaks + +describe('검증과 정보 유출 (§8.3, §10)', () => { + test('not my turn, ± without a sign, joker number out of range, resent packet: refused and nothing changes', () => { + const s = rig(make(), { hands: { A: ['±9', '조커', '3'], B: ['1', '2'], C: ['4'] }, total: 30 }); + const snap = JSON.stringify(s); + expect(reject(s, 'B', play(s, 'B', '1'))).toBe('지금은 내 차례가 아니에요'); + expect(reject(s, 'A', play(s, 'A', '±9'))).toBe('더하기와 빼기 중 하나를 골라 주세요'); + expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 40 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요'); + expect(reject(s, 'A', { type: 'play', cardId: s.hands.B![0]!.id })).toBe('손에 없는 카드예요'); + expect(reject(s, 'A', play(s, 'A', '3', { sign: '+' }))).toBe('이 카드는 고를 것 없이 바로 내요'); + expect(reject(s, 'X', play(s, 'A', '3'))).toBe('이 게임의 플레이어가 아니에요'); + const a = play(s, 'A', '3'); + const next = act(s, 'A', a); + expect(reject(next, 'A', a)).toBe('지금은 내 차례가 아니에요'); + expect(JSON.stringify(s)).toBe(snap); + const dead = rig(make(), { hands: { A: ['4'], B: ['5', '6'], C: ['0', '2'] }, total: 95 }); + const after = act(dead, 'A', play(dead, 'A', '4')); + expect(reject(after, 'B', { type: 'timeout' })).toBe('탈락해서 카드를 낼 수 없어요'); + }); + + test('views: opponents and spectators see counts only; refills and eliminated hands are never revealed', () => { + let s = rig(make(), { hands: { A: ['4', '1', '2'], B: ['5', '6', '7'], C: ['0', '3', '8'] }, total: 95 }); + const bHand = s.hands.B!.map((c) => c.id); + const out = actE(s, 'A', play(s, 'A', '4')); + s = out.state; + const drawn = s.hands.A!.at(-1)!.id; + for (const viewer of ['B', 'C', null]) { + const json = JSON.stringify(ninetyNine.view(s, viewer)); + expect(json).not.toContain(`"${drawn}"`); + for (const id of bHand) expect(json).not.toContain(`"${id}"`); + for (const c of s.drawPile.slice(0, 20)) expect(json).not.toContain(`"${c.id}"`); + checkNoLeak(ninetyNine, s, viewer ?? 'C', s.seats); + } + expect(JSON.stringify(ninetyNine.view(s, 'A'))).toContain(`"${drawn}"`); + expect(JSON.stringify(out.events)).not.toContain('"id"'); + expect(out.events.find((e) => e.type === 'cardDrawn')).toEqual({ type: 'cardDrawn', player: 'A' }); + const v = ninetyNine.view(s, null); + expect(v.my).toBeNull(); + expect(v.seats.map((x) => [x.id, x.cards, x.alive])).toEqual([ + ['A', 3, true], + ['B', 0, false], + ['C', 3, true], + ]); + expect(v.recent.map((e) => [e.face.label, e.total])).toEqual([['4', 99]]); + expect(ninetyNine.view(s, 'B').my!.hand).toEqual([]); + }); + + test('options: defaults, Korean metadata on every option; client actions only in the schema', () => { + const d = ninetyNine.defaultOptions; + expect(d).toMatchObject({ mode: 'kr-product', handSize: 3, refill: true, lives: 'auto', onTheBoard: false, afterElimination: 'continue', floorAtZero: 'forbid', specialCounts: 4, jokerRange: '60-99', aceHigh: 11, deckCount: 'auto', turnSeconds: 15 }); + const shape = (ninetyNine.optionsSchema as unknown as { shape: Record } | undefined }> }).shape; + for (const [k, f] of Object.entries(shape)) { + const m = f.meta(); + expect(m?.title, k).toMatch(/[가-힣]/); + expect(m?.labels, k).toBeTruthy(); + } + expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', sign: '+' }).success).toBe(true); + expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', aceValue: 5 }).success).toBe(false); + }); +}); + +// ---------------------------------------------------------------- random playouts + +interface Report { + steps: number; + finished: boolean; + trace: string; + final: NinetyNineState; +} + +const deckSizeOf = (s: NinetyNineState): number => (s.mode === 'classic' ? 52 * s.decks : 79 + 3 * s.options.specialCounts); + +function invariants(s: NinetyNineState): void { + const size = deckSizeOf(s); + if (cardTotal(s) !== size) throw new Error(`card count ${cardTotal(s)} != ${size}`); + const ids = new Set(); + for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) { + if (ids.has(c.id)) throw new Error(`duplicate card ${c.id}`); + ids.add(c.id); + } + if (s.total < 0 || s.total > 99) throw new Error(`total out of range ${s.total}`); + for (const p of s.seats) { + if (!s.alive[p] && s.hands[p]!.length) throw new Error(`eliminated ${p} holds cards`); + if (s.lives[p]! < 0) throw new Error('negative lives'); + } + if (s.phase === 'playing') { + if (!s.alive[s.current]) throw new Error('current player is out'); + if (!ninetyNine.legalActions!(s, s.current).length) throw new Error('current player has no legal move'); + } + const alive = s.seats.filter((p) => s.alive[p]).length; + if (s.phase === 'finished' ? alive !== 1 : alive < 2) throw new Error(`alive=${alive} in ${s.phase}`); +} + +function checkLeaks(s: NinetyNineState, players: PlayerId[]): void { + for (const p of players) checkNoLeak(ninetyNine, s, p, players); +} + +/** Cheap per-step version: the only card ids in a view are the viewer's own hand (no discard, pile or other hands). */ +const CARD_ID = /"(k\d+|t[SHDC]\d+b?)"/g; +function checkIds(s: NinetyNineState, players: PlayerId[]): void { + for (const p of [...players, null]) { + const own = new Set(p ? s.hands[p]!.map((c) => c.id) : []); + for (const m of JSON.stringify(ninetyNine.view(s, p)).matchAll(CARD_ID)) { + if (!own.has(m[1]!)) throw new Error(`view for ${p} shows card ${m[1]} it does not hold`); + } + } +} + +function fastPlayout(players: PlayerId[], options: NNOptions, seed: RngState, maxSteps: number): Report { + 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 = ninetyNine.setup({ players, options, rng: r, now }); + const trace: string[] = []; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + if (step % 3 === 0) checkIds(s, players); + if (step % 200 === 100) checkLeaks(s, players); + if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable'); + if (ninetyNine.result(s)) { + checkIds(s, players); + return { steps: step, finished: true, trace: trace.join(';'), final: s }; + } + const actor = ninetyNine.activePlayers(s)[0]!; + const v0 = ninetyNine.validate(s, actor, ninetyNine.onTimeout(s, actor)); + if (!v0.ok) throw new Error(`onTimeout rejected: ${v0.reason}`); + const legal = ninetyNine.legalActions!(s, actor); + // Mostly random legal plays, sometimes the timer move or someone leaving. + const roll = chooser.int(100); + let who = actor; + let action: NNAction; + if (roll < 8) action = { type: 'timeout' }; + else if (roll === 99) { + const alive = s.seats.filter((p) => s.alive[p]); + who = alive[chooser.int(alive.length)]!; + action = ninetyNine.onLeave!(s, who)!; + } else action = legal[chooser.int(legal.length)]!; + const v = ninetyNine.validate(s, who, action); + if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`); + now += 1000; + const out = ninetyNine.apply(s, who, action, { rng: r, now }); + if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id'); + s = out.state; + trace.push(`${who}:${JSON.stringify(action)}`); + } + return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s }; +} + +const PRESETS: Partial[] = [ + {}, + { lives: 3 }, + { afterElimination: 'reset', floorAtZero: 'clamp', specialCounts: 6 }, + { refill: false, handSize: 5 }, + { jokerRange: '0-99', handSize: 4, lives: 2, onTheBoard: true }, + { mode: 'classic' }, + { mode: 'classic', lives: 0, aceHigh: 10 }, + { mode: 'classic', lives: 1, onTheBoard: true, floorAtZero: 'clamp', deckCount: 2 }, + { mode: 'classic', refill: false, handSize: 5, lives: 2 }, + { mode: 'classic', deckCount: 1, handSize: 5, lives: 5 }, +]; + +describe('random playouts', () => { + test('1,000 seeded games finish: cards conserved, no leaks, onTimeout always legal, deterministic', () => { + let finished = 0; + for (let i = 1; i <= 1000; i++) { + const n = 2 + (i % 9); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = ninetyNine.optionsSchema.parse(PRESETS[i % PRESETS.length]!); + const a = fastPlayout(players, options, seedN(i), 20_000); + if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`); + finished++; + const res = ninetyNine.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + if (i % 25 === 0) { + const b = fastPlayout(players, options, seedN(i), 20_000); + expect(b.trace).toBe(a.trace); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(finished).toBe(1000); + }, 60_000); + + test.each([ + [2, {}], + [3, { lives: 3 }], + [5, { afterElimination: 'reset' }], + [10, {}], + [4, { mode: 'classic' }], + [8, { mode: 'classic', lives: 1, onTheBoard: true }], + ] as const)('randomPlayout with the engine’s per-step checks: %i players %o', (n, opts) => { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = ninetyNine.optionsSchema.parse(opts); + const check = (st: unknown) => invariants(st as NinetyNineState); + for (let i = 1; i <= 3; i++) { + const a = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: check, maxApplyMs: 30 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }, 60_000); +}); diff --git a/packages/games/src/ninety-nine/types.ts b/packages/games/src/ninety-nine/types.ts new file mode 100644 index 0000000..b53ad05 --- /dev/null +++ b/packages/games/src/ninety-nine/types.ts @@ -0,0 +1,203 @@ +/** 구십구 options, actions, state and view types (docs/games/ninety-nine.md §2, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const ninetyNineOptionsSchema = z.object({ + mode: z + .enum(['kr-product', 'classic']) + .default('kr-product') + .meta({ title: '규칙', labels: { 'kr-product': '전용 카드(±9·±10·0·조커)', classic: '트럼프 카드(전통 99)' } }), + handSize: lit(3, 4, 5).default(3).meta({ title: '손패 장수', labels: { 3: '3장', 4: '4장', 5: '5장' } }), + refill: z.boolean().default(true).meta({ title: '카드를 낸 뒤 1장 보충', labels: { true: '보충해요', false: '보충 안 해요' } }), + lives: lit<'auto' | 0 | 1 | 2 | 3 | 5>('auto', 0, 1, 2, 3, 5) + .default('auto') + .meta({ + title: '목숨 수', + labels: { auto: '규칙 기본(전용 카드 없음, 트럼프 3개)', 0: '없음(바로 탈락)', 1: '1개', 2: '2개', 3: '3개', 5: '5개' }, + }), + onTheBoard: z + .boolean() + .default(false) + .meta({ title: '목숨을 다 잃어도 한 번 더 질 때까지 남기', labels: { true: '켜기', false: '끄기' } }), + afterElimination: z + .enum(['continue', 'reset']) + .default('continue') + .meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '0으로 하고 다시 나누기' } }), + floorAtZero: z + .enum(['forbid', 'clamp']) + .default('forbid') + .meta({ title: '빼서 0보다 작아질 때', labels: { forbid: '그렇게는 못 빼요', clamp: '0으로 맞춰요' } }), + specialCounts: lit(2, 3, 4, 5, 6) + .default(4) + .meta({ title: '(전용 카드) ±9·±10·0 카드 장수(각각)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장', 6: '6장' } }), + jokerRange: z + .enum(['60-99', '0-99']) + .default('60-99') + .meta({ title: '(전용 카드) 조커로 부를 수 있는 수', labels: { '60-99': '60~99', '0-99': '0~99(어린이용)' } }), + aceHigh: lit(10, 11).default(11).meta({ title: '(트럼프) A의 큰 값', labels: { 10: '10', 11: '11' } }), + deckCount: lit<'auto' | 1 | 2>('auto', 1, 2) + .default('auto') + .meta({ title: '(트럼프) 덱 수', labels: { auto: '자동(7명부터 2벌)', 1: '1벌', 2: '2벌' } }), + turnSeconds: lit(10, 15, 20, 30) + .default(15) + .meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }), +}); +export type NNOptions = z.infer; +export type NNMode = NNOptions['mode']; + +export const ninetyNineActionSchema = z.object({ + type: z.literal('play'), + cardId: z.string().max(12), + sign: z.enum(['+', '-']).optional(), + aceValue: lit(1, 10, 11).optional(), + jokerValue: z.number().int().min(0).max(99).optional(), +}); +export type PlayAction = z.infer; +/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */ +export type NNAction = PlayAction | { type: 'timeout' } | { type: 'leave' }; + +export type Suit = 'S' | 'H' | 'D' | 'C'; + +/** What is printed on the card (no id: safe to show in logs). */ +export interface CardFace { + /** kr: "7", "10", "±9", "0", "조커"; classic: "A", "2".."10", "J", "Q", "K". */ + label: string; + /** Classic only. */ + suit?: Suit; +} + +export type NNCard = + | { id: string; kind: 'add'; v: number; face: CardFace } + | { id: string; kind: 'plusMinus'; v: 9 | 10; face: CardFace } + | { id: string; kind: 'reverse'; face: CardFace } + | { id: string; kind: 'hold'; face: CardFace } + | { id: string; kind: 'minus'; v: 10; face: CardFace } + | { id: string; kind: 'ace'; face: CardFace } + | { id: string; kind: 'to99'; face: CardFace } + | { id: string; kind: 'joker'; face: CardFace }; +export type CardKind = NNCard['kind']; + +/** Public log line (structured; the screen turns it into Korean with nicknames). */ +export type LogEntry = + | { seq: number; t: 'play'; p: PlayerId; face: CardFace; kind: CardKind; from: number; total: number; choice?: string; auto?: boolean } + | { seq: number; t: 'life'; p: PlayerId; left: number } + | { seq: number; t: 'out'; p: PlayerId; why: 'noCard' | 'left' } + | { seq: number; t: 'noCard'; p: PlayerId } + | { seq: number; t: 'round'; round: number; starter: PlayerId } + | { seq: number; t: 'reshuffle'; n: number } + | { seq: number; t: 'timeout'; p: PlayerId }; + +export interface PlayerResult { + player: PlayerId; + rank: number; + livesLost: number; + eliminatedAtRound: number | null; + left: boolean; +} + +export interface NNFinal { + ranking: PlayerResult[]; + winner: PlayerId[]; + lastTotal: number; + summary: string; +} + +export interface NinetyNineState { + mode: NNMode; + options: NNOptions; + /** Lives per player resolved from the `auto` option (0 = no lives). */ + livesStart: number; + decks: 1 | 2; + seats: PlayerId[]; + hands: Record; + /** Secret; [0] is the top. */ + drawPile: NNCard[]; + /** Only the count is public (faces are in the log). */ + discard: NNCard[]; + total: number; + direction: 1 | -1; + current: PlayerId; + alive: Record; + lives: Record; + livesLost: Record; + onBoard: Record; + eliminationOrder: PlayerId[]; + eliminatedAtRound: Record; + leftOrder: PlayerId[]; + round: number; + phase: 'playing' | 'finished'; + turnStartedAt: number; + /** Applied actions. */ + seq: number; + /** Changes whenever a new turn starts. */ + turnNo: number; + timeoutsInRow: Record; + away: Record; + log: LogEntry[]; + final: NNFinal | null; +} + +// ---------------------------------------------------------------- view + +/** One way to play a card (e.g. "+9" or "−9") and the total it would make. */ +export interface ChoiceView { + label: string; + sign?: '+' | '-'; + aceValue?: 1 | 10 | 11; + /** Total after playing (may be over 99 or below 0 when not legal). */ + total: number; + legal: boolean; + reason?: string; +} + +export interface HandCardView { + id: string; + kind: CardKind; + face: CardFace; + /** Ways to play it; the joker has none (pick a number in `joker`). */ + choices: ChoiceView[]; + joker: { min: number; max: number } | null; + legal: boolean; + /** Short badge for an unplayable card, e.g. "99 초과". */ + badge: string | null; +} + +export interface SeatView { + id: PlayerId; + cards: number; + lives: number; + livesLost: number; + alive: boolean; + onBoard: boolean; + left: boolean; + away: boolean; + isTurn: boolean; +} + +export interface NinetyNineView { + mode: NNMode; + options: NNOptions; + livesStart: number; + jokerRange: { min: number; max: number }; + me: PlayerId | null; + seats: SeatView[]; + total: number; + direction: 1 | -1; + current: PlayerId | null; + phase: NinetyNineState['phase']; + turnNo: number; + round: number; + drawPileCount: number; + discardCount: number; + /** Plays of this round, newest last (up to 6). */ + recent: Extract[]; + log: LogEntry[]; + eliminationOrder: PlayerId[]; + my: { hand: HandCardView[]; myTurn: boolean } | null; + turnStartedAt: number; + deadlineTotalMs: number; + result: NNFinal | null; +} diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index b39abcc..cf2f21a 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -33,6 +33,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true }, { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true }, { id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true }, + { id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);