Merge branch 'm7/ninety-nine' into codex/owner/grp_discord_joke-app
This commit is contained in:
855
apps/web/src/games/ninety-nine/index.tsx
Normal file
855
apps/web/src/games/ninety-nine/index.tsx
Normal file
@@ -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<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
|
||||
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
|
||||
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
|
||||
const TRUMP_RANK: Record<string, string> = { '10': 'T' };
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const 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<Record<CardKind, string>> = { 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 (
|
||||
<span className="nn-face" aria-hidden>
|
||||
<PlayingCard card={trumpId(face)} size="lg" style={{ width: '100%', height: '100%', filter: 'none' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const sub = kind === 'joker' ? (jokerRange ? `${jokerRange.min}~${jokerRange.max}` : '원하는 수') : (KR_SUB[kind] ?? '');
|
||||
const big = kind === 'joker' ? '★' : face.label;
|
||||
return (
|
||||
<span className="nn-face" aria-hidden>
|
||||
<span className="nn-art" data-k={kind} data-big={kind === 'add' && face.label === '10'}>
|
||||
<span className="corner">{kind === 'joker' ? '조커' : face.label}</span>
|
||||
<span className="big">{big}</span>
|
||||
{sub && <span className="sub">{sub}</span>}
|
||||
<span className="corner br">{kind === 'joker' ? '조커' : face.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ face, kind }: { face: CardFace; kind: CardKind }) {
|
||||
return (
|
||||
<span className="nn-mini" data-k={kind} data-red={face.suit === 'H' || face.suit === 'D'} aria-hidden>
|
||||
{faceName(face)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function Hearts({ s, start }: { s: SeatView; start: number }) {
|
||||
if (start <= 0) return null;
|
||||
return (
|
||||
<span className="nn-hearts" aria-hidden>
|
||||
{Array.from({ length: start }, (_, i) => (
|
||||
<span key={i} className={`nn-heart${i >= s.lives ? ' lost' : ''}`}>
|
||||
{i >= s.lives ? '♡' : '♥'}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function seatLabel(s: SeatView, v: NinetyNineView, players: Record<string, PublicUser>, 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<string, PublicUser>; 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 (
|
||||
<li className={`nn-seat${s.isTurn ? ' turn' : ''}${s.alive ? '' : ' out'}`} aria-label={seatLabel(s, v, players, me)}>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={24} />
|
||||
<span className="nm">{u.nickname}</span>
|
||||
{s.isTurn && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={s.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }} aria-hidden>
|
||||
{s.alive ? (
|
||||
<span className="nn-count">
|
||||
<i />
|
||||
{s.cards}
|
||||
</span>
|
||||
) : (
|
||||
<span className="nn-badge" style={{ background: s.left ? 'var(--text-sub)' : 'var(--danger)' }}>
|
||||
{s.left ? '나감' : '탈락'}
|
||||
</span>
|
||||
)}
|
||||
<Hearts s={s} start={v.livesStart} />
|
||||
{s.onBoard && s.alive && <span className="nn-badge" style={{ background: 'var(--warning)' }}>마지막 기회</span>}
|
||||
{s.away && <span className="nn-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
|
||||
{rank && <span className="nn-badge" style={{ background: rank === 1 ? 'var(--success)' : 'var(--text-sub)' }}>{rank}등</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- table
|
||||
|
||||
function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Record<string, PublicUser>; 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 (
|
||||
<div className="nn-table" role="region" aria-label="카드 판" style={{ '--dial': dial } as CSSProperties}>
|
||||
<div className="nn-left">
|
||||
<span className="nn-dir" data-dir={v.direction} role="img" aria-label={v.direction === 1 ? '진행 방향: 시계 방향' : '진행 방향: 반대 방향'}>
|
||||
<b aria-hidden>↻</b>
|
||||
{v.direction === 1 ? '시계 방향' : '반대 방향'}
|
||||
</span>
|
||||
{cur && (
|
||||
<span className="nn-chip">
|
||||
{cur === me ? '내 차례' : `${nick(players, cur)}님 차례`}
|
||||
<TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={cur === me} size={40} />
|
||||
</span>
|
||||
)}
|
||||
<span className="nn-chip" aria-label={`뽑을 카드 ${v.drawPileCount}장`}>
|
||||
더미 {v.drawPileCount}장
|
||||
</span>
|
||||
{v.livesStart > 0 && <span className="nn-chip">{v.round}판째</span>}
|
||||
</div>
|
||||
<div className="nn-dial" role="img" aria-label={`지금 합계 ${v.total}`}>
|
||||
<svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden>
|
||||
<circle cx="50" cy="50" r={r} fill="rgba(0,0,0,.28)" stroke="rgba(255,255,255,.18)" strokeWidth="8" />
|
||||
<circle cx="50" cy="50" r={r} fill="none" stroke={gaugeColor(v.total)} strokeWidth="8" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} />
|
||||
</svg>
|
||||
<span className="nn-total" aria-hidden>
|
||||
{shown}
|
||||
</span>
|
||||
<span className="nn-total-cap" aria-hidden>
|
||||
/ 99
|
||||
</span>
|
||||
</div>
|
||||
<div className="nn-right">
|
||||
<span className="nn-rule">99를 넘기면 안 돼요!</span>
|
||||
{v.recent.length > 0 ? (
|
||||
<ol className="nn-recent" aria-label="최근에 낸 카드">
|
||||
{v.recent.slice(-4).map((e) => (
|
||||
<li key={`${e.seq}-${e.p}`}>
|
||||
<span className="who">{nick(players, e.p)}</span>
|
||||
<Mini face={e.face} kind={e.kind} />
|
||||
{e.choice && e.kind !== 'joker' ? <span>{e.choice}</span> : null}
|
||||
<span aria-hidden>→</span>
|
||||
<strong>{e.total}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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<HTMLSpanElement>) => {
|
||||
const d: Record<string, number> = { 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 (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<div className="nn-joker" role="group" aria-label="조커로 만들 수 고르기">
|
||||
<button type="button" className="btn btn-secondary step" aria-label="1 작게" disabled={n <= min} onClick={() => setN(clamp(n - 1))}>
|
||||
−
|
||||
</button>
|
||||
<span className="val" role="spinbutton" tabIndex={0} aria-label="조커로 만들 수" aria-valuemin={min} aria-valuemax={max} aria-valuenow={n} onKeyDown={onKey}>
|
||||
{n}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary step" aria-label="1 크게" disabled={n >= max} onClick={() => setN(clamp(n + 1))}>
|
||||
+
|
||||
</button>
|
||||
<span className="nn-quick" role="group" aria-label="빠른 선택">
|
||||
{quick.map((q) => (
|
||||
<button key={q} type="button" className="btn btn-secondary quick" aria-pressed={n === q} onClick={() => setN(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nn-pick">
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => onPlay(n)}>
|
||||
조커 내기 → {n}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const my = v.my!;
|
||||
const [sel, setSel] = useState<string | null>(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<string, unknown> = {}) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSel(null);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const btns = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('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 (
|
||||
<div className="nn-hand-wrap">
|
||||
<div className="nn-hand-head">
|
||||
<strong>
|
||||
내 카드 {n}장{myTurn ? ` · 낼 수 있는 카드 ${legalCount}장` : ''}
|
||||
</strong>
|
||||
{meSeat && v.livesStart > 0 && (
|
||||
<span className="row small" style={{ gap: 6 }} aria-label={`내 목숨 ${meSeat.lives}개`}>
|
||||
목숨 <Hearts s={meSeat} start={v.livesStart} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="nn-hand" role="group" aria-label="내 카드. 좌우 방향키로 고르고 Enter로 고른 뒤 한 번 더 누르면 내요" onKeyDown={onKey} style={{ '--cw': cw } as CSSProperties}>
|
||||
{my.hand.map((c) => {
|
||||
const p = previewOf(c);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="nn-card"
|
||||
data-legal={myTurn ? c.legal : undefined}
|
||||
aria-pressed={sel === c.id}
|
||||
aria-label={`${cardAria(c, myTurn)}${sel === c.id && myTurn && single(c) ? '. 한 번 더 누르면 내요' : ''}`}
|
||||
onClick={() => tap(c)}
|
||||
>
|
||||
<span className={`nn-prev ${p.cls}`} aria-hidden>
|
||||
{p.text}
|
||||
</span>
|
||||
<Face face={c.face} kind={c.kind} jokerRange={c.joker ?? undefined} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{n === 0 && <span className="muted">손에 카드가 없어요.</span>}
|
||||
</div>
|
||||
{note && (
|
||||
<p className="small" style={{ margin: 0 }} aria-live="polite">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
{chosen && myTurn && chosen.legal && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{chosen.joker ? (
|
||||
<JokerDial key={chosen.id} c={chosen} total={v.total} pending={pending} onPlay={(jokerValue) => send(chosen, { jokerValue })} />
|
||||
) : (
|
||||
<div className="nn-pick" role="group" aria-label={`${ariaFace(chosen.face)} 내는 방법`}>
|
||||
{chosen.choices.map((x) => (
|
||||
<button
|
||||
key={x.label}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!x.legal || pending}
|
||||
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
|
||||
>
|
||||
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{myTurn && !chosen && <p className="small muted" style={{ margin: 0 }}>카드를 누르면 낼 때 합계가 보여요. 한 번 더 누르면 내요.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- log
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>): 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<NinetyNineView>) {
|
||||
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 (
|
||||
<div className="nn">
|
||||
<style>{CSS}</style>
|
||||
<ul className="nn-opps" aria-label="다른 사람">
|
||||
{others.map((s) => (
|
||||
<SeatChip key={s.id} s={s} v={v} players={players} me={me} deadline={deadline} />
|
||||
))}
|
||||
</ul>
|
||||
<Table v={v} players={players} me={me} deadline={deadline} />
|
||||
{v.my && meSeat?.alive && v.phase === 'playing' && <MyHand v={v} act={act} pending={pending} />}
|
||||
{meSeat && !meSeat.alive && v.phase === 'playing' && (
|
||||
<div className="nn-panel">
|
||||
<strong>{meSeat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
|
||||
</div>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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<NinetyNineView>) {
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
구십구 · {MODE_KO[v.mode]}
|
||||
{v.livesStart > 0 ? ` · ${v.round}판` : ''}
|
||||
{v.phase === 'finished' ? ' · 게임 끝' : ''}
|
||||
</strong>
|
||||
{v.current && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">{rulesLine(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{v.seats.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.alive ? 1 : 0.55 }} aria-label={seatLabel(s, v, players, me)}>
|
||||
<span style={{ filter: s.alive ? 'none' : 'grayscale(1)', display: 'inline-flex' }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{s.isTurn ? '▶ ' : ''}
|
||||
{nick(players, s.id)}
|
||||
{s.id === me ? ' (나)' : ''}
|
||||
{!s.alive ? (s.left ? ' · 나감' : ' · 탈락') : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Hearts s={s} start={v.livesStart} />
|
||||
{s.alive && (
|
||||
<span className="nn-count" aria-hidden>
|
||||
<i />
|
||||
{s.cards}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((r) => (
|
||||
<span key={r.player}>
|
||||
{r.rank}등 {nick(players, r.player)}
|
||||
<span className="muted small">
|
||||
{r.eliminatedAtRound === null ? ' · 끝까지 살아남음' : r.left ? ' · 나감' : v.livesStart > 0 ? ` · ${r.eliminatedAtRound}판에서 탈락` : ' · 탈락'}
|
||||
{v.livesStart > 0 ? ` · 잃은 목숨 ${r.livesLost}개` : ''}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">{v.result.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol className="nn-log">
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={`${e.seq}-${e.t}-${i}`} className="small">
|
||||
{logText(e, players)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>특수 카드</summary>
|
||||
<SpecialTable o={v.options} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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, PublicUser>): 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<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<NNOptions>) => onChange({ ...o, ...patch });
|
||||
const kr = o.mode === 'kr-product';
|
||||
const lives = resolvedLives(o);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="규칙" help={kr ? '1~8·10 숫자 카드와 ±9·±10·0·조커 전용 카드로 해요.' : '트럼프 카드로 해요. K는 99, 4는 방향 바꾸기, 9는 그대로, 10은 빼기 10.'}>
|
||||
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
|
||||
</Field>
|
||||
<Field label="손패 장수">
|
||||
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
</Field>
|
||||
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
<Seg
|
||||
label="목숨"
|
||||
value={o.lives}
|
||||
options={[['auto', kr ? '없음(기본)' : '3개(기본)'], [0, '없음'], [1, '1개'], [2, '2개'], [3, '3개'], [5, '5개']]}
|
||||
onChange={(v) => set({ lives: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="한 턴 제한 시간">
|
||||
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{kr && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 4장)
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="카드를 낸 뒤 1장 보충">
|
||||
<Seg label="카드를 낸 뒤 1장 보충" value={o.refill} options={[[true, '보충해요'], [false, '보충 안 해요']]} onChange={(refill) => set({ refill })} />
|
||||
</Field>
|
||||
<Field label="목숨을 다 잃어도 한 번 더" help="켜면 목숨이 0개여도 남고, 한 번 더 지면 탈락해요.">
|
||||
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
|
||||
</Field>
|
||||
<Field label="(목숨 없음) 탈락 뒤 합계">
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
</Field>
|
||||
<Field label="빼서 0보다 작아질 때">
|
||||
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
|
||||
</Field>
|
||||
{kr ? (
|
||||
<>
|
||||
<Field label="±9·±10·0 카드 장수(각각)">
|
||||
<Seg label="특수 카드 장수" value={o.specialCounts} options={[[2, '2장'], [3, '3장'], [4, '4장'], [5, '5장'], [6, '6장']]} onChange={(specialCounts) => set({ specialCounts })} />
|
||||
</Field>
|
||||
<Field label="조커로 부를 수 있는 수">
|
||||
<Seg label="조커로 부를 수 있는 수" value={o.jokerRange} options={[['60-99', '60~99'], ['0-99', '0~99(어린이용)']]} onChange={(jokerRange) => set({ jokerRange })} />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="A의 큰 값">
|
||||
<Seg label="A의 큰 값" value={o.aceHigh} options={[[11, '11'], [10, '10']]} onChange={(aceHigh) => set({ aceHigh })} />
|
||||
</Field>
|
||||
<Field label="덱 수">
|
||||
<Seg label="덱 수" value={o.deckCount} options={[['auto', '자동(7명부터 2벌)'], [1, '1벌'], [2, '2벌']]} onChange={(deckCount) => set({ deckCount })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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']) => <Face face={suit ? { label, suit } : { label }} kind={kind} />;
|
||||
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 (
|
||||
<table className="nn-rules small" style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
|
||||
<tbody>
|
||||
{rows.map(([pic, name, desc]) => (
|
||||
<tr key={name}>
|
||||
<td style={{ width: 44 }}>{pic}</td>
|
||||
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</th>
|
||||
<td>{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
|
||||
</p>
|
||||
<p>
|
||||
<strong>낼 카드가 없으면</strong>:{' '}
|
||||
{lives > 0
|
||||
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
: o.afterElimination === 'reset'
|
||||
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
|
||||
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
|
||||
마지막까지 남은 1명이 이겨요.
|
||||
</p>
|
||||
<SpecialTable o={o} />
|
||||
<p className="small muted">
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<NinetyNineView, NNOptions> = {
|
||||
Board,
|
||||
Side,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -24,6 +24,7 @@ export const GAME_UI: Record<string, () => 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<string, GameUI>();
|
||||
|
||||
@@ -21,6 +21,7 @@ export const GAME_ICON: Record<string, string> = {
|
||||
quoridor: '🧱',
|
||||
'las-vegas': '⚅',
|
||||
'cockroach-poker': '🐞',
|
||||
'ninety-nine': '💯',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
|
||||
119
e2e/games.e2e.ts
119
e2e/games.e2e.ts
@@ -577,6 +577,70 @@ async function cockroachStep(p: Page, stats: Record<string, number>): Promise<st
|
||||
return null;
|
||||
}
|
||||
|
||||
const nnShots = new Set<string>();
|
||||
/** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */
|
||||
async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<boolean> {
|
||||
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<Page['locator']>) => (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<string, Plan> = {
|
||||
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<string, Plan> = {
|
||||
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<string, number> = {};
|
||||
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);
|
||||
|
||||
@@ -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<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -45,4 +46,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[quoridor.id]: quoridor,
|
||||
[lasVegas.id]: lasVegas,
|
||||
[cockroachPoker.id]: cockroachPoker,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
};
|
||||
|
||||
139
packages/games/src/ninety-nine/cards.ts
Normal file
139
packages/games/src/ninety-nine/cards.ts
Normal file
@@ -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<number, string> = { 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<NNOptions, 'jokerRange'>): { 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<ChoiceView> = {}, 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<NNCard['kind'], number> = { 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;
|
||||
}
|
||||
459
packages/games/src/ninety-nine/index.ts
Normal file
459
packages/games/src/ninety-nine/index.ts
Normal file
@@ -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 = <T>(r: Record<PlayerId, T>): Record<PlayerId, T> => ({ ...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<NNOptions, 'mode' | 'lives'>): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
export const resolvedDecks = (o: Pick<NNOptions, 'mode' | 'deckCount'>, players: number): 1 | 2 =>
|
||||
o.mode !== 'classic' ? 1 : o.deckCount === 'auto' ? (players >= 7 ? 2 : 1) : o.deckCount;
|
||||
export const fullDeck = (s: Pick<NinetyNineState, 'mode' | 'options' | 'decks'>): 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<string, unknown> & { 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<NinetyNineState, NNAction, NinetyNineView, NNOptions>;
|
||||
|
||||
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 = <T>(v: T) => Object.fromEntries(players.map((p) => [p, v])) as Record<PlayerId, T>;
|
||||
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<number | null>(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<LogEntry, { t: 'play' }> => 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)];
|
||||
},
|
||||
};
|
||||
599
packages/games/src/ninety-nine/ninety-nine.test.ts
Normal file
599
packages/games/src/ninety-nine/ninety-nine.test.ts
Normal file
@@ -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<NNOptions> = {}, 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<string, string[]>; 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> = {}): 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<string, { meta(): { title?: string; labels?: Record<string, string> } | 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<string>();
|
||||
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<NNOptions>[] = [
|
||||
{},
|
||||
{ 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);
|
||||
});
|
||||
203
packages/games/src/ninety-nine/types.ts
Normal file
203
packages/games/src/ninety-nine/types.ts
Normal file
@@ -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 = <T extends string | number>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
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<typeof ninetyNineOptionsSchema>;
|
||||
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<typeof ninetyNineActionSchema>;
|
||||
/** 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<PlayerId, NNCard[]>;
|
||||
/** 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<PlayerId, boolean>;
|
||||
lives: Record<PlayerId, number>;
|
||||
livesLost: Record<PlayerId, number>;
|
||||
onBoard: Record<PlayerId, boolean>;
|
||||
eliminationOrder: PlayerId[];
|
||||
eliminatedAtRound: Record<PlayerId, number | null>;
|
||||
leftOrder: PlayerId[];
|
||||
round: number;
|
||||
phase: 'playing' | 'finished';
|
||||
turnStartedAt: number;
|
||||
/** Applied actions. */
|
||||
seq: number;
|
||||
/** Changes whenever a new turn starts. */
|
||||
turnNo: number;
|
||||
timeoutsInRow: Record<PlayerId, number>;
|
||||
away: Record<PlayerId, boolean>;
|
||||
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<LogEntry, { t: 'play' }>[];
|
||||
log: LogEntry[];
|
||||
eliminationOrder: PlayerId[];
|
||||
my: { hand: HandCardView[]; myTurn: boolean } | null;
|
||||
turnStartedAt: number;
|
||||
deadlineTotalMs: number;
|
||||
result: NNFinal | null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user