Compare commits
14 Commits
4de9ca467c
...
bc199de10b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc199de10b | ||
|
|
8fb4219749 | ||
|
|
77a7492b85 | ||
|
|
7e83d061bc | ||
|
|
5aad13f8a5 | ||
|
|
df280eb5b0 | ||
|
|
467d8ee4c2 | ||
|
|
c34497a445 | ||
|
|
fa7ceea668 | ||
|
|
11ad2be147 | ||
|
|
1fc4d8c7fa | ||
|
|
dfe4ce1ca4 | ||
|
|
3e414e62ca | ||
|
|
559fb8507c |
1059
apps/web/src/games/cockroach-poker/index.tsx
Normal file
1059
apps/web/src/games/cockroach-poker/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1007
apps/web/src/games/dalmuti/index.tsx
Normal file
1007
apps/web/src/games/dalmuti/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
816
apps/web/src/games/las-vegas/index.tsx
Normal file
816
apps/web/src/games/las-vegas/index.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const ROLL_MS = 650;
|
||||
const HOUSE = 'house';
|
||||
/** Seat colours (dice), plus white for the neutral "house" dice. */
|
||||
const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0'];
|
||||
const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라'];
|
||||
/** Play-bill colours per face value (deliberately toy-like). */
|
||||
const BILL: Record<number, [string, string]> = {
|
||||
1: ['#fde68a', '#78350f'],
|
||||
2: ['#bbf7d0', '#14532d'],
|
||||
3: ['#bae6fd', '#0c4a6e'],
|
||||
4: ['#fbcfe8', '#831843'],
|
||||
5: ['#ddd6fe', '#3b0764'],
|
||||
6: ['#fed7aa', '#7c2d12'],
|
||||
7: ['#a7f3d0', '#064e3b'],
|
||||
8: ['#c7d2fe', '#1e1b4b'],
|
||||
9: ['#fecaca', '#7f1d1d'],
|
||||
};
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (players[id]?.nickname ?? '알 수 없음'));
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888'));
|
||||
const initial = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?'));
|
||||
|
||||
const CSS = `
|
||||
.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; }
|
||||
.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; }
|
||||
.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
|
||||
.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border);
|
||||
background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; }
|
||||
button.lv-casino { cursor: pointer; }
|
||||
button.lv-casino:hover { border-color: var(--primary); }
|
||||
.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.lv-casino.can { border-style: dashed; border-color: var(--primary); }
|
||||
.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); }
|
||||
.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); }
|
||||
.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); }
|
||||
.lv-ctop { display: flex; align-items: center; gap: 6px; }
|
||||
.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; }
|
||||
.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; }
|
||||
.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; }
|
||||
.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px;
|
||||
font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; }
|
||||
.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; }
|
||||
.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; }
|
||||
.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); }
|
||||
.lv-nobill { color: var(--text-sub); font-size: .8rem; }
|
||||
.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; }
|
||||
.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; }
|
||||
.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; }
|
||||
.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; }
|
||||
.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
|
||||
.lv-tag.first { background: var(--success); color: #fff; }
|
||||
.lv-tag.tie { background: #facc15; color: #422006; }
|
||||
.lv-tag.award { background: var(--primary); color: var(--primary-text); }
|
||||
.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); }
|
||||
@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } }
|
||||
.lv-casino.hit { animation: lv-hit .55s ease-out; }
|
||||
.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; }
|
||||
.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
|
||||
.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px;
|
||||
border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; }
|
||||
.lv-group:disabled { cursor: default; opacity: 1; }
|
||||
.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.lv-group b { font-size: 1.15rem; }
|
||||
.lv-group small { color: var(--text-sub); font-weight: 700; }
|
||||
.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; }
|
||||
.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; }
|
||||
.lv-die svg { display: block; width: 100%; height: 100%; }
|
||||
.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; }
|
||||
@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } }
|
||||
.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; }
|
||||
.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; }
|
||||
.lv-preview.first { background: var(--success-soft); color: var(--success); }
|
||||
.lv-preview.tie { background: var(--warning-soft); color: var(--warning); }
|
||||
.lv-preview.award, .lv-preview.none { background: var(--surface-2); }
|
||||
.lv-roll { min-height: 60px; font-size: 1.15rem; }
|
||||
.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.lv-settle h3 { margin: 0; font-size: 1rem; }
|
||||
.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); }
|
||||
.lv-prow:last-child { border-bottom: 0; }
|
||||
.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; }
|
||||
.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; }
|
||||
.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; }
|
||||
.lv-tieout .lv-dot { opacity: .55; }
|
||||
@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
|
||||
.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; }
|
||||
.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
|
||||
.lv-final { padding: 12px; }
|
||||
.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
|
||||
.lv-final td.notes { white-space: normal; text-align: left; }
|
||||
@media (max-width: 1099px) {
|
||||
.lv-lower { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.lv-casino { min-height: 132px; padding: 6px; gap: 4px; }
|
||||
.lv-wide { display: none; }
|
||||
.lv-cnum { font-size: 1.25rem; }
|
||||
.lv-cname { display: none; }
|
||||
.lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; }
|
||||
.lv-dot { width: 11px; height: 11px; }
|
||||
.lv-help { gap: 6px; flex-wrap: nowrap; }
|
||||
.lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; }
|
||||
.lv-group { min-width: 76px; padding: 6px 8px; }
|
||||
.lv-die { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.lv-die.lg { width: 32px; height: 32px; }
|
||||
/* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */
|
||||
.lv-lower { display: contents; }
|
||||
.lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-casino, .lv-casino.sel { transition: none; transform: none; }
|
||||
.lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- small pieces
|
||||
|
||||
// prettier-ignore
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[50, 50]],
|
||||
2: [[28, 28], [72, 72]],
|
||||
3: [[26, 26], [50, 50], [74, 74]],
|
||||
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
|
||||
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
|
||||
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
|
||||
};
|
||||
|
||||
/** Pip colour that reads on the die colour (white dice → dark pips). */
|
||||
const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff');
|
||||
|
||||
function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) {
|
||||
return (
|
||||
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
|
||||
<svg viewBox="0 0 100 100">
|
||||
{PIPS[value]!.map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Bill({ note, small }: { note: number | null; small?: boolean }) {
|
||||
if (note === null)
|
||||
return (
|
||||
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
|
||||
?
|
||||
</span>
|
||||
);
|
||||
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
|
||||
return (
|
||||
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
|
||||
{note}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
|
||||
}
|
||||
|
||||
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
|
||||
return (
|
||||
<span className="lv-chip">
|
||||
<Dot color={seatColor(v, owner)} />
|
||||
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' };
|
||||
|
||||
function previewText(c: LasVegasChoice): string {
|
||||
const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`;
|
||||
switch (c.standing) {
|
||||
case 'first':
|
||||
return `${head} 지금은 1등이에요! (${c.note}점 지폐)`;
|
||||
case 'award':
|
||||
return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`;
|
||||
case 'tie':
|
||||
return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`;
|
||||
default:
|
||||
return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps<LasVegasView>) {
|
||||
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [flick, setFlick] = useState<number[] | null>(null);
|
||||
const [hit, setHit] = useState<{ value: number; key: number } | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevRoll = useRef(v.rollSeq);
|
||||
const prevTurn = useRef(v.turnSeq);
|
||||
const prevRounds = useRef(v.roundResults.length);
|
||||
|
||||
// New roll: tumble animation + screen-reader text.
|
||||
useLayoutEffect(() => {
|
||||
if (v.rollSeq <= prevRoll.current || !v.lastRoll) {
|
||||
prevRoll.current = v.rollSeq;
|
||||
return;
|
||||
}
|
||||
prevRoll.current = v.rollSeq;
|
||||
setSel(null);
|
||||
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
|
||||
const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b);
|
||||
setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`);
|
||||
if (reducedMotion()) {
|
||||
sounds.stone();
|
||||
return;
|
||||
}
|
||||
setRolling(true);
|
||||
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!rolling || !v.lastRoll) return;
|
||||
const n = v.lastRoll.own.length + v.lastRoll.neutral.length;
|
||||
const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70);
|
||||
const end = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setFlick(null);
|
||||
sounds.stone();
|
||||
}, ROLL_MS);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
clearTimeout(end);
|
||||
};
|
||||
}, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Turn changes: placement flash, round settlement and my-turn cue.
|
||||
useEffect(() => {
|
||||
if (v.turnSeq === prevTurn.current) return;
|
||||
prevTurn.current = v.turnSeq;
|
||||
setSel(null);
|
||||
const parts: string[] = [];
|
||||
const lp = v.lastPlace;
|
||||
if (lp) {
|
||||
setHit({ value: lp.value, key: v.turnSeq });
|
||||
const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`;
|
||||
parts.push(
|
||||
`${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`,
|
||||
);
|
||||
}
|
||||
if (v.roundResults.length > prevRounds.current) {
|
||||
prevRounds.current = v.roundResults.length;
|
||||
const rr = v.roundResults[v.roundResults.length - 1]!;
|
||||
const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length;
|
||||
parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`);
|
||||
haptic([50, 30, 50]);
|
||||
}
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
if (parts.length) setAnnounce(parts.join(' '));
|
||||
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const choices = v.phase === 'place' && !rolling ? v.choices : [];
|
||||
const selChoice = choices.find((c) => c.value === sel) ?? null;
|
||||
const canPick = myTurn && v.phase === 'place' && !pending && !rolling;
|
||||
const pick = (value: number) => {
|
||||
if (!canPick || !choices.some((c) => c.value === value)) return;
|
||||
setSel(sel === value ? null : value);
|
||||
};
|
||||
const place = () => {
|
||||
if (!canPick || sel === null) return;
|
||||
act({ type: 'PLACE', value: sel });
|
||||
setSel(null);
|
||||
};
|
||||
const roll = () => {
|
||||
if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' });
|
||||
};
|
||||
|
||||
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
|
||||
const hand = me ? v.hand[me] : undefined;
|
||||
const lastRR = v.roundResults[v.roundResults.length - 1];
|
||||
|
||||
return (
|
||||
<div className="lv-wrap">
|
||||
<style>{CSS}</style>
|
||||
<div className="card lv-head">
|
||||
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
|
||||
{v.current && (
|
||||
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
<Dot color={seatColor(v, v.current)} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="small muted">
|
||||
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
|
||||
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
|
||||
</span>
|
||||
</div>
|
||||
{v.round === 1 && v.phase !== 'finished' && (
|
||||
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
|
||||
<span>🎲 같은 숫자는 한꺼번에</span>
|
||||
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
|
||||
</div>
|
||||
)}
|
||||
{v.result && lastRR && (
|
||||
<div className="lv-lower">
|
||||
<FinalTable v={v} me={me} players={players} />
|
||||
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
|
||||
</div>
|
||||
)}
|
||||
{!v.result && (
|
||||
<section className="lv-casinos" aria-label="카지노 6곳">
|
||||
{v.casinos.map((c) => {
|
||||
const ch = choices.find((x) => x.value === c.value);
|
||||
const isSel = sel === c.value;
|
||||
const owners = Object.entries(c.dice)
|
||||
.filter(([, n]) => (n ?? 0) > 0)
|
||||
.sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0]));
|
||||
const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${
|
||||
owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음'
|
||||
}${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`;
|
||||
const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`;
|
||||
const body = (
|
||||
<>
|
||||
<div className="lv-ctop">
|
||||
<Die value={c.value} color="#fffdf8" />
|
||||
<span className="lv-cnum">{c.value}</span>
|
||||
<span className="lv-cname">번 카지노</span>
|
||||
</div>
|
||||
<div className="lv-bills">
|
||||
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
|
||||
</div>
|
||||
<div className="lv-stacks">
|
||||
{owners.map(([o, n]) => (
|
||||
<div key={o} className="lv-stack" title={nick(players, o)}>
|
||||
<span className="lv-pips">
|
||||
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
|
||||
<Dot key={i} color={seatColor(v, o)} />
|
||||
))}
|
||||
</span>
|
||||
<span>{n! > 8 ? `${n}` : ''}</span>
|
||||
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{initial(players, o)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{ch && isSel && (
|
||||
<div className="lv-stack" style={{ opacity: 0.85 }}>
|
||||
<span className="lv-pips">
|
||||
{Array.from({ length: ch.own }, (_, i) => (
|
||||
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
|
||||
))}
|
||||
{Array.from({ length: ch.neutral }, (_, i) => (
|
||||
<Dot key={`n${i}`} color="#ffffff" />
|
||||
))}
|
||||
</span>
|
||||
<span className="small">+{ch.own + ch.neutral}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
|
||||
</>
|
||||
);
|
||||
return ch && canPick ? (
|
||||
<button
|
||||
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
|
||||
type="button"
|
||||
className={cls}
|
||||
aria-pressed={isSel}
|
||||
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
|
||||
onClick={() => pick(c.value)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{!v.result && (
|
||||
<div className="lv-lower">
|
||||
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
|
||||
{v.phase === 'finished' ? (
|
||||
<p className="muted" style={{ textAlign: 'center' }}>
|
||||
게임이 끝났어요
|
||||
</p>
|
||||
) : rolling && v.lastRoll ? (
|
||||
<div className="lv-mdice" aria-hidden>
|
||||
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
|
||||
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
|
||||
))}
|
||||
</div>
|
||||
) : v.phase === 'place' && v.current ? (
|
||||
<>
|
||||
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
className="lv-group"
|
||||
data-standing={c.standing}
|
||||
aria-pressed={canPick ? sel === c.value : undefined}
|
||||
disabled={!canPick}
|
||||
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
|
||||
onClick={() => pick(c.value)}
|
||||
>
|
||||
<span className="lv-mdice">
|
||||
{Array.from({ length: c.own }, (_, i) => (
|
||||
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
|
||||
))}
|
||||
{Array.from({ length: c.neutral }, (_, i) => (
|
||||
<Die key={`n${i}`} value={c.value} color="#ffffff" />
|
||||
))}
|
||||
</span>
|
||||
<b>
|
||||
{c.value} × {c.own + c.neutral}개
|
||||
</b>
|
||||
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
|
||||
{canPick ? (
|
||||
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
|
||||
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted small" style={{ textAlign: 'center' }}>
|
||||
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : myTurn ? (
|
||||
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
|
||||
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
|
||||
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
|
||||
</p>
|
||||
)}
|
||||
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
|
||||
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
|
||||
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
|
||||
</p>
|
||||
)}
|
||||
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
|
||||
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
|
||||
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{lastRR ? (
|
||||
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
|
||||
) : (
|
||||
<section className="card lv-settle" aria-label="정산 안내">
|
||||
<h3>라운드가 끝나면</h3>
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Settlement({
|
||||
round,
|
||||
payouts,
|
||||
v,
|
||||
me,
|
||||
players,
|
||||
}: {
|
||||
round: number;
|
||||
payouts: LasVegasPayoutView[];
|
||||
v: LasVegasView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
}) {
|
||||
const fresh = v.phase !== 'finished' && round === v.round - 1;
|
||||
return (
|
||||
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
|
||||
<h3>{round}라운드 정산</h3>
|
||||
{payouts.map((p) => (
|
||||
<div key={p.casino} className="lv-prow">
|
||||
<strong>{p.casino}번</strong>
|
||||
<div className="lv-pbody">
|
||||
{p.removedTies.length > 0 && (
|
||||
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
|
||||
💥 동점 무효
|
||||
{p.removedTies.map((o) => (
|
||||
<Dot key={o} color={seatColor(v, o)} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{p.awards.map((a, i) => (
|
||||
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
|
||||
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
|
||||
<Bill note={a.note} small />
|
||||
{a.note === null && <span className="muted">1장</span>}
|
||||
</span>
|
||||
))}
|
||||
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
|
||||
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
|
||||
const r = v.result!;
|
||||
return (
|
||||
<section className="card lv-final" aria-label="최종 결과">
|
||||
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">순위</th>
|
||||
<th scope="col">이름</th>
|
||||
{v.roundResults.map((rr) => (
|
||||
<th key={rr.round} scope="col">
|
||||
{rr.round}R
|
||||
</th>
|
||||
))}
|
||||
<th scope="col">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.ranking.map((row) => (
|
||||
<tr key={row.player}>
|
||||
<td>{row.rank}등</td>
|
||||
<td style={{ textAlign: 'left' }}>
|
||||
<OwnerChip v={v} players={players} owner={row.player} me={me} />
|
||||
{row.conceded && <span className="muted small"> (기권)</span>}
|
||||
</td>
|
||||
{(r.roundTotals[row.player] ?? []).map((t, i) => (
|
||||
<td key={i}>{t}</td>
|
||||
))}
|
||||
<td>
|
||||
<strong>{row.total}점</strong>
|
||||
<div className="small muted">{row.notes.length}장</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
|
||||
const [ask, setAsk] = useState(false);
|
||||
const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000;
|
||||
const mine = me ? v.money[me] : undefined;
|
||||
const playing = v.phase !== 'finished';
|
||||
return (
|
||||
<>
|
||||
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
|
||||
{v.seats.map((p, i) => {
|
||||
const turn = v.current === p;
|
||||
const m = v.money[p]!;
|
||||
const h = v.hand[p]!;
|
||||
const out = v.conceded.includes(p);
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
className="row"
|
||||
style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}
|
||||
>
|
||||
<span aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
|
||||
<Die value={i + 1} color={COLORS[i]!} />
|
||||
</span>
|
||||
<Avatar user={userOf(players, p)} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{nick(players, p)}
|
||||
{p === me && <span className="muted small"> (나)</span>}
|
||||
{v.startPlayer === p && playing && (
|
||||
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
|
||||
선
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
|
||||
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
|
||||
>
|
||||
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
|
||||
<div className="small muted">{m.count}장</div>
|
||||
</span>
|
||||
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
{mine && (
|
||||
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
|
||||
<div className="row small" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>내 지폐</strong>
|
||||
<span>
|
||||
{mine.count}장 · {mine.total ?? 0}점
|
||||
</span>
|
||||
</div>
|
||||
<div className="lv-bills">
|
||||
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
|
||||
</div>
|
||||
{v.options.hideMoney && playing && (
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
다른 사람에게는 장 수만 보여요.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{playing && me && v.money[me] && !v.conceded.includes(me) && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'CONCEDE' })}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: LasVegasView, me: string | null): boolean {
|
||||
return v.phase !== 'finished' && !!me && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요';
|
||||
if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요';
|
||||
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ 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 }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<LasVegasOptions>) {
|
||||
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="라운드 수">
|
||||
<Seg
|
||||
label="라운드 수"
|
||||
value={o.rounds}
|
||||
options={[
|
||||
[3, '3라운드'],
|
||||
[4, '4라운드'],
|
||||
[5, '5라운드'],
|
||||
]}
|
||||
onChange={(rounds) => set({ rounds })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="중립(흰) 주사위">
|
||||
<Seg
|
||||
label="중립(흰) 주사위"
|
||||
value={o.neutralDice}
|
||||
options={[
|
||||
['auto', '자동(2명일 때만)'],
|
||||
['on', '켜기(2~4명)'],
|
||||
['off', '끄기'],
|
||||
]}
|
||||
onChange={(neutralDice) => set({ neutralDice })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="다른 사람이 받은 지폐 금액">
|
||||
<Seg
|
||||
label="다른 사람이 받은 지폐 금액"
|
||||
value={o.hideMoney ? 'hide' : 'show'}
|
||||
options={[
|
||||
['hide', '숨기기(장 수만)'],
|
||||
['show', '보여 주기'],
|
||||
]}
|
||||
onChange={(k) => set({ hideMoney: k === 'hide' })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="굴리기·놓기 제한 시간">
|
||||
<Seg
|
||||
label="굴리기·놓기 제한 시간"
|
||||
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
|
||||
options={[
|
||||
['20', '20초'],
|
||||
['30', '30초'],
|
||||
['60', '60초'],
|
||||
['none', '무제한'],
|
||||
]}
|
||||
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
|
||||
/>
|
||||
</Field>
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
흰 주사위 "켜기"는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: LasVegasOptions): string {
|
||||
return [
|
||||
`${o.rounds}라운드`,
|
||||
o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기',
|
||||
o.hideMoney ? '금액 숨김' : '금액 공개',
|
||||
o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Rules({ options }: { options?: LasVegasOptions }) {
|
||||
const rounds = options?.rounds ?? 4;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
|
||||
시작해요.
|
||||
</p>
|
||||
<p>
|
||||
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
|
||||
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
|
||||
</p>
|
||||
<p>
|
||||
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
|
||||
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
|
||||
</p>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Bill note={8} />
|
||||
<Bill note={3} />
|
||||
<Bill note={1} />
|
||||
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 "하우스"라는
|
||||
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
|
||||
</p>
|
||||
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
|
||||
<p className="muted">
|
||||
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
|
||||
놓아요.
|
||||
</p>
|
||||
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<LasVegasView, LasVegasOptions> = {
|
||||
Board: LasVegasBoard,
|
||||
Side: LasVegasSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
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;
|
||||
936
apps/web/src/games/quoridor/index.tsx
Normal file
936
apps/web/src/games/quoridor/index.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { toast } from '../../store/toast';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type O = 'h' | 'v';
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const N = 9;
|
||||
const G = 0.2; // 홈(groove) 폭
|
||||
const PAD = 0.78;
|
||||
const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
|
||||
const SEAT_COLOR: Record<Seat, string> = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' };
|
||||
const COLOR_KO: Record<Seat, string> = { S: '빨강', W: '초록', N: '파랑', E: '주황' };
|
||||
const SIDE_KO = { top: '맨 위', bottom: '맨 아래', left: '맨 왼쪽', right: '맨 오른쪽' } as const;
|
||||
type Side = keyof typeof SIDE_KO;
|
||||
const label = (p: Cell) => `${'abcdefghi'[p.c]}${p.r + 1}`;
|
||||
const wallKey = (w: Wall) => `${w.o}:${w.c},${w.r}`;
|
||||
/** 가로·세로는 보는 사람 화면 기준(서·동 자리는 판이 90° 돌아가 있음). */
|
||||
const wallText = (w: Wall, swap = false) => `${(w.o === 'h') !== swap ? '가로' : '세로'} 벽 ${label(w)}`;
|
||||
const swapFor = (seat: Seat | null) => seat === 'W' || seat === 'E';
|
||||
|
||||
const CSS = `
|
||||
.qd-pawn { transition: transform 220ms ease-out; }
|
||||
.qd-wall-new { animation: qd-drop 260ms ease-out both; transform-box: fill-box; transform-origin: center; }
|
||||
.qd-turn-ring { animation: qd-pulse 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes qd-drop { 0% { transform: scale(1.35); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
@keyframes qd-pulse { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.35; } }
|
||||
.qd-board * { pointer-events: none; }
|
||||
.qd-board [data-cell], .qd-board [data-anchor] { pointer-events: auto; }
|
||||
.qd-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.qd-modes .btn { min-height: 52px; padding-inline: 8px; white-space: nowrap; font-size: 1rem; }
|
||||
.qd-modes .btn[aria-pressed='true'] { box-shadow: inset 0 0 0 3px var(--focus); }
|
||||
.qd-tray { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
|
||||
.qd-tray .btn { min-height: 48px; }
|
||||
.qd-hint { flex: 1 1 100%; min-width: 0; font-weight: 700; }
|
||||
.qd-tray .qd-confirm { flex: 2 1 0; }
|
||||
.qd-tray .qd-rotate { flex: 1 1 0; }
|
||||
.qd-hint.bad { color: var(--danger); }
|
||||
.qd-bars { display: inline-flex; gap: 2px; align-items: flex-end; }
|
||||
.qd-bars i { display: inline-block; width: 4px; height: 16px; border-radius: 2px; background: var(--warning); }
|
||||
.qd-bars i.used { background: var(--border); opacity: 0.6; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qd-pawn { transition: none; }
|
||||
.qd-wall-new, .qd-turn-ring { animation: none; }
|
||||
}`;
|
||||
|
||||
/** 판 돌리기: 모델 좌표(x 오른쪽, y 위쪽, 0~9) ↔ 화면 좌표(y 아래쪽). 네 가지 모두 자기 자신이 역변환. */
|
||||
function makeTf(seat: Seat | null) {
|
||||
const s = seat ?? 'S';
|
||||
const tf = (x: number, y: number): [number, number] =>
|
||||
s === 'S' ? [x, N - y] : s === 'N' ? [N - x, y] : s === 'W' ? [N - y, N - x] : [y, x];
|
||||
const swap = s === 'W' || s === 'E';
|
||||
return {
|
||||
swap,
|
||||
cellToScreen: (p: Cell): Pt => {
|
||||
const [x, y] = tf(p.c + 0.5, p.r + 0.5);
|
||||
return { x: Math.floor(x), y: Math.floor(y) };
|
||||
},
|
||||
screenToCell: (p: Pt): Cell => {
|
||||
const [x, y] = tf(p.x + 0.5, p.y + 0.5);
|
||||
return { c: Math.floor(x), r: Math.floor(y) };
|
||||
},
|
||||
/** 화면 앵커(0~7)와 화면 방향. */
|
||||
wallToScreen: (w: Wall): { a: Pt; o: O } => {
|
||||
const [x, y] = tf(w.c + 1, w.r + 1);
|
||||
return { a: { x: Math.round(x) - 1, y: Math.round(y) - 1 }, o: swap ? (w.o === 'h' ? 'v' : 'h') : w.o };
|
||||
},
|
||||
screenToWall: (a: Pt, o: O): Wall => {
|
||||
const [x, y] = tf(a.x + 1, a.y + 1);
|
||||
return { c: Math.round(x) - 1, r: Math.round(y) - 1, o: swap ? (o === 'h' ? 'v' : 'h') : o };
|
||||
},
|
||||
goalSide: (seat: Seat): Side => {
|
||||
const g: Record<Seat, [number, number]> = { S: [4.5, 9], N: [4.5, 0], W: [9, 4.5], E: [0, 4.5] };
|
||||
const [x, y] = tf(...g[seat]);
|
||||
return y < 0.5 ? 'top' : y > 8.5 ? 'bottom' : x < 0.5 ? 'left' : 'right';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function overlapsAny(walls: Wall[], w: Wall): boolean {
|
||||
const has = (o: O, c: number, r: number) => walls.some((x) => x.o === o && x.c === c && x.r === r);
|
||||
if (w.o === 'h') return has('h', w.c - 1, w.r) || has('h', w.c, w.r) || has('h', w.c + 1, w.r) || has('v', w.c, w.r);
|
||||
return has('v', w.c, w.r - 1) || has('v', w.c, w.r) || has('v', w.c, w.r + 1) || has('h', w.c, w.r);
|
||||
}
|
||||
|
||||
function wallRect(a: Pt, o: O) {
|
||||
return o === 'h' ? { x: a.x + 0.05, y: a.y + 1 - G / 2, width: 2 - 0.1, height: G } : { x: a.x + 1 - G / 2, y: a.y + 0.05, width: G, height: 2 - 0.1 };
|
||||
}
|
||||
|
||||
function moveText(m: QuoridorMove, swap = false): string {
|
||||
if (m.kind === 'move') return `${SEAT_KO[m.seat]} ${label(m.to)}${m.auto ? '(자동)' : ''}`;
|
||||
if (m.kind === 'wall') return `${SEAT_KO[m.seat]} ${wallText(m.wall, swap)}`;
|
||||
return `${SEAT_KO[m.seat]} 쉼`;
|
||||
}
|
||||
|
||||
function PawnMark({ seat, cx, cy, r = 0.34, ghost }: { seat: Seat; cx: number; cy: number; r?: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
{!ghost && <circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={0.25} />}
|
||||
<circle cx={cx} cy={cy} r={r} fill={SEAT_COLOR[seat]} stroke="#fff" strokeWidth={0.05} />
|
||||
<text x={cx} y={cy + 0.01} fontSize={r * 0.95} fontWeight={800} fill="#fff" textAnchor="middle" dominantBaseline="central">
|
||||
{SEAT_KO[seat]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
type Mode = 'move' | 'wall';
|
||||
|
||||
function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps<QuoridorView>) {
|
||||
const me = v.seats.find((x) => x.seat === v.mySeat) ?? null;
|
||||
const myTurn = v.phase === 'playing' && !!me && v.turn === me.seat && v.legalPawnMoves !== null;
|
||||
const t = useMemo(() => makeTf(v.mySeat), [v.mySeat]);
|
||||
const [mode, setMode] = useState<Mode>('move');
|
||||
const [orient, setOrient] = useState<O>('h');
|
||||
const [wallAt, setWallAt] = useState<Pt | null>(null); // 화면 앵커(고정된 미리보기)
|
||||
const [hoverAnchor, setHoverAnchor] = useState<Pt | null>(null);
|
||||
const [hoverCell, setHoverCell] = useState<Pt | null>(null);
|
||||
const [movePreview, setMovePreview] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: 4, y: 8 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevWalls = useRef(v.walls.length);
|
||||
const [newWall, setNewWall] = useState<string | null>(null);
|
||||
|
||||
const legalCells = useMemo(() => {
|
||||
const m = new Set<string>();
|
||||
for (const c of v.legalPawnMoves ?? []) {
|
||||
const p = t.cellToScreen(c);
|
||||
m.add(`${p.x},${p.y}`);
|
||||
}
|
||||
return m;
|
||||
}, [v.legalPawnMoves, t]);
|
||||
const legalWalls = useMemo(() => {
|
||||
const m = new Map<string, QuoridorLegalWall>();
|
||||
for (const w of v.legalWalls ?? []) m.set(wallKey(w), w);
|
||||
return m;
|
||||
}, [v.legalWalls]);
|
||||
|
||||
// 새 수: 소리·진동·스크린리더 안내, 미리보기 지우기, 차례가 오면 커서를 내 말로.
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevCount.current;
|
||||
prevCount.current = v.moveCount;
|
||||
setMovePreview(null);
|
||||
setWallAt(null);
|
||||
if (v.walls.length > prevWalls.current) setNewWall(wallKey(v.walls[v.walls.length - 1]!));
|
||||
else setNewWall(null);
|
||||
prevWalls.current = v.walls.length;
|
||||
if (v.moveCount <= prev) return;
|
||||
const added = v.moves.slice(prev);
|
||||
const texts = added.map((m) => (m.kind === 'pass' ? `${SEAT_KO[m.seat]}은 움직일 곳이 없어 차례를 넘겼어요.` : `${moveText(m, t.swap)}.`));
|
||||
for (const m of added) if (m.kind === 'pass' && m.seat === v.mySeat) toast('움직일 곳도 세울 벽도 없어서 차례를 넘겼어요');
|
||||
setAnnounce(texts.join(' '));
|
||||
if (added.some((m) => m.kind !== 'pass')) sounds.stone();
|
||||
if (v.phase === 'playing' && v.mySeat === v.turn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (myTurn && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||
if (!myTurn) setMode('move');
|
||||
}, [myTurn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const wallsLeft = me?.wallsLeft ?? 0;
|
||||
const canWall = myTurn && wallsLeft > 0;
|
||||
|
||||
/** 화면 앵커·방향의 벽에 대한 판정: 합법이면 정보, 아니면 이유. */
|
||||
const judge = (a: Pt, o: O): { wall: Wall; legal: QuoridorLegalWall | null; why: string | null } => {
|
||||
const wall = t.screenToWall(a, o);
|
||||
if (wallsLeft <= 0) return { wall, legal: null, why: '남은 벽이 없어요.' };
|
||||
const legal = legalWalls.get(wallKey(wall)) ?? null;
|
||||
if (legal) return { wall, legal, why: null };
|
||||
return { wall, legal: null, why: overlapsAny(v.walls, wall) ? '다른 벽과 겹쳐요.' : '누군가의 길을 완전히 막을 수 없어요.' };
|
||||
};
|
||||
|
||||
const doMove = (p: Pt) => {
|
||||
act({ type: 'move', to: t.screenToCell(p) });
|
||||
setMovePreview(null);
|
||||
setHoverCell(null);
|
||||
};
|
||||
const doWall = (a: Pt) => {
|
||||
const j = judge(a, orient);
|
||||
if (j.why) {
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(j.why);
|
||||
toast(j.why);
|
||||
return;
|
||||
}
|
||||
act({ type: 'placeWall', ...j.wall });
|
||||
setWallAt(null);
|
||||
setHoverAnchor(null);
|
||||
};
|
||||
const refuseCell = (p: Pt) => {
|
||||
const c = t.screenToCell(p);
|
||||
const occupied = v.seats.some((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||
const msg = occupied ? '말이 있는 칸이에요.' : '그 칸으로는 갈 수 없어요. 밝게 표시된 칸으로 움직일 수 있어요.';
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(msg);
|
||||
toast(msg);
|
||||
};
|
||||
|
||||
const tapCell = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (!legalCells.has(`${p.x},${p.y}`)) {
|
||||
refuseCell(p);
|
||||
return;
|
||||
}
|
||||
if (mouse || !tapConfirm || (movePreview && movePreview.x === p.x && movePreview.y === p.y)) {
|
||||
if (!pending) doMove(p);
|
||||
return;
|
||||
}
|
||||
setMovePreview(p);
|
||||
};
|
||||
const tapAnchor = (a: Pt) => {
|
||||
if (!canWall) return;
|
||||
if (wallAt && wallAt.x === a.x && wallAt.y === a.y) {
|
||||
setOrient((o) => (o === 'h' ? 'v' : 'h'));
|
||||
return;
|
||||
}
|
||||
setWallAt(a);
|
||||
setCursor(a);
|
||||
};
|
||||
|
||||
const pointer = (e: React.PointerEvent<SVGSVGElement>): { x: number; y: number } | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
return { x: loc.x, y: loc.y };
|
||||
};
|
||||
const toCell = (p: { x: number; y: number }): Pt | null => {
|
||||
const x = Math.floor(p.x);
|
||||
const y = Math.floor(p.y);
|
||||
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
|
||||
};
|
||||
/** 가장 가까운 교차점으로 스냅. */
|
||||
const toAnchor = (p: { x: number; y: number }): Pt | null => {
|
||||
if (p.x < -0.3 || p.y < -0.3 || p.x > N + 0.3 || p.y > N + 0.3) return null;
|
||||
return { x: Math.min(7, Math.max(0, Math.round(p.x) - 1)), y: Math.min(7, Math.max(0, Math.round(p.y) - 1)) };
|
||||
};
|
||||
|
||||
const setModeTo = (m: Mode) => {
|
||||
setMode(m);
|
||||
setMovePreview(null);
|
||||
setWallAt(null);
|
||||
if (m === 'wall' && me?.pos) {
|
||||
const p = t.cellToScreen(me.pos);
|
||||
// 내 말 바로 위쪽 교차점에서 시작(상대 쪽으로 세우기 쉽게).
|
||||
setCursor({ x: Math.min(7, Math.max(0, p.x - 1)), y: Math.min(7, Math.max(0, p.y - 2)) });
|
||||
} else if (m === 'move' && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||
setAnnounce(m === 'wall' ? '벽 세우기: 방향키로 자리를 고르고 R로 방향을 바꾼 뒤 Enter로 세워요.' : '말 움직이기: 방향키로 칸을 고르고 Enter로 움직여요.');
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
const max = mode === 'wall' ? 7 : 8;
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
const next = { x: Math.min(max, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(max, Math.max(0, cursor.y + d[e.key]![1])) };
|
||||
setCursor(next);
|
||||
if (mode === 'wall') {
|
||||
setWallAt(next);
|
||||
const j = judge(next, orient);
|
||||
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||
} else {
|
||||
const c = t.screenToCell(next);
|
||||
const who = v.seats.find((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||
setAnnounce(`${label(c)} ${who ? `${SEAT_KO[who.seat]} 말` : legalCells.has(`${next.x},${next.y}`) ? '갈 수 있음' : '빈칸'}`);
|
||||
}
|
||||
} else if ((e.key === 'r' || e.key === 'R') && mode === 'wall') {
|
||||
e.preventDefault();
|
||||
const o = orient === 'h' ? 'v' : 'h';
|
||||
setOrient(o);
|
||||
setWallAt(cursor);
|
||||
const j = judge(cursor, o);
|
||||
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||
} else if (e.key === 'w' || e.key === 'W') {
|
||||
e.preventDefault();
|
||||
if (canWall) setModeTo(mode === 'wall' ? 'move' : 'wall');
|
||||
} else if (e.key === 'Escape') {
|
||||
setWallAt(null);
|
||||
setMovePreview(null);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!myTurn || pending) return;
|
||||
if (mode === 'wall') doWall(cursor);
|
||||
else if (legalCells.has(`${cursor.x},${cursor.y}`)) doMove(cursor);
|
||||
else refuseCell(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
// 미리보기 대상(고정 > 마우스 올림 > 키보드 커서).
|
||||
const previewAnchor = mode === 'wall' && canWall ? (wallAt ?? hoverAnchor ?? (kbd ? cursor : null)) : null;
|
||||
const preview = previewAnchor ? judge(previewAnchor, orient) : null;
|
||||
const ghostCell = mode === 'move' && myTurn ? (movePreview ?? (hoverCell && legalCells.has(`${hoverCell.x},${hoverCell.y}`) ? hoverCell : null)) : null;
|
||||
const path = myTurn && v.showPath && v.myShortestPath && me?.pos ? [me.pos, ...v.myShortestPath].map((c) => t.cellToScreen(c)) : null;
|
||||
const goalSides = v.seats.filter((x) => x.status === 'playing' || (x.status === 'finished' && v.phase === 'finished')).map((x) => ({ seat: x.seat, side: t.goalSide(x.seat) }));
|
||||
const nick = (id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const lastWall = v.lastMove?.kind === 'wall' ? wallKey(v.lastMove.wall) : null;
|
||||
const lastTo = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.to) : null;
|
||||
const lastFrom = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.from) : null;
|
||||
const myGoal = me ? SIDE_KO[t.goalSide(me.seat)] : null;
|
||||
|
||||
const band = (side: Side, k: number) => {
|
||||
const w = 0.16;
|
||||
const off = -0.1 - w - k * (w + 0.04);
|
||||
if (side === 'top') return { x: 0, y: off, width: N, height: w };
|
||||
if (side === 'bottom') return { x: 0, y: N - off - w, width: N, height: w };
|
||||
if (side === 'left') return { x: off, y: 0, width: w, height: N };
|
||||
return { x: N - off - w, y: 0, width: w, height: N };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10, maxWidth: 'min(100%, calc(100vh - 340px))', minWidth: 'min(100%, 300px)' }}>
|
||||
<style>{CSS}</style>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="qd-board"
|
||||
viewBox={`${-PAD} ${-PAD} ${N + 2 * PAD} ${N + 2 * PAD}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
data-my-seat={v.mySeat ?? ''}
|
||||
data-mode={mode}
|
||||
data-path={path && v.myShortestPath ? v.myShortestPath.map(label).join(' ') : ''}
|
||||
data-preview={preview ? `${preview.wall.o}:${label(preview.wall)}${preview.why ? ':bad' : ''}` : ''}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const p = pointer(e);
|
||||
if (!p) return;
|
||||
if (mode === 'wall') {
|
||||
const a = toAnchor(p);
|
||||
if (a?.x !== hoverAnchor?.x || a?.y !== hoverAnchor?.y) setHoverAnchor(a);
|
||||
} else {
|
||||
const c = toCell(p);
|
||||
if (c?.x !== hoverCell?.x || c?.y !== hoverCell?.y) setHoverCell(c);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHoverAnchor(null);
|
||||
setHoverCell(null);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointer(e);
|
||||
if (!p) return;
|
||||
if (mode === 'wall') {
|
||||
const a = toAnchor(p);
|
||||
if (a) tapAnchor(a);
|
||||
} else {
|
||||
const c = toCell(p);
|
||||
if (c) tapCell(c, e.pointerType === 'mouse');
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{goalSides.map((g, k) => {
|
||||
const sameSide = goalSides.filter((x, j) => j < k && x.side === g.side).length;
|
||||
return <rect key={`g${g.seat}`} {...band(g.side, sameSide)} rx={0.06} fill={SEAT_COLOR[g.seat]} opacity={0.9} />;
|
||||
})}
|
||||
{Array.from({ length: N }, (_, i) => {
|
||||
const bottom = t.screenToCell({ x: i, y: N - 1 });
|
||||
const left = t.screenToCell({ x: 0, y: i });
|
||||
return (
|
||||
<g key={`l${i}`} fill="var(--wood-line)" fontSize={0.28} fontWeight={700} textAnchor="middle" dominantBaseline="central" aria-hidden>
|
||||
<text x={i + 0.5} y={N + PAD - 0.2}>
|
||||
{t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]}
|
||||
</text>
|
||||
<text x={-PAD + 0.2} y={i + 0.5}>
|
||||
{t.swap ? 'abcdefghi'[left.c] : left.r + 1}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: N * N }, (_, i) => {
|
||||
const x = i % N;
|
||||
const y = Math.floor(i / N);
|
||||
const legal = myTurn && mode === 'move' && legalCells.has(`${x},${y}`);
|
||||
return (
|
||||
<rect
|
||||
key={`c${i}`}
|
||||
x={x + G / 2}
|
||||
y={y + G / 2}
|
||||
width={1 - G}
|
||||
height={1 - G}
|
||||
rx={0.08}
|
||||
fill={legal ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.32)'}
|
||||
stroke={legal ? 'var(--primary)' : 'none'}
|
||||
strokeWidth={legal ? 0.06 : 0}
|
||||
data-cell={label(t.screenToCell({ x, y }))}
|
||||
data-legal={legal ? '1' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{lastFrom && <rect x={lastFrom.x + G / 2 + 0.05} y={lastFrom.y + G / 2 + 0.05} width={1 - G - 0.1} height={1 - G - 0.1} rx={0.08} fill="none" stroke="var(--wood-line)" strokeWidth={0.04} strokeDasharray="0.1 0.08" />}
|
||||
{path && path.length > 1 && (
|
||||
<polyline
|
||||
points={path.map((p) => `${p.x + 0.5},${p.y + 0.5}`).join(' ')}
|
||||
fill="none"
|
||||
stroke={SEAT_COLOR[me!.seat]}
|
||||
strokeWidth={0.07}
|
||||
strokeDasharray="0.14 0.12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.75}
|
||||
/>
|
||||
)}
|
||||
{myTurn &&
|
||||
mode === 'move' &&
|
||||
[...legalCells].map((k) => {
|
||||
const [x, y] = k.split(',').map(Number) as [number, number];
|
||||
return <circle key={`d${k}`} cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" opacity={0.8} />;
|
||||
})}
|
||||
{v.walls.map((w) => {
|
||||
const s = t.wallToScreen(w);
|
||||
const k = wallKey(w);
|
||||
return (
|
||||
<rect
|
||||
key={k}
|
||||
className={k === newWall ? 'qd-wall-new' : undefined}
|
||||
{...wallRect(s.a, s.o)}
|
||||
rx={0.05}
|
||||
fill="#3b2412"
|
||||
stroke={k === lastWall ? 'var(--focus)' : '#f3e2c0'}
|
||||
strokeWidth={k === lastWall ? 0.05 : 0.025}
|
||||
data-wall={`${w.o}:${label(w)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{v.seats.map((x) => {
|
||||
if (!x.pos) return null;
|
||||
const p = t.cellToScreen(x.pos);
|
||||
const turn = v.phase === 'playing' && v.turn === x.seat;
|
||||
return (
|
||||
<g key={x.seat} className="qd-pawn" style={{ transform: `translate(${p.x + 0.5}px, ${p.y + 0.5}px)` }} data-pawn={x.seat} data-at={label(x.pos)}>
|
||||
{turn && <circle className="qd-turn-ring" cx={0} cy={0} r={0.45} fill="none" stroke={SEAT_COLOR[x.seat]} strokeWidth={0.06} />}
|
||||
<PawnMark seat={x.seat} cx={0} cy={0} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{lastTo && <circle cx={lastTo.x + 0.5 + 0.27} cy={lastTo.y + 0.5 - 0.27} r={0.08} fill="var(--focus)" stroke="#fff" strokeWidth={0.02} />}
|
||||
{ghostCell && me && <PawnMark seat={me.seat} cx={ghostCell.x + 0.5} cy={ghostCell.y + 0.5} ghost />}
|
||||
{preview && previewAnchor && (
|
||||
<rect
|
||||
{...wallRect(previewAnchor, orient)}
|
||||
rx={0.05}
|
||||
fill={preview.why ? 'rgba(220, 38, 38, 0.6)' : SEAT_COLOR[me!.seat]}
|
||||
opacity={preview.why ? 1 : 0.6}
|
||||
stroke={preview.why ? '#7f1d1d' : '#fff'}
|
||||
strokeWidth={0.04}
|
||||
strokeDasharray={wallAt ? undefined : '0.12 0.08'}
|
||||
/>
|
||||
)}
|
||||
{myTurn && mode === 'wall' && (
|
||||
<g aria-hidden>
|
||||
{Array.from({ length: 64 }, (_, i) => {
|
||||
const a = { x: i % 8, y: Math.floor(i / 8) };
|
||||
const w = t.screenToWall(a, 'h');
|
||||
return <circle key={`a${i}`} cx={a.x + 1} cy={a.y + 1} r={0.09} fill="var(--wood-line)" opacity={0.35} data-anchor={label(w)} />;
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
{myTurn && kbd && mode === 'move' && (
|
||||
<rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.1} fill="none" stroke="var(--focus)" strokeWidth={0.07} />
|
||||
)}
|
||||
{myTurn && kbd && mode === 'wall' && <circle cx={cursor.x + 1} cy={cursor.y + 1} r={0.16} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{me && v.phase === 'playing' && me.status === 'playing' && (
|
||||
<div className="qd-modes" role="group" aria-label="할 일 고르기">
|
||||
<button type="button" className={`btn ${mode === 'move' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'move'} disabled={!myTurn} onClick={() => setModeTo('move')}>
|
||||
🚶 말 움직이기
|
||||
</button>
|
||||
<button type="button" className={`btn ${mode === 'wall' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'wall'} disabled={!canWall} onClick={() => setModeTo('wall')}>
|
||||
🧱 벽 세우기 ({wallsLeft}개)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{myTurn && mode === 'wall' && (
|
||||
<div className="qd-tray">
|
||||
<span className={`qd-hint small${preview?.why ? ' bad' : ''}`} role="status">
|
||||
{!preview
|
||||
? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.'
|
||||
: preview.why
|
||||
? `${wallText(preview.wall, t.swap)}: ${preview.why}`
|
||||
: `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary qd-rotate" onClick={() => {
|
||||
const o = orient === 'h' ? 'v' : 'h';
|
||||
setOrient(o);
|
||||
}} aria-label={`벽 방향 바꾸기, 지금 ${orient === 'h' ? '가로' : '세로'}`}>
|
||||
↻ {orient === 'h' ? '가로' : '세로'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary qd-confirm" disabled={!previewAnchor || !!preview?.why || pending} onClick={() => previewAnchor && doWall(previewAnchor)}>
|
||||
벽 세우기 확인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{myTurn && mode === 'move' && movePreview && (
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(movePreview)}>
|
||||
{label(t.screenToCell(movePreview))}으로 이동
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMovePreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{me.status === 'playing'
|
||||
? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.`
|
||||
: me.status === 'finished'
|
||||
? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.'
|
||||
: '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}
|
||||
</p>
|
||||
)}
|
||||
{!me && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function WallBars({ left, total }: { left: number; total: number }) {
|
||||
return (
|
||||
<span className="qd-bars" aria-label={`남은 벽 ${left}개`} role="img">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<i key={i} className={i < left ? undefined : 'used'} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const u = players[x.playerId] ?? { id: x.playerId, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === x.seat;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (x.status === 'playing' && tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[x.seat] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
|
||||
}
|
||||
const state =
|
||||
x.status === 'finished'
|
||||
? `${x.finishOrder}등 도착`
|
||||
: x.status === 'resigned'
|
||||
? '기권'
|
||||
: x.status === 'timedOut'
|
||||
? '시간패'
|
||||
: `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`;
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? SEAT_COLOR[x.seat] : 'transparent'}`, opacity: x.status === 'resigned' || x.status === 'timedOut' ? 0.6 : 1 }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||
<PawnMark seat={x.seat} cx={0} cy={0} r={0.45} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{x.playerId === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{COLOR_KO[x.seat]} · {state}
|
||||
</div>
|
||||
</div>
|
||||
<WallBars left={x.wallsLeft} total={v.wallsPerPlayer} />
|
||||
<span className="small" style={{ fontWeight: 800, minWidth: '2ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||
{x.wallsLeft}
|
||||
</span>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps<QuoridorView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||
const live = v.phase === 'playing' && mine?.status === 'playing';
|
||||
const incoming =
|
||||
live && v.mySeat
|
||||
? v.undo.pending && v.undo.pending.by !== v.mySeat && !v.undo.pending.accepted.includes(v.mySeat)
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.mySeat && !v.draw.accepted.includes(v.mySeat)
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const waitingOthers = live && v.mySeat && ((v.undo.pending && (v.undo.pending.by === v.mySeat || v.undo.pending.accepted.includes(v.mySeat))) || (v.draw.pendingBy && (v.draw.pendingBy === v.mySeat || v.draw.accepted.includes(v.mySeat))));
|
||||
const outgoing = !!v.mySeat && (v.undo.pending?.by === v.mySeat || v.draw.pendingBy === v.mySeat);
|
||||
const undoLeft = v.mySeat ? v.undo.remaining[v.mySeat] : null;
|
||||
const requester = v.undo.pending?.by ?? v.draw.pendingBy;
|
||||
// 내 자리가 맨 아래, 나머지는 차례 순서대로 위에.
|
||||
const order = mine ? [...v.seats.filter((x) => x.seat !== mine.seat), mine] : v.seats;
|
||||
const four = v.seats.length === 4;
|
||||
return (
|
||||
<>
|
||||
{order.map((x) => (
|
||||
<PlayerRow key={x.seat} v={v} x={x} players={players} deadline={deadline} me={me} />
|
||||
))}
|
||||
{live && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: v.undo.allowed && v.draw.allowed ? '1fr 1fr 1fr' : '1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!requester || undoLeft === 0 || !v.moves.some((m) => m.kind !== 'pass' && m.seat === v.mySeat)} onClick={() => act({ type: 'requestUndo' })} title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!requester} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{waitingOthers && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">다른 사람의 답을 기다리는 중…</span>
|
||||
{outgoing && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && requester && (
|
||||
<Sheet onClose={() => {}} label="다른 사람의 요청">
|
||||
<h2>{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}</h2>
|
||||
<p className="muted">
|
||||
{incoming === 'undo'
|
||||
? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}`
|
||||
: `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body={four ? '기권하면 내 말이 판에서 빠지고, 남은 벽은 사라져요.' : '기권하면 이 판은 상대가 이겨요.'}
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'resign' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: QuoridorView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
let n = 0;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className={`small${m.kind === 'pass' ? ' muted' : ''}`}>
|
||||
{m.kind !== 'pass' && <span className="muted">{++n}. </span>}
|
||||
<span style={{ color: SEAT_COLOR[m.seat], fontWeight: 800 }} aria-hidden>
|
||||
●{' '}
|
||||
</span>
|
||||
{moveText(m, swapFor(v.mySeat))}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: QuoridorView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summaryKo ?? '게임이 끝났어요';
|
||||
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||
if (mine && mine.status === 'playing') {
|
||||
if (v.undo.pending && v.undo.pending.by !== mine.seat && !v.undo.pending.accepted.includes(mine.seat)) return '무르기 요청이 왔어요';
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== mine.seat && !v.draw.accepted.includes(mine.seat)) return '무승부 제안이 왔어요';
|
||||
}
|
||||
if (mine && v.turn === mine.seat && mine.status === 'playing') {
|
||||
return mine.wallsLeft > 0 ? '내 차례예요. 말을 움직이거나 벽을 세워요' : '내 차례예요. 남은 벽이 없어서 말을 움직여요';
|
||||
}
|
||||
const turnSeat = v.seats.find((x) => x.seat === v.turn)!;
|
||||
const nick = players[turnSeat.playerId]?.nickname ?? SEAT_KO[v.turn];
|
||||
if (mine?.status === 'finished') return `도착했어요! ${nick}님 차례예요`;
|
||||
return mine ? `${nick}님(${COLOR_KO[v.turn]})이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={aria}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label: title, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{title}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<QuoridorOptions['timeControl']>][] = [
|
||||
['pm15', '한 수 15초', { kind: 'perMove', perMoveSec: 15 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: QuoridorOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function timeLabel(tc: QuoridorOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<QuoridorOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<QuoridorOptions>) => onChange({ ...o, ...patch });
|
||||
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||
const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl));
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="시간">
|
||||
<Seg
|
||||
label="시간"
|
||||
value={timeKey(o.timeControl)}
|
||||
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(known ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]])]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="2명일 때 1인당 벽 수">
|
||||
<Seg
|
||||
label="2명일 때 1인당 벽 수"
|
||||
value={o.wallsPerPlayer}
|
||||
options={[...([10, 8, 6] as const).map((n) => [n, n === 10 ? '10개(기본)' : `${n}개(빠른 게임)`] as [number, string]), ...([10, 8, 6].includes(o.wallsPerPlayer) ? [] : [[o.wallsPerPlayer, `${o.wallsPerPlayer}개`] as [number, string]])]}
|
||||
onChange={(wallsPerPlayer) => set({ wallsPerPlayer })}
|
||||
/>
|
||||
<span className="small muted">4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요.</span>
|
||||
</Field>
|
||||
<Field label="내 최단 길 힌트">
|
||||
<Seg label="내 최단 길 힌트" value={onOff(o.showPath)} options={[['on', '점선으로 보여 주기'], ['off', '안 보여 주기']]} onChange={(k) => set({ showPath: k === 'on' })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="대각선으로 넘어갈 수 있는 때">
|
||||
<Seg
|
||||
label="대각선으로 넘어갈 수 있는 때"
|
||||
value={o.diagonalJumpTrigger}
|
||||
options={[
|
||||
['wallEdgePawn', '말 뒤가 벽·판 끝·다른 말'],
|
||||
['wallOnly', '말 뒤가 벽일 때만'],
|
||||
]}
|
||||
onChange={(diagonalJumpTrigger) => set({ diagonalJumpTrigger })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="시작 자리">
|
||||
<Seg label="시작 자리" value={o.seatOrder} options={[['random', '무작위'], ['joinOrder', '들어온 순서']]} onChange={(seatOrder) => set({ seatOrder })} />
|
||||
</Field>
|
||||
<Field label="4명일 때 1등이 나오면">
|
||||
<Seg label="4명일 때 1등이 나오면" value={onOff(o.continueForRanking)} options={[['off', '바로 끝내기'], ['on', '계속해서 2·3등 가리기']]} onChange={(k) => set({ continueForRanking: k === 'on' })} />
|
||||
</Field>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<Field label="시간이 다 되면">
|
||||
<Seg label="시간이 다 되면" value={o.timeoutPolicy} options={[['autoMove', '최단 길로 한 칸 자동 이동'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="무르기">
|
||||
<Seg label="무르기" value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</Field>
|
||||
<Field label="무승부 제안">
|
||||
<Seg label="무승부 제안" value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: QuoridorOptions): string {
|
||||
return [timeLabel(o.timeControl), `벽 ${o.wallsPerPlayer}개(2명)`, ...(o.showPath ? ['길 힌트'] : []), ...(o.diagonalJumpTrigger === 'wallOnly' ? ['대각은 벽 뒤만'] : []), ...(o.continueForRanking ? ['순위 끝까지'] : [])].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
/** 규칙 그림: 5×5 조각판. pawns: [열, 행(위=0), 자리], walls: [앵커 열, 앵커 행, 방향], marks: 갈 수 있는 칸. */
|
||||
function Diagram({ pawns, walls = [], marks = [], caption }: { pawns: [number, number, Seat][]; walls?: [number, number, O, boolean?][]; marks?: [number, number][]; caption: string }) {
|
||||
const n = 5;
|
||||
return (
|
||||
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||
<svg viewBox={`-0.1 -0.1 ${n + 0.2} ${n + 0.2}`} style={{ width: '100%', maxWidth: 170, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 8 }} aria-hidden>
|
||||
{Array.from({ length: n * n }, (_, i) => (
|
||||
<rect key={i} x={(i % n) + G / 2} y={Math.floor(i / n) + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.35)" />
|
||||
))}
|
||||
{marks.map(([x, y]) => (
|
||||
<g key={`m${x}${y}`}>
|
||||
<rect x={x + G / 2} y={y + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.92)" stroke="var(--primary)" strokeWidth={0.06} />
|
||||
<circle cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" />
|
||||
</g>
|
||||
))}
|
||||
{walls.map(([x, y, o, red], i) => (
|
||||
<rect key={`w${i}`} {...wallRect({ x, y }, o)} rx={0.05} fill={red ? 'rgba(220, 38, 38, 0.7)' : '#3b2412'} stroke={red ? '#7f1d1d' : '#f3e2c0'} strokeWidth={0.03} strokeDasharray={red ? '0.12 0.08' : undefined} />
|
||||
))}
|
||||
{pawns.map(([x, y, s]) => (
|
||||
<PawnMark key={`p${x}${y}`} seat={s} cx={x + 0.5} cy={y + 0.5} />
|
||||
))}
|
||||
</svg>
|
||||
<figcaption className="small muted" style={{ marginTop: 4 }}>
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: QuoridorOptions }) {
|
||||
const wallOnly = options?.diagonalJumpTrigger === 'wallOnly';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
9×9 판에서 <strong>내 말을 반대편 끝줄까지 먼저 보내면</strong> 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.
|
||||
</p>
|
||||
<p>
|
||||
내 차례에는 <strong>말을 한 칸 움직이거나</strong>, <strong>벽을 하나 세워요</strong>(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>말 이동</strong>: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>뛰어넘기</strong>: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} marks={[[2, 1], [1, 3], [3, 3], [2, 4]]} caption="① 앞의 말을 곧게 뛰어넘기" />
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h']]} marks={[[1, 2], [3, 2], [1, 3], [3, 3], [2, 4]]} caption="② 뒤가 벽이면 대각선으로" />
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h'], [1, 1, 'v']]} marks={[[3, 2], [1, 3], [3, 3], [2, 4]]} caption="③ 한쪽도 벽이면 다른 쪽만" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>벽 세우기</strong>: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 <strong>누구의 길도 완전히 막으면 안 돼요</strong>. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h'], [3, 0, 'v', true]]} caption="④ 길을 완전히 막는 벽은 안 돼요" />
|
||||
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h']]} marks={[[3, 0]]} caption="⑤ 돌아갈 길이 남으면 괜찮아요" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>세우는 방법</strong>: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).
|
||||
</p>
|
||||
<p>
|
||||
<strong>이기는 방법</strong>: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.
|
||||
</p>
|
||||
<p className="muted">팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<QuoridorView, QuoridorOptions> = {
|
||||
Board: QuoridorBoard,
|
||||
Side: QuoridorSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.mySeat && v.seats.some((x) => x.seat === v.turn && x.playerId === me && x.status === 'playing'),
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -19,6 +19,12 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
bang: () => import('./bang'),
|
||||
'halli-galli': () => import('./halli-galli'),
|
||||
marble: () => import('./marble'),
|
||||
dalmuti: () => import('./dalmuti'),
|
||||
splendor: () => import('./splendor'),
|
||||
quoridor: () => import('./quoridor'),
|
||||
'las-vegas': () => import('./las-vegas'),
|
||||
'cockroach-poker': () => import('./cockroach-poker'),
|
||||
'ninety-nine': () => import('./ninety-nine'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
1238
apps/web/src/games/splendor/index.tsx
Normal file
1238
apps/web/src/games/splendor/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,13 @@ export const GAME_ICON: Record<string, string> = {
|
||||
marble: '🏙️',
|
||||
bang: '🤠',
|
||||
'halli-galli': '🔔',
|
||||
dalmuti: '👑',
|
||||
splendor: '💎',
|
||||
quoridor: '🧱',
|
||||
'las-vegas': '⚅',
|
||||
'cockroach-poker': '🐞',
|
||||
'ninety-nine': '💯',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['달무티', '보석 상인', '쿼리도', '라스베가스', '바퀴벌레 포커', '구십구', '도블', '그림 맞히기', '마피아'];
|
||||
export const UPCOMING = ['도블', '그림 맞히기', '마피아'];
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p));
|
||||
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'games/las-vegas', 'components/cards'].map((p) => join(import.meta.dir, p));
|
||||
const FORBIDDEN: [RegExp, string][] = [
|
||||
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
|
||||
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
## M6. 뱅 + 실시간 반응 + 땅따먹기
|
||||
- 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기.
|
||||
- **진행 결과(2026-10-05)**: 세 게임 + 할리갈리 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
|
||||
- **진행 결과(2026-10-05)**: 세 게임 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
|
||||
게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인).
|
||||
엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음).
|
||||
뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기.
|
||||
@@ -62,6 +62,12 @@
|
||||
|
||||
## M7. 보드게임카페 인기 게임
|
||||
- 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구.
|
||||
- **진행 결과(2026-10-05)**: 여섯 게임 규칙 + 화면 완료. 규칙 테스트 달무티 34·보석 상인 42(+무작위 대국 파일)·벽 쌓기 길찾기(쿼리도 방식) 41·
|
||||
라스베가스 32·바퀴벌레 포커 31·구십구 28, 게임마다 무작위 플레이 1,000판(구성물 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법).
|
||||
쿼리도는 상표 주의(§12)에 따라 화면 이름을 '벽 쌓기 길찾기'로 표시. 보석 상인 카드 수치는 `splendor/cards.ts`에 따로 두어 교체 가능.
|
||||
E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인),
|
||||
라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰).
|
||||
사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12).
|
||||
|
||||
## M8. 파티 게임
|
||||
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).
|
||||
|
||||
729
e2e/games.e2e.ts
729
e2e/games.e2e.ts
@@ -260,6 +260,387 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 달무티: one UI step for `p` — confirm the hand, give tax cards back (suggested cards), or on its turn pick a
|
||||
* number group (a "광대 필요" group first; leads mix a jester in via the toggle) and press "내기", passing now
|
||||
* and then. The host uses the keyboard (focus + Enter) for the group. Returns what it did, or null.
|
||||
*/
|
||||
async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>): Promise<string | null> {
|
||||
const enabled = async (l: ReturnType<Page['locator']>) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false));
|
||||
const confirm = p.getByRole('button', { name: '손패 확인', exact: true });
|
||||
if (await enabled(confirm)) {
|
||||
await confirm.click({ timeout: 1500 });
|
||||
return 'confirm';
|
||||
}
|
||||
const giveBack = p.getByRole('button', { name: /^돌려주기/ });
|
||||
if (await enabled(giveBack)) {
|
||||
if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) {
|
||||
st[`taxshot-${tag(p)}`] = 1;
|
||||
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) });
|
||||
await p.emulateMedia({ colorScheme: 'dark' });
|
||||
await p.waitForTimeout(400);
|
||||
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) });
|
||||
await p.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await giveBack.click({ timeout: 1500 });
|
||||
return 'return';
|
||||
}
|
||||
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
|
||||
if (!status.includes('내 차례예요')) return null;
|
||||
const pass = p.getByRole('button', { name: '패스', exact: true });
|
||||
const canPass = await enabled(pass);
|
||||
const legal = p.locator('button.dm-group[data-legal="true"]');
|
||||
const n = await legal.count();
|
||||
if (n === 0 || (canPass && st.turns! % 4 === 3)) {
|
||||
if (!canPass) return null;
|
||||
await pass.click({ timeout: 1500 });
|
||||
st.turns!++;
|
||||
st.passes = (st.passes ?? 0) + 1;
|
||||
return 'pass';
|
||||
}
|
||||
const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' });
|
||||
const lead = status.includes('새로 시작');
|
||||
const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]');
|
||||
let target = legal.last();
|
||||
if (await needs.count()) target = needs.last();
|
||||
else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2);
|
||||
if (isHost) {
|
||||
await target.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
st.hostKeys = (st.hostKeys ?? 0) + 1;
|
||||
} else await target.click({ timeout: 1500 });
|
||||
const toggle = p.locator('.dm-toggle');
|
||||
if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 });
|
||||
const play = p.getByRole('button', { name: /^내기 \(/ });
|
||||
if (!(await enabled(play))) return null;
|
||||
const summary = await p.locator('.dm-pick .small').innerText().catch(() => '');
|
||||
if (!st[`pickshot-${tag(p)}`]) {
|
||||
st[`pickshot-${tag(p)}`] = 1;
|
||||
await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) });
|
||||
}
|
||||
if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1;
|
||||
await play.click({ timeout: 1500 });
|
||||
st.turns!++;
|
||||
st.plays = (st.plays ?? 0) + 1;
|
||||
return 'play';
|
||||
}
|
||||
|
||||
/**
|
||||
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
|
||||
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
|
||||
* Returns the kind of action or null when it isn't this page's turn.
|
||||
*/
|
||||
async function splendorStep(p: Page, stats: Record<string, number>, turn: number): Promise<string | null> {
|
||||
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
|
||||
if (!status.startsWith('내 차례예요')) return null;
|
||||
const phone = (p.viewportSize()?.width ?? 0) < 600;
|
||||
const press = async (l: ReturnType<Page['locator']>) => {
|
||||
if (phone) await l.tap({ timeout: 2000 });
|
||||
else if (turn % 2 === 0) {
|
||||
// Keyboard path on PC every other turn: focus + Enter.
|
||||
await l.focus({ timeout: 2000 });
|
||||
await p.keyboard.press('Enter');
|
||||
} else await l.click({ timeout: 2000 });
|
||||
};
|
||||
const panel = p.locator('.sp-panel');
|
||||
if (status.includes('돌려놓아')) {
|
||||
const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0);
|
||||
for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first());
|
||||
await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` }));
|
||||
return 'discard';
|
||||
}
|
||||
if (status.includes('귀족 중 한 명')) {
|
||||
await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first());
|
||||
return 'noble';
|
||||
}
|
||||
if (status.includes('넘겨야')) {
|
||||
await press(panel.getByRole('button', { name: '차례 넘기기' }));
|
||||
const ok = p.getByRole('button', { name: '넘기기', exact: true });
|
||||
if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click();
|
||||
return 'pass';
|
||||
}
|
||||
const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold');
|
||||
const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2;
|
||||
// The phone player hoards gems early on (no buying) until it has had to give some back once.
|
||||
const hoard = phone && !stats.discard && turn < 30;
|
||||
if ((await affordable.count()) && !reserveTurn && !hoard) {
|
||||
const card = affordable.first();
|
||||
await press(card);
|
||||
if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) {
|
||||
stats.shotPay = 1;
|
||||
await p.waitForTimeout(250);
|
||||
await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone });
|
||||
}
|
||||
await press(panel.getByRole('button', { name: '🛒 사기' }));
|
||||
return 'buy';
|
||||
}
|
||||
const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ });
|
||||
if (reserveTurn) {
|
||||
// Tier 1 open card on even turns, the tier 2 deck (blind) otherwise.
|
||||
const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck');
|
||||
await press(target);
|
||||
if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) {
|
||||
await press(reserveBtn);
|
||||
return 'reserve';
|
||||
}
|
||||
await press(panel.getByRole('button', { name: '닫기' }));
|
||||
}
|
||||
const tokens = p.locator('.sp-bank button.sp-token:not([disabled])');
|
||||
const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0)));
|
||||
const four = counts.findIndex((n) => n >= 4);
|
||||
if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) {
|
||||
await press(tokens.nth(four));
|
||||
await press(tokens.nth(four));
|
||||
await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' }));
|
||||
return 'same';
|
||||
}
|
||||
const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3);
|
||||
let picked = 0;
|
||||
for (let i = 0; i < counts.length && picked < want; i++) {
|
||||
if (counts[i]! > 0) {
|
||||
await press(tokens.nth(i));
|
||||
picked++;
|
||||
}
|
||||
}
|
||||
const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ });
|
||||
if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) {
|
||||
await press(takeBtn);
|
||||
return 'different';
|
||||
}
|
||||
// Nothing to take: reserve whatever is first, or buy anything.
|
||||
await press(p.locator('.sp-market button.sp-card').first());
|
||||
if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) {
|
||||
await press(reserveBtn);
|
||||
return 'reserve';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
|
||||
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
|
||||
async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
|
||||
if (tag(p) === 'phone') await target.tap();
|
||||
else await target.click();
|
||||
}
|
||||
async function qdWaitTurn(p: Page) {
|
||||
await p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 15_000 });
|
||||
}
|
||||
/** 벽 세우기 모드에서 앵커를 고르고 방향을 맞춘 뒤 미리보기 값("v:d8" / "h:e7:bad")을 돌려준다. */
|
||||
async function qdPreviewWall(p: Page, anchor: string, o: 'h' | 'v'): Promise<string> {
|
||||
await qdWaitTurn(p);
|
||||
if ((await qdSvg(p).getAttribute('data-mode')) !== 'wall') await qdTap(p, p.getByRole('button', { name: /^🧱 벽 세우기/ }));
|
||||
await qdTap(p, p.locator(`[data-anchor="${anchor}"]`));
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const pv = (await qdSvg(p).getAttribute('data-preview')) ?? '';
|
||||
if (pv.startsWith(`${o}:${anchor}`)) return pv;
|
||||
await qdTap(p, p.getByRole('button', { name: /벽 방향 바꾸기/ }));
|
||||
}
|
||||
throw new Error(`wall preview did not become ${o}:${anchor}`);
|
||||
}
|
||||
async function qdConfirmWall(p: Page) {
|
||||
const before = await p.locator('[data-wall]').count();
|
||||
await qdTap(p, p.getByRole('button', { name: '벽 세우기 확인' }));
|
||||
await p.waitForFunction((n) => document.querySelectorAll('[data-wall]').length > n, before, { timeout: 5000 });
|
||||
}
|
||||
/** 내 최단 길(data-path)에서 가장 멀리 가는 이동 가능 칸으로 한 수. 점프였으면 true. */
|
||||
async function qdStep(p: Page, keyboard: boolean): Promise<boolean> {
|
||||
await qdWaitTurn(p);
|
||||
if ((await qdSvg(p).getAttribute('data-mode')) !== 'move') await qdTap(p, p.getByRole('button', { name: /말 움직이기/ }));
|
||||
const seat = await qdSvg(p).getAttribute('data-my-seat');
|
||||
const from = (await p.locator(`[data-pawn="${seat}"]`).getAttribute('data-at'))!;
|
||||
const legal = await p.locator('[data-legal]').evaluateAll((els) => els.map((e) => e.getAttribute('data-cell')!));
|
||||
const path = ((await qdSvg(p).getAttribute('data-path')) ?? '').split(' ');
|
||||
let to = legal[0]!;
|
||||
let best = -1;
|
||||
for (const c of legal) if (path.indexOf(c) > best) [best, to] = [path.indexOf(c), c];
|
||||
if (keyboard && to === path[0]) {
|
||||
// 커서는 내 말에서 시작하고 화면 위쪽이 항상 내 목표 방향.
|
||||
await keys(p, ['ArrowUp', 'Enter']);
|
||||
} else {
|
||||
await qdTap(p, p.locator(`[data-cell="${to}"]`));
|
||||
const confirm = p.getByRole('button', { name: /으로 이동$/ });
|
||||
if (await confirm.count()) await qdTap(p, confirm);
|
||||
}
|
||||
await p.waitForFunction((t) => document.querySelector(`[data-pawn] [data-at="${t}"], [data-at="${t}"]`) !== null, to, { timeout: 5000 });
|
||||
const d = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)) + Math.abs(Number(from.slice(1)) - Number(to.slice(1)));
|
||||
return d === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라스베가스: one UI step for `p` — press "주사위 굴리기", or pick a number group (every other pick prefers a group
|
||||
* marked "동점 주의" so duplicate-tie cancellations really happen) and confirm "N번 카지노에 M개 놓기".
|
||||
* The PC host's first pick uses the keyboard, its second one taps the casino card instead of the group.
|
||||
*/
|
||||
async function lasVegasStep(
|
||||
p: Page,
|
||||
phone: boolean,
|
||||
st: { rolls: number; places: number; keyboard: boolean; casinoPick: boolean; shots: Set<string> },
|
||||
): Promise<boolean> {
|
||||
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap({ timeout: 1500 }) : l.click({ timeout: 1500 }));
|
||||
const roll = p.getByRole('button', { name: /^🎲 주사위 굴리기/ });
|
||||
if ((await roll.count()) && (await roll.isEnabled().catch(() => false))) {
|
||||
if (!(await press(roll).then(() => true, () => false))) return false;
|
||||
st.rolls++;
|
||||
return true;
|
||||
}
|
||||
const groups = p.locator('.lv-group:not([disabled])');
|
||||
if (!(await groups.count())) return false;
|
||||
try {
|
||||
const tie = p.locator('.lv-group:not([disabled])[data-standing="tie"]');
|
||||
const target = st.places % 2 === 1 && (await tie.count()) ? tie.first() : groups.first();
|
||||
const confirm = p.getByRole('button', { name: /번 카지노에 \d+개 놓기$/ });
|
||||
if (!phone && !st.keyboard) {
|
||||
// Keyboard only: Enter on a number group, then Enter on the confirm button.
|
||||
await target.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
await confirm.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
st.keyboard = true;
|
||||
} else if (!phone && !st.casinoPick) {
|
||||
// Choose by tapping the highlighted casino card instead of the group button.
|
||||
await p.locator('button.lv-casino').first().click({ timeout: 1500 });
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
st.casinoPick = true;
|
||||
await confirm.click({ timeout: 1500 });
|
||||
} else {
|
||||
await press(target);
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
const tag = phone ? 'phone' : 'pc';
|
||||
if (st.places >= 6 && !st.shots.has(tag)) {
|
||||
// Mid-game: a number group chosen, casino highlighted with its preview tag, light + dark.
|
||||
st.shots.add(tag);
|
||||
if (phone) await p.evaluate(() => window.scrollTo(0, 0));
|
||||
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}.png`) });
|
||||
if (phone) await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-full.png`), fullPage: true });
|
||||
await p.emulateMedia({ colorScheme: 'dark' });
|
||||
await p.waitForTimeout(250);
|
||||
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-dark.png`) });
|
||||
await p.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await press(confirm);
|
||||
}
|
||||
st.places++;
|
||||
await p.waitForFunction(() => !document.querySelector('.lv-group[aria-pressed="true"]'), null, { timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바퀴벌레 포커: one UI step for whoever must act on `p` — offer (card → person → claim → confirm), pass after
|
||||
* peeking (person → claim → confirm) or answer an offer (진짜!/거짓말!, sometimes 몰래 보고 넘기기).
|
||||
*/
|
||||
async function cockroachStep(p: Page, stats: Record<string, number>): Promise<string | null> {
|
||||
const status = await p.locator('.turn-status').innerText().catch(() => '');
|
||||
if (!status.includes('내 차례예요')) return null;
|
||||
const panel = p.locator('.cp-panel').first();
|
||||
const offering = (await panel.getByText('카드를 건네 주세요', { exact: true }).count()) > 0;
|
||||
if (offering || (await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요', { exact: true }).count())) {
|
||||
const give = panel.getByRole('button', { name: /(건네기|넘기기)$/ });
|
||||
const n = (stats.offer ?? 0) + (stats.pass ?? 0);
|
||||
if (offering) {
|
||||
const cards = panel.locator('.cp-hand button.cp-pick');
|
||||
await cards.nth(n % (await cards.count())).click();
|
||||
}
|
||||
const targets = panel.locator('button.cp-target:not([disabled])');
|
||||
await targets.nth(n % (await targets.count())).click();
|
||||
await panel.locator('button.cp-claim').nth((n * 3) % 8).click();
|
||||
if (!stats[`shot-give-${tag(p)}`]) {
|
||||
stats[`shot-give-${tag(p)}`] = 1;
|
||||
await p.screenshot({ path: join(SHOTS, `cockroach-poker-give-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
|
||||
}
|
||||
await give.click({ timeout: 2000 });
|
||||
stats[offering ? 'offer' : 'pass'] = (stats[offering ? 'offer' : 'pass'] ?? 0) + 1;
|
||||
return offering ? 'offer' : 'pass';
|
||||
}
|
||||
const yes = panel.getByRole('button', { name: /^진짜!/ });
|
||||
if (await yes.count()) {
|
||||
if (!stats[`shot-respond-${tag(p)}`]) {
|
||||
stats[`shot-respond-${tag(p)}`] = 1;
|
||||
await p.screenshot({ path: join(SHOTS, `cockroach-poker-respond-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
|
||||
}
|
||||
const peek = panel.getByRole('button', { name: '👁 몰래 보고 넘기기' });
|
||||
const calls = (stats.true ?? 0) + (stats.false ?? 0);
|
||||
if ((await peek.isEnabled().catch(() => false)) && calls % 3 === 1) {
|
||||
await peek.click();
|
||||
await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요').waitFor({ timeout: 3000 });
|
||||
stats.peek = (stats.peek ?? 0) + 1;
|
||||
return 'peek';
|
||||
}
|
||||
const which = calls % 2 === 0 ? 'true' : 'false';
|
||||
await panel.getByRole('button', { name: which === 'true' ? /^진짜!/ : /^거짓말!/ }).click();
|
||||
stats[which] = (stats[which] ?? 0) + 1;
|
||||
return which;
|
||||
}
|
||||
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.
|
||||
@@ -689,7 +1070,7 @@ const PLANS: Record<string, Plan> = {
|
||||
const target = p.locator('button.bg-seat[data-target="ok"]').first();
|
||||
await target.waitFor({ timeout: 2000 });
|
||||
await target.click();
|
||||
const fire = p.getByRole('button', { name: /에게 뱅!$/ });
|
||||
const fire = p.getByRole('button', { name: /님에게 뱅!$/ });
|
||||
await fire.click();
|
||||
stats.bangs++;
|
||||
if (!shot) {
|
||||
@@ -879,6 +1260,352 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
dalmuti: {
|
||||
// PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with
|
||||
// jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen.
|
||||
players: 4,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
|
||||
await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click();
|
||||
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/9~12 빼기/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const st: Record<string, number> = { turns: 0 };
|
||||
let actions = 0;
|
||||
let midShot = false;
|
||||
let round2 = false;
|
||||
const until = Date.now() + 300_000;
|
||||
const playingAgain = () => host.locator('.dm-table').count();
|
||||
while (Date.now() < until) {
|
||||
let acted = false;
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if (await dalmutiStep(p, i === 0, st)) {
|
||||
acted = true;
|
||||
actions++;
|
||||
await p.waitForTimeout(120);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) {
|
||||
midShot = true;
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true });
|
||||
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') });
|
||||
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) {
|
||||
round2 = true;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true });
|
||||
}
|
||||
if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) {
|
||||
st.taxDone = 0;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true });
|
||||
}
|
||||
// Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen.
|
||||
if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break;
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`);
|
||||
if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`);
|
||||
if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`);
|
||||
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') });
|
||||
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
|
||||
},
|
||||
},
|
||||
splendor: {
|
||||
// PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a
|
||||
// colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too),
|
||||
// return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^10점 · 한 차례 60초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const stats: Record<string, number> = {};
|
||||
let turns = 0;
|
||||
let shot = false;
|
||||
const until = Date.now() + 480_000;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted: string | null = null;
|
||||
for (const p of pages) {
|
||||
const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
|
||||
acted = await splendorStep(p, stats, turns);
|
||||
if (acted) {
|
||||
stats[acted] = (stats[acted] ?? 0) + 1;
|
||||
turns++;
|
||||
// Wait until the server answered (status or log changed).
|
||||
await p
|
||||
.waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 })
|
||||
.catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (turns >= 14 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(700);
|
||||
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'splendor-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, 'splendor-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await host.waitForTimeout(150);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') });
|
||||
if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`);
|
||||
console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
quoridor: {
|
||||
// PC 방장 + 휴대폰 친구(2인, 자리는 무작위). 남: 세로벽 d8 → 북: 세로벽 e8(북이 e8·e9 상자 안) → 남: 가로벽 e7은
|
||||
// 북의 길을 완전히 막으므로 빨간 미리보기·이유와 함께 거부 → 다른 가로벽 a3. 그 뒤 둘 다 e열 최단 길로 달려
|
||||
// 마주치면 뛰어넘고, 먼저 끝줄에 닿은 쪽이 이긴다(첫 이동은 키보드 ↑+Enter).
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
await qdSvg(host).waitFor();
|
||||
await qdSvg(guest).waitFor();
|
||||
const south = (await qdSvg(host).getAttribute('data-my-seat')) === 'S' ? host : guest;
|
||||
const north = south === host ? guest : host;
|
||||
if ((await qdSvg(north).getAttribute('data-my-seat')) !== 'N') throw new Error('seats are not S/N');
|
||||
await qdPreviewWall(south, 'd8', 'v');
|
||||
await qdConfirmWall(south);
|
||||
await qdPreviewWall(north, 'e8', 'v');
|
||||
await north.waitForTimeout(200);
|
||||
await north.screenshot({ path: join(SHOTS, `quoridor-wall-preview-${tag(north)}.png`) });
|
||||
await qdConfirmWall(north);
|
||||
const bad = await qdPreviewWall(south, 'e7', 'h');
|
||||
if (!bad.endsWith(':bad')) throw new Error(`blocking wall was not refused (${bad})`);
|
||||
await south.getByText('누군가의 길을 완전히 막을 수 없어요').first().waitFor();
|
||||
if (await south.getByRole('button', { name: '벽 세우기 확인' }).isEnabled()) throw new Error('confirm enabled for an illegal wall');
|
||||
await south.waitForTimeout(300);
|
||||
await south.screenshot({ path: join(SHOTS, `quoridor-illegal-wall-${tag(south)}.png`) });
|
||||
if ((await south.locator('[data-wall]').count()) !== 2) throw new Error('illegal wall was placed');
|
||||
await qdPreviewWall(south, 'a3', 'h');
|
||||
await qdConfirmWall(south);
|
||||
let jumps = 0;
|
||||
let steps = 0;
|
||||
let shot = false;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
const first = new Set<Page>();
|
||||
for (let turn = 0; turn < 40 && !(await over()); turn++) {
|
||||
const mover = turn % 2 === 0 ? north : south;
|
||||
const useKeys = mover === host && !first.has(mover);
|
||||
first.add(mover);
|
||||
if (await qdStep(mover, useKeys)) jumps++;
|
||||
steps++;
|
||||
if (steps === 6 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await host.waitForTimeout(150);
|
||||
}
|
||||
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-result-phone.png') });
|
||||
const summary = await host.getByRole('dialog', { name: '게임 결과' }).innerText();
|
||||
if (!/승리 — \d+수 만에 도착/.test(summary)) throw new Error(`unexpected result: ${summary.slice(0, 80)}`);
|
||||
if (jumps < 1) throw new Error('no jump happened');
|
||||
console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`);
|
||||
},
|
||||
},
|
||||
'las-vegas': {
|
||||
// PC host + phone guest (2 players → 4 white dice each), 3 rounds via the lobby: real rolls and placements
|
||||
// through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const st = { rolls: 0, places: 0, keyboard: false, casinoPick: false, shots: new Set<string>() };
|
||||
let tieSeen = false;
|
||||
let settleShot = false;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
const until = Date.now() + 300_000;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if (await lasVegasStep(p, i > 0, st)) {
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!settleShot && (await host.locator('section[aria-label="1라운드 정산"]').count())) {
|
||||
settleShot = true;
|
||||
await host.waitForTimeout(700);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-settle-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'las-vegas-settle-phone.png'), fullPage: true });
|
||||
}
|
||||
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
|
||||
if (!acted) await host.waitForTimeout(150);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${JSON.stringify({ ...st, shots: [...st.shots] })})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'las-vegas-result-phone.png') });
|
||||
// Close the result sheet to look at the final table on the board.
|
||||
await host.keyboard.press('Escape');
|
||||
await host.locator('section[aria-label="최종 결과"]').waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc.png') });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(250);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
if (!(await host.locator('section[aria-label="3라운드 정산"]').count())) throw new Error('no settlement for round 3');
|
||||
if (!tieSeen) throw new Error('no duplicate-tie cancellation appeared in any settlement');
|
||||
if (st.rolls < 10 || st.places < 10 || !st.keyboard || !st.casinoPick) throw new Error(`too few real turns: ${JSON.stringify({ ...st, shots: [...st.shots] })}`);
|
||||
console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`);
|
||||
},
|
||||
},
|
||||
'cockroach-poker': {
|
||||
// PC host + 2 phones, "같은 벌레 3장" for a shorter game. Real turns through the UI until someone loses:
|
||||
// offer a face-down card with a claim, the target calls 진짜!/거짓말! or peeks and passes; the card goes face-up.
|
||||
players: 3,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page, Page];
|
||||
const stats: Record<string, number> = {};
|
||||
let actions = 0;
|
||||
let shot = false;
|
||||
const until = Date.now() + 300_000;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
const did = await cockroachStep(p, stats);
|
||||
if (did) {
|
||||
acted = true;
|
||||
actions++;
|
||||
// Wait for the server to move on before looking again.
|
||||
await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요') || !!document.querySelector('.cp-panel button.cp-target'), null, { timeout: 4000 }).catch(() => {});
|
||||
await p.waitForTimeout(120);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shot && (stats.true ?? 0) + (stats.false ?? 0) >= 4 && (await host.locator('.cp-chip').count()) >= 3) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(800);
|
||||
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-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, 'cockroach-poker-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-dark.png'), fullPage: true });
|
||||
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 (${actions} UI actions: ${JSON.stringify(stats)})`);
|
||||
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-result-phone.png') });
|
||||
const calls = (stats.true ?? 0) + (stats.false ?? 0);
|
||||
if (!stats.offer || calls < 3) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
|
||||
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);
|
||||
|
||||
604
packages/games/src/cockroach-poker/cockroach-poker.test.ts
Normal file
604
packages/games/src/cockroach-poker/cockroach-poker.test.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
|
||||
import {
|
||||
KINDS,
|
||||
allCardIds,
|
||||
autoAction,
|
||||
cockroachPoker as game,
|
||||
countKinds,
|
||||
handSizes,
|
||||
kindOf,
|
||||
type CockroachAction,
|
||||
type CockroachOptions,
|
||||
type CockroachState,
|
||||
type CockroachView,
|
||||
type Kind,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
let clock = 1_000;
|
||||
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||
|
||||
function setup(opts: Partial<CockroachOptions> = {}, players = ['A', 'B', 'C', 'D'], seed = 1): CockroachState {
|
||||
return game.setup({ players, options: game.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* A game in progress with the given hands / fronts (as kinds; ids are assigned in order) and `starter` to offer.
|
||||
* Unused cards are dropped from play (tests only care about the listed ones).
|
||||
*/
|
||||
function rigged(hands: Record<string, Kind[]>, opts: Partial<CockroachOptions> = {}, fronts: Record<string, Kind[]> = {}, starter?: string): CockroachState {
|
||||
const players = Object.keys(hands);
|
||||
const s = setup(opts, players);
|
||||
const next: Record<Kind, number> = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
|
||||
const take = (k: Kind) => `${k}-${next[k]++}`;
|
||||
for (const p of players) {
|
||||
s.hands[p] = hands[p]!.map(take);
|
||||
s.fronts[p] = (fronts[p] ?? []).map(take);
|
||||
}
|
||||
s.removed = [];
|
||||
s.round = { no: 1, starter: starter ?? players[0]!, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] };
|
||||
return s;
|
||||
}
|
||||
|
||||
function actE(s: CockroachState, p: string, a: CockroachAction): { state: CockroachState; events: GameEvent[] } {
|
||||
const v = game.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return game.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
|
||||
}
|
||||
const act = (s: CockroachState, p: string, a: CockroachAction) => actE(s, p, a).state;
|
||||
const reject = (s: CockroachState, p: string, a: CockroachAction) => {
|
||||
const v = game.validate(s, p, a);
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
const cardOf = (s: CockroachState, p: string, k: Kind) => s.hands[p]!.find((id) => kindOf(id) === k)!;
|
||||
const offer = (s: CockroachState, from: string, k: Kind, to: string, claim: Kind) => act(s, from, { type: 'offer', cardId: cardOf(s, from, k), to, claim });
|
||||
const frontKinds = (s: CockroachState, p: string) => s.fronts[p]!.map(kindOf);
|
||||
|
||||
/** Every card exists exactly once across hands, fronts, the removed pile and the passed card. */
|
||||
function checkInvariants(st: unknown, total = 64): void {
|
||||
const s = st as CockroachState;
|
||||
const all = [...Object.values(s.hands).flat(), ...Object.values(s.fronts).flat(), ...s.removed, ...(s.round.card ? [s.round.card] : [])];
|
||||
if (all.length !== total || new Set(all).size !== total) throw new Error(`cards not conserved: ${all.length}/${total}`);
|
||||
if (s.final === null) {
|
||||
for (const p of s.order) {
|
||||
const c = countKinds(s.fronts[p]!);
|
||||
if (KINDS.some((k) => c[k] >= s.loseCount)) throw new Error(`${p} should have lost`);
|
||||
}
|
||||
const r = s.round;
|
||||
if ((r.card === null) !== (r.holder === null)) throw new Error('card/holder mismatch');
|
||||
if (r.holder !== null && !r.seen.includes(r.starter)) throw new Error('starter not in seen');
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural hidden-info check for one view (beyond the id-substring leak test). */
|
||||
function checkViewShape(s: CockroachState, v: CockroachView, viewer: string | null): void {
|
||||
const over = s.final !== null;
|
||||
for (const p of s.order) expect(v.handCounts[p]).toBe(s.hands[p]!.length);
|
||||
if (viewer === null) expect(v.myHand).toBeNull();
|
||||
const json = JSON.stringify(v);
|
||||
if (!over) {
|
||||
expect(v.revealed).toBeNull();
|
||||
for (const id of s.removed) if (json.includes(JSON.stringify(id))) throw new Error(`removed card ${id} leaks`);
|
||||
for (const p of s.order) if (p !== viewer) for (const id of s.hands[p]!) if (json.includes(JSON.stringify(id))) throw new Error(`hand card ${id} of ${p} leaks to ${viewer}`);
|
||||
const knows = viewer !== null && s.round.seen.includes(viewer);
|
||||
if (s.round.card) {
|
||||
if (knows) expect(v.card).toEqual({ known: true, id: s.round.card, kind: kindOf(s.round.card) });
|
||||
else {
|
||||
expect(v.card).toEqual({ known: false });
|
||||
if (json.includes(JSON.stringify(s.round.card))) throw new Error(`passed card leaks to ${viewer}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup
|
||||
|
||||
describe('셋업 (§4)', () => {
|
||||
test('인원별 손패 장수가 표와 일치(첫 플레이어부터), 2인은 10장 제거 후 27장씩, 카드 총합 보존', () => {
|
||||
const table: Record<number, number[]> = { 2: [27, 27], 3: [22, 21, 21], 4: [16, 16, 16, 16], 5: [13, 13, 13, 13, 12], 6: [11, 11, 11, 11, 10, 10] };
|
||||
for (let n = 2; n <= 6; n++) {
|
||||
expect(handSizes(n)).toEqual(table[n]!);
|
||||
for (let seed = 1; seed <= 10; seed++) {
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
const s = setup({}, players, seed);
|
||||
const fi = players.indexOf(s.round.starter);
|
||||
const sizes = players.map((_, k) => s.hands[players[(fi + k) % n]!]!.length);
|
||||
expect(sizes).toEqual(table[n]!);
|
||||
expect(s.removed.length).toBe(n === 2 ? 10 : 0);
|
||||
expect(s.loseCount).toBe(n === 2 ? 5 : 4);
|
||||
checkInvariants(s);
|
||||
for (const p of players) expect(s.fronts[p]).toEqual([]);
|
||||
}
|
||||
}
|
||||
expect(allCardIds().length).toBe(64);
|
||||
for (const k of KINDS) expect(allCardIds().filter((id) => kindOf(id) === k).length).toBe(8);
|
||||
});
|
||||
|
||||
test('같은 시드 = 같은 셋업; 첫 시작자는 시드에 따라 달라짐', () => {
|
||||
expect(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))).toBe(JSON.stringify(setup({}, ['A', 'B', 'C'], 5)));
|
||||
const starters = new Set(Array.from({ length: 30 }, (_, i) => setup({}, ['A', 'B', 'C'], i + 1).round.starter));
|
||||
expect(starters.size).toBe(3);
|
||||
});
|
||||
|
||||
test('loseCount 옵션: 자동(2인 5, 3~6인 4) 또는 지정값', () => {
|
||||
expect(setup({ loseCount: 3 }, ['A', 'B']).loseCount).toBe(3);
|
||||
expect(setup({ loseCount: 5 }, ['A', 'B', 'C']).loseCount).toBe(5);
|
||||
expect(setup({}, ['A', 'B', 'C', 'D', 'E', 'F']).loseCount).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
describe('판정 (§5.2, §5.3)', () => {
|
||||
test('진실 주장에 "진짜" → 전달자 앞마당; 받은 사람이 다음 시작자', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
const out = actE(s, 'B', { type: 'call', call: 'true' });
|
||||
s = out.state;
|
||||
expect(frontKinds(s, 'A')).toEqual(['rat']);
|
||||
expect(frontKinds(s, 'B')).toEqual([]);
|
||||
expect(s.round.starter).toBe('A');
|
||||
expect(s.round.no).toBe(2);
|
||||
expect(s.lastReveal).toMatchObject({ kind: 'rat', claim: 'rat', call: 'true', caller: 'B', correct: true, takenBy: 'A' });
|
||||
expect(out.events.find((e) => e.type === 'reveal')).toMatchObject({ kind: 'rat', correct: true, takenBy: 'A' });
|
||||
checkInvariants(s, 4);
|
||||
});
|
||||
|
||||
test('거짓 주장에 "거짓" → 전달자; 거짓 주장에 "진짜" → 수신자; 진실 주장에 "거짓" → 수신자', () => {
|
||||
let s = rigged({ A: ['rat', 'rat'], B: ['bat', 'bat'], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
s = act(s, 'B', { type: 'call', call: 'false' });
|
||||
expect(frontKinds(s, 'A')).toEqual(['rat']);
|
||||
expect(s.round.starter).toBe('A');
|
||||
s = offer(s, 'A', 'rat', 'C', 'fly');
|
||||
s = act(s, 'C', { type: 'call', call: 'true' });
|
||||
expect(frontKinds(s, 'C')).toEqual(['rat']);
|
||||
expect(s.round.starter).toBe('C');
|
||||
s = offer(s, 'C', 'toad', 'B', 'toad');
|
||||
s = act(s, 'B', { type: 'call', call: 'false' });
|
||||
expect(frontKinds(s, 'B')).toEqual(['toad']);
|
||||
expect(s.round.starter).toBe('B');
|
||||
});
|
||||
|
||||
test('§5.4 예시: A가 거미를 "파리"로 B에게 → B가 보고 C에게 "거미"(진실) → C "거짓" → C 앞마당, C가 다음 시작자', () => {
|
||||
let s = rigged({ A: ['spider', 'rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
|
||||
s = offer(s, 'A', 'spider', 'B', 'fly');
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
expect(s.round.seen).toEqual(['A', 'B']);
|
||||
expect(game.view(s, 'B').card).toEqual({ known: true, id: 'spider-0', kind: 'spider' });
|
||||
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'spider' });
|
||||
expect([s.round.holder, s.round.lastPasser, s.round.claim]).toEqual(['C', 'B', 'spider']);
|
||||
const alt = s;
|
||||
s = act(s, 'C', { type: 'call', call: 'false' });
|
||||
expect(frontKinds(s, 'C')).toEqual(['spider']);
|
||||
expect(s.round.starter).toBe('C');
|
||||
// C could instead peek and pass only to D; D then has nobody left and must call.
|
||||
let t = act(alt, 'C', { type: 'peek' });
|
||||
expect(game.view(t, 'C').my!.passTargets).toEqual(['D']);
|
||||
expect(reject(t, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
|
||||
t = act(t, 'C', { type: 'pass', to: 'D', claim: 'bat' });
|
||||
expect(reject(t, 'D', { type: 'peek' })).toContain('넘길 사람이 없어요');
|
||||
expect(game.view(t, 'D').my).toMatchObject({ canCall: true, canPeek: false });
|
||||
expect(game.view(t, 'D').my!.peekBlocked).toContain('넘길 사람이 없어요');
|
||||
t = act(t, 'D', { type: 'call', call: 'false' }); // claim "bat", card spider → lie → D correct → C takes it
|
||||
expect(frontKinds(t, 'C')).toEqual(['spider']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('떠넘기기 거부 (§5.2, §8.2)', () => {
|
||||
test('seen에 있는 사람(시작자 포함)에게 pass 거부, 본인에게도 거부', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
expect(reject(s, 'B', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
|
||||
expect(reject(s, 'B', { type: 'pass', to: 'B', claim: 'rat' })).toBe('나에게는 넘길 수 없어요.');
|
||||
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' });
|
||||
s = act(s, 'C', { type: 'peek' });
|
||||
expect(reject(s, 'C', { type: 'pass', to: 'B', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
|
||||
expect(reject(s, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
|
||||
expect(reject(s, 'C', { type: 'pass', to: 'D', claim: 'rat' })).toBeNull();
|
||||
});
|
||||
|
||||
test('나머지 전원이 본 상태의 holder는 peek 거부, call만 가능', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'fly' });
|
||||
expect(reject(s, 'C', { type: 'peek' })).toBe('모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.');
|
||||
expect(game.legalActions!(s, 'C')).toEqual([
|
||||
{ type: 'call', call: 'true' },
|
||||
{ type: 'call', call: 'false' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('peek 후 call 거부; peek 전 pass 거부; 두 번 peek 거부', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
expect(reject(s, 'B', { type: 'pass', to: 'C', claim: 'rat' })).toBe('먼저 카드를 몰래 봐야 넘길 수 있어요.');
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
expect(reject(s, 'B', { type: 'call', call: 'true' })).toBe('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.');
|
||||
expect(reject(s, 'B', { type: 'peek' })).toBe('이미 카드를 봤어요.');
|
||||
expect(game.legalActions!(s, 'B').every((a) => a.type === 'pass')).toBe(true);
|
||||
});
|
||||
|
||||
test('차례가 아닌 사람·잘못된 offer 거부', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
|
||||
expect(reject(s, 'B', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('지금은 내 차례가 아니에요.');
|
||||
expect(reject(s, 'A', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('내 손에 없는 카드예요.');
|
||||
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'A', claim: 'bat' })).toBe('나에게는 건넬 수 없어요.');
|
||||
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'Z', claim: 'bat' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||
expect(reject(s, 'A', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.');
|
||||
expect(reject(s, 'Z', { type: 'peek' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'C', claim: 'bat' })).toBe('지금은 카드를 건넬 때가 아니에요.');
|
||||
expect(reject(s, 'C', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.');
|
||||
expect(reject(s, 'C', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2인 규칙 (§5.2, §6)', () => {
|
||||
test('peek 거부(항상 판정), 같은 종류 5장에서 패배, 4장에서는 계속', () => {
|
||||
let s = rigged({ A: ['rat', 'rat', 'bat'], B: ['fly', 'fly'] }, {}, { A: ['rat', 'rat', 'rat'] });
|
||||
expect(s.loseCount).toBe(5);
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
expect(reject(s, 'B', { type: 'peek' })).toBe('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.');
|
||||
expect(game.view(s, 'B').my).toMatchObject({ canPeek: false, canCall: true });
|
||||
s = act(s, 'B', { type: 'call', call: 'true' }); // A takes 4th rat → continues
|
||||
expect(frontKinds(s, 'A').filter((k) => k === 'rat').length).toBe(4);
|
||||
expect(s.final).toBeNull();
|
||||
s = offer(s, 'A', 'rat', 'B', 'bat');
|
||||
s = act(s, 'B', { type: 'call', call: 'false' }); // 5th rat → A loses
|
||||
expect(s.final).toMatchObject({ loser: 'A', reason: 'four', losingKind: 'rat' });
|
||||
expect(game.result(s)).toMatchObject({ ranking: [['B'], ['A']], scores: { A: 0, B: 1 }, reason: 'normal' });
|
||||
});
|
||||
|
||||
test('twoPlayerPass: 한 번만 보고 되돌려 줄 수 있고, 되돌려 받은 사람은 판정만', () => {
|
||||
let s = rigged({ A: ['rat', 'bat'], B: ['fly'] }, { twoPlayerPass: true });
|
||||
s = offer(s, 'A', 'rat', 'B', 'bat');
|
||||
expect(game.view(s, 'B').my!.canPeek).toBe(true);
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
expect(game.view(s, 'B').my!.passTargets).toEqual(['A']);
|
||||
s = act(s, 'B', { type: 'pass', to: 'A', claim: 'rat' });
|
||||
expect(reject(s, 'A', { type: 'peek' })).toContain('되돌려 받은 카드');
|
||||
s = act(s, 'A', { type: 'call', call: 'true' });
|
||||
expect(frontKinds(s, 'B')).toEqual(['rat']);
|
||||
expect(s.round.starter).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('패배 조건 (§6)', () => {
|
||||
test('같은 종류 4장 도달 즉시 종료, 다른 종류 4장(합계)은 무관', () => {
|
||||
let s = rigged({ A: ['rat', 'rat'], B: ['fly'], C: ['toad'] }, {}, { B: ['bat', 'fly', 'toad'], C: ['rat', 'rat', 'rat'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes rat: 4 different kinds
|
||||
expect(s.final).toBeNull();
|
||||
expect(s.fronts.B!.length).toBe(4);
|
||||
s = offer(s, 'B', 'fly', 'C', 'rat');
|
||||
const out = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes fly: still 3 rats + 1 fly
|
||||
expect(out.state.final).toBeNull();
|
||||
s = offer(out.state, 'C', 'toad', 'A', 'toad');
|
||||
s = act(s, 'A', { type: 'call', call: 'false' }); // wrong → A takes toad
|
||||
s = offer(s, 'A', 'rat', 'C', 'bat');
|
||||
const end = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes 4th rat
|
||||
expect(end.state.final).toMatchObject({ loser: 'C', reason: 'four', losingKind: 'rat' });
|
||||
expect(end.events.some((e) => e.type === 'gameEnded')).toBe(true);
|
||||
expect(game.activePlayers(end.state)).toEqual([]);
|
||||
expect(game.deadline!(end.state)).toBeNull();
|
||||
const res = game.result(end.state)!;
|
||||
expect(res.ranking).toEqual([['A', 'B'], ['C']]);
|
||||
expect(res.summary).toContain('쥐 4장');
|
||||
// Detailed placings: A (max 1, total 2) before B (max 1, total 4); loser last.
|
||||
expect(end.state.final!.placings).toEqual([['A'], ['B'], ['C']]);
|
||||
expect(reject(end.state, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' })).toBe('게임이 이미 끝났어요.');
|
||||
});
|
||||
|
||||
test('loseCount=3 옵션', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { loseCount: 3 }, { B: ['rat', 'rat'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
s = act(s, 'B', { type: 'call', call: 'true' });
|
||||
expect(s.final).toMatchObject({ loser: 'B', reason: 'four', losingKind: 'rat' });
|
||||
});
|
||||
|
||||
test('다음 시작자의 손패가 0장 → 즉시 패배(reason emptyHand)', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes it and must start with 0 cards
|
||||
expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' });
|
||||
expect(game.result(s)!.ranking[1]).toEqual(['B']);
|
||||
});
|
||||
|
||||
test('손패 0장인 사람에게 건네기 허용, 그가 떠넘기기/판정 정상 동작', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'], D: ['bat'] });
|
||||
expect(game.view(s, 'A').my!.offerTargets).toEqual(['B', 'C', 'D']);
|
||||
s = offer(s, 'A', 'rat', 'B', 'bat');
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' });
|
||||
s = act(s, 'C', { type: 'call', call: 'true' }); // true claim → C right → B takes it, then B starts with 0 cards
|
||||
expect(frontKinds(s, 'B')).toEqual(['rat']);
|
||||
expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' });
|
||||
// Calling correctly with an empty hand is fine: the passer takes the card.
|
||||
let t = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] });
|
||||
t = offer(t, 'A', 'rat', 'B', 'rat');
|
||||
t = act(t, 'B', { type: 'call', call: 'true' });
|
||||
expect(t.final).toBeNull();
|
||||
expect(t.round.starter).toBe('A');
|
||||
});
|
||||
|
||||
test('eightKindsRule: 8종이 모이면 패배; 끄면 계속', () => {
|
||||
const fr = { B: ['cockroach', 'bat', 'fly', 'toad', 'scorpion', 'spider', 'stinkbug'] as Kind[] };
|
||||
for (const on of [true, false]) {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { eightKindsRule: on }, fr);
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
s = act(s, 'B', { type: 'call', call: 'true' });
|
||||
if (on) expect(s.final).toMatchObject({ loser: 'B', reason: 'eight' });
|
||||
else expect(s.final).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- timers
|
||||
|
||||
describe('시간 초과 (§8.5)', () => {
|
||||
test('시작자 무응답 → 자동 offer(다음 좌석, 손패 1장, 무작위 주장)', () => {
|
||||
const s = rigged({ A: ['rat', 'fly', 'bat'], B: ['bat'], C: ['toad'] });
|
||||
expect(game.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
|
||||
expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
const out = actE(s, 'A', { type: 'timeout' });
|
||||
const t = out.state;
|
||||
expect(t.round.holder).toBe('B');
|
||||
expect(t.round.claims[0]).toMatchObject({ from: 'A', to: 'B', auto: true });
|
||||
expect(t.hands.A!.length).toBe(2);
|
||||
expect(out.events.map((e) => e.type)).toEqual(['timeout', 'offer']);
|
||||
expect(t.timeoutsInRow.A).toBe(1);
|
||||
});
|
||||
|
||||
test('holder 무응답 → 자동 call; peek 후 무응답 → 자동 pass(seen 밖 첫 좌석, 실제 종류로)', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
const called = act(s, 'B', { type: 'timeout' });
|
||||
expect(called.lastReveal).toMatchObject({ caller: 'B', auto: true });
|
||||
expect(called.round.no).toBe(2);
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
const passed = actE(s, 'B', { type: 'timeout' });
|
||||
expect(passed.state.round).toMatchObject({ holder: 'C', lastPasser: 'B', claim: 'rat', peeked: false });
|
||||
expect(passed.state.round.claims[1]).toMatchObject({ from: 'B', to: 'C', claim: 'rat', auto: true });
|
||||
// C peeks; first unseen seat clockwise from C is D.
|
||||
let t = act(passed.state, 'C', { type: 'peek' });
|
||||
t = act(t, 'C', { type: 'timeout' });
|
||||
expect(t.round.holder).toBe('D');
|
||||
expect(autoAction(t, 'D', rng()).type).toBe('call');
|
||||
});
|
||||
|
||||
test('3번 연속 시간 초과 → 자리 비움(마감 5초), 직접 행동하면 해제', () => {
|
||||
const t0 = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { turnSeconds: 30 });
|
||||
let t = act({ ...t0, timeoutsInRow: { A: 1, B: 0, C: 0 } }, 'A', { type: 'timeout' });
|
||||
expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 2]);
|
||||
const third = actE({ ...t0, timeoutsInRow: { A: 2, B: 0, C: 0 } }, 'A', { type: 'timeout' });
|
||||
expect(third.state.away.A).toBe(true);
|
||||
expect(third.events.map((e) => e.type)).toEqual(['timeout', 'away', 'offer']);
|
||||
t = third.state;
|
||||
expect(game.view(t, null).away.A).toBe(true);
|
||||
const w = { ...t0, away: { A: true, B: false, C: false } };
|
||||
expect(game.deadline!(w)! - w.turnStartedAt).toBe(5000);
|
||||
expect(game.view(w, 'A').turnMs).toBe(5000);
|
||||
const back = act(w, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' });
|
||||
expect(back.away.A).toBe(false);
|
||||
expect(back.timeoutsInRow.A).toBe(0);
|
||||
expect(game.deadline!(back)! - back.turnStartedAt).toBe(30_000);
|
||||
});
|
||||
|
||||
test('모두 무응답이어도 게임이 끝남; 매 상태에서 onTimeout이 합법', () => {
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
const n = 2 + (seed % 5);
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
let s = setup({ twoPlayerPass: seed % 2 === 0 }, players, seed);
|
||||
const r = SeededRng.fromSeed(seedN(seed));
|
||||
let step = 0;
|
||||
for (; step < 2000 && !s.final; step++) {
|
||||
const p = game.timeoutPlayers!(s)[0]!;
|
||||
expect(game.validate(s, p, game.onTimeout(s, p))).toEqual({ ok: true });
|
||||
s = game.apply(s, p, game.onTimeout(s, p), { rng: r, now: step }).state;
|
||||
checkInvariants(s);
|
||||
}
|
||||
expect(s.final).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('나가기·빠른 말', () => {
|
||||
test('나가면 그 사람이 패배하고 나머지 모두 승리(reason resign)', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
expect(game.onLeave!(s, 'C')).toEqual({ type: 'leave' });
|
||||
expect(game.onLeave!(s, 'Z')).toBeNull();
|
||||
s = act(s, 'C', { type: 'leave' });
|
||||
expect(s.final).toMatchObject({ loser: 'C', reason: 'left' });
|
||||
expect(game.result(s)).toMatchObject({ ranking: [['A', 'B'], ['C']], reason: 'resign' });
|
||||
expect(game.onLeave!(s, 'A')).toBeNull();
|
||||
expect(game.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||
});
|
||||
|
||||
test('빠른 말: 라운드당 3번, 옵션을 끄면 거부, 차례 시계는 그대로', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] });
|
||||
const started = s.turnStartedAt;
|
||||
for (let i = 0; i < 3; i++) s = act(s, 'C', { type: 'say', phrase: 'doubt' });
|
||||
expect(s.turnStartedAt).toBe(started);
|
||||
expect(reject(s, 'C', { type: 'say', phrase: 'doubt' })).toContain('3번까지');
|
||||
expect(game.view(s, 'C').said.length).toBe(3);
|
||||
expect(game.view(s, 'C').my!.canSay).toBe(false);
|
||||
s = offer(s, 'A', 'rat', 'B', 'rat');
|
||||
s = act(s, 'B', { type: 'call', call: 'true' }); // next round resets the count
|
||||
expect(reject(s, 'C', { type: 'say', phrase: 'really' })).toBeNull();
|
||||
const off = rigged({ A: ['rat'], B: ['bat'] }, { quickChat: false });
|
||||
expect(reject(off, 'A', { type: 'say', phrase: 'really' })).toBe('이 방은 빠른 말을 쓰지 않아요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- info
|
||||
|
||||
describe('비공개 정보 (§8.3)', () => {
|
||||
test('미확인 holder·관전자·다른 플레이어 view에 건네는 카드가 없고, 본 사람에게만 보임', () => {
|
||||
let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'], D: ['fly'] });
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
expect(game.view(s, 'A').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' });
|
||||
for (const v of ['B', 'C', 'D', null]) {
|
||||
expect(game.view(s, v).card).toEqual({ known: false });
|
||||
expect(JSON.stringify(game.view(s, v))).not.toContain('"rat-0"');
|
||||
}
|
||||
s = act(s, 'B', { type: 'peek' });
|
||||
expect(game.view(s, 'B').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' });
|
||||
expect(game.view(s, 'C').card).toEqual({ known: false });
|
||||
expect(game.view(s, null).round.seen).toEqual(['A', 'B']);
|
||||
expect(game.view(s, 'C').myHand).toEqual([{ kind: 'toad', ids: ['toad-0'] }]);
|
||||
expect(JSON.stringify(game.view(s, 'C'))).not.toContain('"fly-0"');
|
||||
expect(game.view(s, null).myHand).toBeNull();
|
||||
expect(game.view(s, null).my).toBeNull();
|
||||
});
|
||||
|
||||
test('2인 제거 카드는 진행 중 비공개, 종료 후 전체 손패와 함께 공개', () => {
|
||||
let s = setup({}, ['A', 'B'], 3);
|
||||
for (const v of ['A', 'B', null]) {
|
||||
const j = JSON.stringify(game.view(s, v));
|
||||
for (const id of s.removed) expect(j).not.toContain(JSON.stringify(id));
|
||||
expect(game.view(s, v).removedCount).toBe(10);
|
||||
}
|
||||
s = act(s, 'A', { type: 'leave' });
|
||||
const v = game.view(s, null);
|
||||
expect(v.revealed!.removed).toEqual(s.removed);
|
||||
expect(v.revealed!.hands.B).toEqual(s.hands.B!);
|
||||
expect(game.secretsOf!(s, 'A')).toEqual([]);
|
||||
});
|
||||
|
||||
test('claimHistory=off: 주장 기록은 숨기고 현재 주장만', () => {
|
||||
let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }, { claimHistory: false });
|
||||
s = offer(s, 'A', 'rat', 'B', 'fly');
|
||||
const v = game.view(s, 'C');
|
||||
expect(v.round.claims).toEqual([]);
|
||||
expect(v.round.claim).toBe('fly');
|
||||
expect(game.view(rigged({ A: ['rat'], B: ['bat'] }), 'A').round.claims).toEqual([]);
|
||||
});
|
||||
|
||||
test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => {
|
||||
for (let seed = 1; seed <= 150; seed++) {
|
||||
const n = 2 + (seed % 5);
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
const r = SeededRng.fromSeed(seedN(seed));
|
||||
const chooser = SeededRng.fromSeed(seedN(seed + 77));
|
||||
let s = setup({ twoPlayerPass: seed % 3 === 0, claimHistory: seed % 4 !== 0 }, players, seed);
|
||||
for (let step = 0; step < 600 && !s.final; step++) {
|
||||
for (const viewer of [...players, null]) checkViewShape(s, game.view(s, viewer), viewer);
|
||||
const actor = game.activePlayers(s)[0]!;
|
||||
const legal = game.legalActions!(s, actor);
|
||||
const a = chooser.int(5) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||
const out = game.apply(s, actor, a, { rng: r, now: step * 1000 });
|
||||
if (!out.state.final) {
|
||||
const hidden = [...Object.values(out.state.hands).flat(), ...out.state.removed, ...(out.state.round.card ? [out.state.round.card] : [])];
|
||||
for (const e of out.events) {
|
||||
const ej = JSON.stringify(e);
|
||||
for (const id of hidden) if (ej.includes(JSON.stringify(id))) throw new Error(`event ${e.type} leaks ${id}`);
|
||||
}
|
||||
}
|
||||
s = out.state;
|
||||
}
|
||||
expect(s.final).not.toBeNull();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- schema
|
||||
|
||||
describe('옵션·스키마', () => {
|
||||
test('모든 옵션에 한국어 메타; 기본값이 문서와 같음', () => {
|
||||
const shape = (game.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
|
||||
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
|
||||
expect(game.defaultOptions).toEqual({ loseCount: 'auto', turnSeconds: 30, claimHistory: true, quickChat: true, eightKindsRule: false, twoPlayerPass: false });
|
||||
expect(game.optionsSchema.safeParse({ loseCount: 6 }).success).toBe(false);
|
||||
expect(game.optionsSchema.safeParse({ turnSeconds: 20 }).success).toBe(false);
|
||||
});
|
||||
|
||||
test('클라이언트 행동만 스키마에 있음', () => {
|
||||
expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }).success).toBe(true);
|
||||
expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'dragon' }).success).toBe(false);
|
||||
expect(game.actionSchema.safeParse({ type: 'call', call: true }).success).toBe(false);
|
||||
expect(game.actionSchema.safeParse({ type: 'peek' }).success).toBe(true);
|
||||
expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- properties
|
||||
|
||||
function cfg(i: number): { players: string[]; options: CockroachOptions } {
|
||||
return {
|
||||
players: Array.from({ length: 2 + (i % 5) }, (_, k) => `P${k}`),
|
||||
options: game.optionsSchema.parse({
|
||||
loseCount: (['auto', 3, 4, 5] as const)[i % 4],
|
||||
claimHistory: i % 3 !== 0,
|
||||
eightKindsRule: i % 7 === 0,
|
||||
twoPlayerPass: i % 2 === 0,
|
||||
turnSeconds: ([15, 30, 60] as const)[i % 3],
|
||||
}),
|
||||
};
|
||||
}
|
||||
function invariant(st: unknown): void {
|
||||
checkInvariants(st);
|
||||
const s = st as CockroachState;
|
||||
for (const p of game.timeoutPlayers!(s)) {
|
||||
const v = game.validate(s, p, game.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
|
||||
const c = game.validate(s, p, autoAction(s, p, rng()));
|
||||
if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('무작위 대국 (docs/09 §7)', () => {
|
||||
test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => {
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const { players, options } = cfg(i);
|
||||
const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 });
|
||||
expect(r.finished).toBe(true);
|
||||
const res = game.result(r.finalState as CockroachState)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||
expect(res.ranking[1]!.length).toBe(1);
|
||||
if (i % 40 === 1) {
|
||||
const b = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, maxApplyMs: 50 });
|
||||
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState));
|
||||
expect(b.trace).toEqual(r.trace);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => {
|
||||
let maxSteps = 0;
|
||||
for (let i = 201; i <= 1000; i++) {
|
||||
const { players, options } = cfg(i);
|
||||
const r = SeededRng.fromSeed(seedN(i));
|
||||
const chooser = SeededRng.fromSeed(seedN(i + 5000));
|
||||
let s = game.setup({ players, options, rng: r, now: 0 });
|
||||
const log: { actor: string; action: CockroachAction }[] = [];
|
||||
let step = 0;
|
||||
for (; step < 2000 && !s.final; step++) {
|
||||
const actor = game.activePlayers(s)[0]!;
|
||||
const legal = game.legalActions!(s, actor);
|
||||
const a = chooser.int(10) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||
const v = game.validate(s, actor, a);
|
||||
if (!v.ok) throw new Error(`seed ${i}: ${v.reason}`);
|
||||
s = game.apply(s, actor, a, { rng: r, now: step * 1000 }).state;
|
||||
log.push({ actor, action: a });
|
||||
checkInvariants(s);
|
||||
}
|
||||
maxSteps = Math.max(maxSteps, step);
|
||||
expect(s.final).not.toBeNull();
|
||||
expect(JSON.parse(JSON.stringify(s))).toEqual(s);
|
||||
if (i % 50 === 0) {
|
||||
// Replay: same seed + same action log → identical state.
|
||||
const r2 = SeededRng.fromSeed(seedN(i));
|
||||
let t = game.setup({ players, options, rng: r2, now: 0 });
|
||||
log.forEach(({ actor, action }, k) => (t = game.apply(t, actor, action, { rng: r2, now: k * 1000 }).state));
|
||||
expect(JSON.stringify(t)).toBe(JSON.stringify(s));
|
||||
}
|
||||
}
|
||||
expect(maxSteps).toBeLessThan(2000);
|
||||
}, 60_000);
|
||||
});
|
||||
650
packages/games/src/cockroach-poker/index.ts
Normal file
650
packages/games/src/cockroach-poker/index.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* 바퀴벌레 포커 (docs/games/cockroach-poker.md).
|
||||
*
|
||||
* Secret-id design: card ids ('rat-3', ...) encode the kind, so an id only ever appears in a view when the
|
||||
* viewer may know that card (own hand, face-up fronts, the passed card for players in `seen`, everything
|
||||
* after the game ends). Other hands are sent as counts; the passed card as `{ known: false }`.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||
|
||||
export const KINDS = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug'] as const;
|
||||
export type Kind = (typeof KINDS)[number];
|
||||
export const KIND_KO: Record<Kind, string> = {
|
||||
cockroach: '바퀴벌레',
|
||||
bat: '박쥐',
|
||||
fly: '파리',
|
||||
toad: '두꺼비',
|
||||
rat: '쥐',
|
||||
scorpion: '전갈',
|
||||
spider: '거미',
|
||||
stinkbug: '노린재',
|
||||
};
|
||||
export const PER_KIND = 8;
|
||||
export const REMOVED_2P = 10;
|
||||
|
||||
export const PHRASES = ['really', 'doubt', 'hmm', 'trust'] as const;
|
||||
export type Phrase = (typeof PHRASES)[number];
|
||||
export const PHRASE_KO: Record<Phrase, string> = {
|
||||
really: '정말이야!',
|
||||
doubt: '의심스러운데?',
|
||||
hmm: '음… 고민되네',
|
||||
trust: '믿어 볼게',
|
||||
};
|
||||
/** Quick-chat phrases per player per round (keeps the log small). */
|
||||
export const SAY_PER_ROUND = 3;
|
||||
|
||||
const AWAY_AFTER = 3;
|
||||
const AWAY_MS = 5000;
|
||||
const SAID_KEEP = 6;
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
export const cockroachOptionsSchema = z.object({
|
||||
loseCount: z
|
||||
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
|
||||
.default('auto')
|
||||
.meta({ title: '같은 벌레가 몇 장 모이면 패배', labels: { auto: '자동(2명 5장, 3~6명 4장)', 3: '3장(짧은 게임)', 4: '4장', 5: '5장' } }),
|
||||
turnSeconds: z
|
||||
.union([z.literal(15), z.literal(30), z.literal(60)])
|
||||
.default(30)
|
||||
.meta({ title: '건네기·판정 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초' } }),
|
||||
claimHistory: z.boolean().default(true).meta({ title: '이번 라운드 주장 기록 보이기' }),
|
||||
quickChat: z.boolean().default(true).meta({ title: '빠른 말 버튼("정말이야!", "의심스러운데?")' }),
|
||||
eightKindsRule: z.boolean().default(false).meta({ title: '앞마당에 8종을 모두 모아도 패배(하우스 룰)' }),
|
||||
twoPlayerPass: z.boolean().default(false).meta({ title: '2명일 때도 몰래 보고 되돌려 주기 허용(하우스 룰)' }),
|
||||
});
|
||||
export type CockroachOptions = z.infer<typeof cockroachOptionsSchema>;
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
const kindSchema = z.enum(KINDS);
|
||||
export const cockroachActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('offer'), cardId: z.string().max(32), to: z.string().max(64), claim: kindSchema }),
|
||||
z.object({ type: z.literal('call'), call: z.enum(['true', 'false']) }),
|
||||
z.object({ type: z.literal('peek') }),
|
||||
z.object({ type: z.literal('pass'), to: z.string().max(64), claim: kindSchema }),
|
||||
z.object({ type: z.literal('say'), phrase: z.enum(PHRASES) }),
|
||||
]);
|
||||
export type CockroachClientAction = z.infer<typeof cockroachActionSchema>;
|
||||
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
|
||||
export type CockroachAction = CockroachClientAction | { type: 'timeout' } | { type: 'leave' };
|
||||
type TurnAction = Extract<CockroachClientAction, { type: 'offer' | 'call' | 'peek' | 'pass' }>;
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
export type LossReason = 'four' | 'emptyHand' | 'eight' | 'left';
|
||||
|
||||
export interface ClaimEntry {
|
||||
from: PlayerId;
|
||||
to: PlayerId;
|
||||
claim: Kind;
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
export interface CockroachRound {
|
||||
no: number;
|
||||
starter: PlayerId;
|
||||
/** Card being passed (null while the starter is choosing). */
|
||||
card: string | null;
|
||||
/** Current receiver who must decide (null while the starter is choosing). */
|
||||
holder: PlayerId | null;
|
||||
lastPasser: PlayerId | null;
|
||||
claim: Kind | null;
|
||||
/** Players who know the passed card: the starter plus every holder who peeked. */
|
||||
seen: PlayerId[];
|
||||
/** The holder has looked at the card and must pass it on. */
|
||||
peeked: boolean;
|
||||
claims: ClaimEntry[];
|
||||
}
|
||||
|
||||
export interface LastReveal {
|
||||
card: string;
|
||||
kind: Kind;
|
||||
claim: Kind;
|
||||
call: 'true' | 'false';
|
||||
caller: PlayerId;
|
||||
passer: PlayerId;
|
||||
correct: boolean;
|
||||
takenBy: PlayerId;
|
||||
auto: boolean;
|
||||
round: number;
|
||||
}
|
||||
|
||||
export interface CockroachFinal {
|
||||
loser: PlayerId;
|
||||
reason: LossReason;
|
||||
losingKind: Kind | null;
|
||||
/** Detailed placings (§6): winners by fewest same-kind cards, then fewest front cards; loser last. */
|
||||
placings: PlayerId[][];
|
||||
fronts: Record<PlayerId, Record<Kind, number>>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Said {
|
||||
seq: number;
|
||||
player: PlayerId;
|
||||
phrase: Phrase;
|
||||
}
|
||||
|
||||
export interface CockroachState {
|
||||
options: CockroachOptions;
|
||||
loseCount: number;
|
||||
order: PlayerId[];
|
||||
hands: Record<PlayerId, string[]>;
|
||||
fronts: Record<PlayerId, string[]>;
|
||||
/** 2-player game: 10 cards removed unseen (secret until the end). */
|
||||
removed: string[];
|
||||
round: CockroachRound;
|
||||
lastReveal: LastReveal | null;
|
||||
timeoutsInRow: Record<PlayerId, number>;
|
||||
away: Record<PlayerId, boolean>;
|
||||
said: Said[];
|
||||
sayCount: Record<PlayerId, number>;
|
||||
turnStartedAt: number;
|
||||
seq: number;
|
||||
final: CockroachFinal | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
export type DangerLevel = 'ok' | 'warn' | 'danger';
|
||||
|
||||
export interface FrontView {
|
||||
counts: Record<Kind, number>;
|
||||
/** Kinds with at least one card, most first (ties in KINDS order). */
|
||||
kinds: Kind[];
|
||||
level: Record<Kind, DangerLevel>;
|
||||
total: number;
|
||||
/** Distinct kinds (for the 8-kinds house rule). */
|
||||
distinct: number;
|
||||
}
|
||||
|
||||
export interface CockroachView {
|
||||
options: CockroachOptions;
|
||||
loseCount: number;
|
||||
me: PlayerId | null;
|
||||
order: PlayerId[];
|
||||
phase: 'offer' | 'respond' | 'finished';
|
||||
handCounts: Record<PlayerId, number>;
|
||||
fronts: Record<PlayerId, FrontView>;
|
||||
/** Viewer's hand ids grouped by kind (null for spectators). */
|
||||
myHand: { kind: Kind; ids: string[] }[] | null;
|
||||
round: {
|
||||
no: number;
|
||||
starter: PlayerId;
|
||||
holder: PlayerId | null;
|
||||
lastPasser: PlayerId | null;
|
||||
claim: Kind | null;
|
||||
seen: PlayerId[];
|
||||
peeked: boolean;
|
||||
/** Empty when claimHistory is off. */
|
||||
claims: ClaimEntry[];
|
||||
passes: number;
|
||||
};
|
||||
/** The passed card: id/kind only for players who have seen it. */
|
||||
card: { known: true; id: string; kind: Kind } | { known: false } | null;
|
||||
/** What the viewer can do right now (null for spectators / when it is not the viewer's move). */
|
||||
my: {
|
||||
canOffer: boolean;
|
||||
offerTargets: PlayerId[];
|
||||
canCall: boolean;
|
||||
canPeek: boolean;
|
||||
peekBlocked: string | null;
|
||||
canPass: boolean;
|
||||
passTargets: PlayerId[];
|
||||
canSay: boolean;
|
||||
} | null;
|
||||
lastReveal: LastReveal | null;
|
||||
away: Record<PlayerId, boolean>;
|
||||
said: Said[];
|
||||
removedCount: number;
|
||||
turnStartedAt: number;
|
||||
turnMs: number;
|
||||
seq: number;
|
||||
final: CockroachFinal | null;
|
||||
/** After the game: every hand and the removed cards. */
|
||||
revealed: { hands: Record<PlayerId, string[]>; removed: string[] } | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
export const kindOf = (id: string): Kind => {
|
||||
const k = id.slice(0, id.lastIndexOf('-')) as Kind;
|
||||
if (!KINDS.includes(k)) throw new Error(`cockroach-poker: bad card id ${id}`);
|
||||
return k;
|
||||
};
|
||||
export const allCardIds = (): string[] => KINDS.flatMap((k) => Array.from({ length: PER_KIND }, (_, i) => `${k}-${i}`));
|
||||
const isKind = (k: unknown): k is Kind => typeof k === 'string' && (KINDS as readonly string[]).includes(k);
|
||||
|
||||
export function loseCountFor(o: CockroachOptions, players: number): number {
|
||||
return o.loseCount === 'auto' ? (players === 2 ? 5 : 4) : o.loseCount;
|
||||
}
|
||||
|
||||
/** Hand sizes dealt one at a time from the first player (§4 table). */
|
||||
export function handSizes(players: number): number[] {
|
||||
const total = players === 2 ? KINDS.length * PER_KIND - REMOVED_2P : KINDS.length * PER_KIND;
|
||||
return Array.from({ length: players }, (_, i) => Math.floor(total / players) + (i < total % players ? 1 : 0));
|
||||
}
|
||||
|
||||
export function countKinds(ids: readonly string[]): Record<Kind, number> {
|
||||
const out = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
|
||||
for (const id of ids) out[kindOf(id)] += 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const finished = (s: CockroachState) => s.final !== null;
|
||||
|
||||
/** Seats clockwise starting after `p`. */
|
||||
function after(s: CockroachState, p: PlayerId): PlayerId[] {
|
||||
const i = s.order.indexOf(p);
|
||||
return s.order.map((_, k) => s.order[(i + 1 + k) % s.order.length]!).filter((x) => x !== p);
|
||||
}
|
||||
|
||||
function waiting(s: CockroachState): PlayerId[] {
|
||||
if (finished(s)) return [];
|
||||
return [s.round.holder ?? s.round.starter];
|
||||
}
|
||||
|
||||
function passTargets(s: CockroachState, holder: PlayerId): PlayerId[] {
|
||||
const r = s.round;
|
||||
if (s.order.length === 2) return s.options.twoPlayerPass && r.claims.length === 1 ? after(s, holder) : [];
|
||||
return after(s, holder).filter((p) => !r.seen.includes(p));
|
||||
}
|
||||
|
||||
/** Korean reason why the current holder may not peek, or null when peeking is allowed. */
|
||||
function peekBlock(s: CockroachState): string | null {
|
||||
const r = s.round;
|
||||
if (r.peeked) return '이미 카드를 봤어요.';
|
||||
if (s.order.length === 2) {
|
||||
if (!s.options.twoPlayerPass) return '2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.';
|
||||
if (r.claims.length > 1) return '되돌려 받은 카드는 다시 넘길 수 없어요. 진짜인지 거짓인지 골라 주세요.';
|
||||
return null;
|
||||
}
|
||||
return passTargets(s, r.holder!).length === 0 ? '모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.' : null;
|
||||
}
|
||||
|
||||
function turnMs(s: CockroachState): number {
|
||||
const p = waiting(s)[0];
|
||||
return p && s.away[p] ? AWAY_MS : s.options.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
function newRound(s: CockroachState, starter: PlayerId): void {
|
||||
s.round = { no: s.round.no + 1, starter, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] };
|
||||
s.sayCount = Object.fromEntries(s.order.map((p) => [p, 0]));
|
||||
}
|
||||
|
||||
function placings(s: CockroachState, loser: PlayerId): PlayerId[][] {
|
||||
const key = (p: PlayerId) => {
|
||||
const c = countKinds(s.fronts[p]!);
|
||||
return [Math.max(...Object.values(c)), s.fronts[p]!.length] as const;
|
||||
};
|
||||
const winners = s.order.filter((p) => p !== loser).sort((a, b) => {
|
||||
const [ma, ta] = key(a);
|
||||
const [mb, tb] = key(b);
|
||||
return ma - mb || ta - tb;
|
||||
});
|
||||
const groups: PlayerId[][] = [];
|
||||
for (const p of winners) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && key(last[0]!)[0] === key(p)[0] && key(last[0]!)[1] === key(p)[1]) last.push(p);
|
||||
else groups.push([p]);
|
||||
}
|
||||
groups.push([loser]);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function finish(s: CockroachState, loser: PlayerId, reason: LossReason, losingKind: Kind | null, events: GameEvent[]): void {
|
||||
let summary: string;
|
||||
switch (reason) {
|
||||
case 'four':
|
||||
summary = `앞마당에 ${KIND_KO[losingKind!]} ${s.loseCount}장이 모였어요! 그 사람이 지고, 나머지 모두 이겼어요`;
|
||||
break;
|
||||
case 'eight':
|
||||
summary = '앞마당에 벌레 8종이 모두 모였어요! 그 사람이 지고, 나머지 모두 이겼어요';
|
||||
break;
|
||||
case 'emptyHand':
|
||||
summary = '카드를 건넬 차례인데 손에 카드가 없어서 졌어요. 나머지 모두 이겼어요';
|
||||
break;
|
||||
case 'left':
|
||||
summary = '한 사람이 게임을 나가서 끝났어요. 나머지 모두 이겼어요';
|
||||
break;
|
||||
}
|
||||
s.final = {
|
||||
loser,
|
||||
reason,
|
||||
losingKind,
|
||||
placings: placings(s, loser),
|
||||
fronts: Object.fromEntries(s.order.map((p) => [p, countKinds(s.fronts[p]!)])),
|
||||
summary,
|
||||
};
|
||||
events.push({ type: 'gameEnded', loser, reason, ...(losingKind ? { kind: losingKind } : {}) });
|
||||
}
|
||||
|
||||
/** The concrete move a timeout stands for (§8.5). Uses the RNG for the starter / unpeeked holder. */
|
||||
export function autoAction(s: CockroachState, player: PlayerId, rng: Rng): TurnAction {
|
||||
const r = s.round;
|
||||
if (r.holder === null) {
|
||||
const hand = s.hands[player]!;
|
||||
return { type: 'offer', cardId: hand[rng.int(hand.length)]!, to: after(s, player)[0]!, claim: KINDS[rng.int(KINDS.length)]! };
|
||||
}
|
||||
if (!r.peeked) return { type: 'call', call: rng.int(2) === 0 ? 'true' : 'false' };
|
||||
return { type: 'pass', to: passTargets(s, player)[0]!, claim: kindOf(r.card!) };
|
||||
}
|
||||
|
||||
function validateTurn(s: CockroachState, actor: PlayerId, a: TurnAction): Validation {
|
||||
const r = s.round;
|
||||
switch (a.type) {
|
||||
case 'offer': {
|
||||
if (r.holder !== null) return fail('지금은 카드를 건넬 때가 아니에요.');
|
||||
if (r.starter !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||
if (!s.hands[actor]!.includes(a.cardId)) return fail('내 손에 없는 카드예요.');
|
||||
if (a.to === actor) return fail('나에게는 건넬 수 없어요.');
|
||||
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
|
||||
}
|
||||
case 'call':
|
||||
if (r.holder !== actor) return fail('지금은 내가 판정할 차례가 아니에요.');
|
||||
if (r.peeked) return fail('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.');
|
||||
return a.call === 'true' || a.call === 'false' ? ok : fail('진짜인지 거짓인지 골라 주세요.');
|
||||
case 'peek': {
|
||||
if (r.holder !== actor) return fail('지금은 내가 카드를 받을 차례가 아니에요.');
|
||||
const why = peekBlock(s);
|
||||
return why ? fail(why) : ok;
|
||||
}
|
||||
case 'pass': {
|
||||
if (r.holder !== actor) return fail('지금은 내가 카드를 넘길 차례가 아니에요.');
|
||||
if (!r.peeked) return fail('먼저 카드를 몰래 봐야 넘길 수 있어요.');
|
||||
if (a.to === actor) return fail('나에게는 넘길 수 없어요.');
|
||||
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (!passTargets(s, actor).includes(a.to)) return fail('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
|
||||
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTurn(s: CockroachState, actor: PlayerId, a: TurnAction, events: GameEvent[], auto: boolean): void {
|
||||
const r = s.round;
|
||||
switch (a.type) {
|
||||
case 'offer': {
|
||||
const hand = s.hands[actor]!;
|
||||
hand.splice(hand.indexOf(a.cardId), 1);
|
||||
r.card = a.cardId;
|
||||
r.holder = a.to;
|
||||
r.lastPasser = actor;
|
||||
r.claim = a.claim;
|
||||
r.seen = [actor];
|
||||
r.peeked = false;
|
||||
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
|
||||
events.push({ type: 'offer', from: actor, to: a.to, claim: a.claim, auto });
|
||||
break;
|
||||
}
|
||||
case 'peek':
|
||||
if (!r.seen.includes(actor)) r.seen.push(actor);
|
||||
r.peeked = true;
|
||||
events.push({ type: 'peek', player: actor });
|
||||
break;
|
||||
case 'pass':
|
||||
r.lastPasser = actor;
|
||||
r.holder = a.to;
|
||||
r.claim = a.claim;
|
||||
r.peeked = false;
|
||||
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
|
||||
events.push({ type: 'pass', from: actor, to: a.to, claim: a.claim, auto });
|
||||
break;
|
||||
case 'call': {
|
||||
const card = r.card!;
|
||||
const kind = kindOf(card);
|
||||
const truth = kind === r.claim;
|
||||
const correct = (a.call === 'true') === truth;
|
||||
const takenBy = correct ? r.lastPasser! : actor;
|
||||
s.fronts[takenBy]!.push(card);
|
||||
r.card = null;
|
||||
r.holder = null;
|
||||
s.lastReveal = { card, kind, claim: r.claim!, call: a.call, caller: actor, passer: r.lastPasser!, correct, takenBy, auto, round: r.no };
|
||||
events.push({ type: 'reveal', card, kind, claim: r.claim, call: a.call, caller: actor, correct, takenBy, auto });
|
||||
const counts = countKinds(s.fronts[takenBy]!);
|
||||
if (counts[kind] >= s.loseCount) finish(s, takenBy, 'four', kind, events);
|
||||
else if (s.options.eightKindsRule && KINDS.every((k) => counts[k] > 0)) finish(s, takenBy, 'eight', null, events);
|
||||
else {
|
||||
newRound(s, takenBy);
|
||||
if (s.hands[takenBy]!.length === 0) finish(s, takenBy, 'emptyHand', null, events);
|
||||
else events.push({ type: 'round', starter: takenBy });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const cockroachPoker: GameDefinition<CockroachState, CockroachAction, CockroachView, CockroachOptions> = {
|
||||
id: 'cockroach-poker',
|
||||
nameKo: '바퀴벌레 포커',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 6,
|
||||
stateVersion: 1,
|
||||
defaultOptions: cockroachOptionsSchema.parse({}),
|
||||
optionsSchema: cockroachOptionsSchema as unknown as z.ZodType<CockroachOptions>,
|
||||
actionSchema: cockroachActionSchema as unknown as z.ZodType<CockroachAction>,
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 2 || players.length > 6) throw new Error(`cockroach-poker: ${players.length} players not allowed`);
|
||||
const deck = rng.shuffle(allCardIds());
|
||||
const removed = players.length === 2 ? deck.splice(0, REMOVED_2P) : [];
|
||||
const first = players[rng.int(players.length)]!;
|
||||
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
|
||||
const hands = per<string[]>(() => []);
|
||||
// Deal one at a time clockwise from the first player, so the first player gets the most (§4).
|
||||
const fi = players.indexOf(first);
|
||||
deck.forEach((id, i) => hands[players[(fi + i) % players.length]!]!.push(id));
|
||||
return {
|
||||
options,
|
||||
loseCount: loseCountFor(options, players.length),
|
||||
order: [...players],
|
||||
hands,
|
||||
fronts: per(() => []),
|
||||
removed,
|
||||
round: { no: 1, starter: first, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] },
|
||||
lastReveal: null,
|
||||
timeoutsInRow: per(() => 0),
|
||||
away: per(() => false),
|
||||
said: [],
|
||||
sayCount: per(() => 0),
|
||||
turnStartedAt: now,
|
||||
seq: 0,
|
||||
final: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (finished(s)) return fail('게임이 이미 끝났어요.');
|
||||
switch (a.type) {
|
||||
case 'leave':
|
||||
return ok;
|
||||
case 'timeout':
|
||||
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
|
||||
case 'say':
|
||||
if (!s.options.quickChat) return fail('이 방은 빠른 말을 쓰지 않아요.');
|
||||
if (!PHRASES.includes(a.phrase)) return fail('보낼 말을 골라 주세요.');
|
||||
return (s.sayCount[actor] ?? 0) >= SAY_PER_ROUND ? fail(`빠른 말은 한 라운드에 ${SAY_PER_ROUND}번까지 할 수 있어요.`) : ok;
|
||||
default:
|
||||
return validateTurn(s, actor, a);
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const events: GameEvent[] = [];
|
||||
s.seq += 1;
|
||||
switch (a.type) {
|
||||
case 'leave':
|
||||
events.push({ type: 'left', player: actor });
|
||||
finish(s, actor, 'left', null, events);
|
||||
break;
|
||||
case 'say':
|
||||
s.sayCount[actor] = (s.sayCount[actor] ?? 0) + 1;
|
||||
s.said = [...s.said, { seq: s.seq, player: actor, phrase: a.phrase }].slice(-SAID_KEEP);
|
||||
events.push({ type: 'say', player: actor, phrase: a.phrase });
|
||||
// Chatting does not restart the turn clock.
|
||||
return { state: s, events };
|
||||
case 'timeout': {
|
||||
const concrete = autoAction(s, actor, rng);
|
||||
const v = validateTurn(s, actor, concrete);
|
||||
if (!v.ok) throw new Error(`cockroach-poker: auto action rejected: ${v.reason}`);
|
||||
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
|
||||
events.push({ type: 'timeout', player: actor });
|
||||
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
|
||||
s.away[actor] = true;
|
||||
events.push({ type: 'away', player: actor });
|
||||
}
|
||||
applyTurn(s, actor, concrete, events, true);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
s.timeoutsInRow[actor] = 0;
|
||||
s.away[actor] = false;
|
||||
applyTurn(s, actor, a, events, false);
|
||||
}
|
||||
s.turnStartedAt = now;
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const me = viewer && s.order.includes(viewer) ? viewer : null;
|
||||
const over = finished(s);
|
||||
const r = s.round;
|
||||
const fronts: Record<PlayerId, FrontView> = {};
|
||||
for (const p of s.order) {
|
||||
const counts = countKinds(s.fronts[p]!);
|
||||
const kinds = KINDS.filter((k) => counts[k] > 0).sort((a, b) => counts[b] - counts[a] || KINDS.indexOf(a) - KINDS.indexOf(b));
|
||||
const level = Object.fromEntries(
|
||||
KINDS.map((k) => [k, counts[k] >= s.loseCount - 1 ? 'danger' : counts[k] >= Math.max(2, s.loseCount - 2) ? 'warn' : 'ok']),
|
||||
) as Record<Kind, DangerLevel>;
|
||||
fronts[p] = { counts, kinds, level, total: s.fronts[p]!.length, distinct: kinds.length };
|
||||
}
|
||||
let myHand: CockroachView['myHand'] = null;
|
||||
if (me) {
|
||||
const ids = s.hands[me]!;
|
||||
myHand = KINDS.map((kind) => ({ kind, ids: ids.filter((id) => kindOf(id) === kind).sort() })).filter((g) => g.ids.length > 0);
|
||||
}
|
||||
let card: CockroachView['card'] = null;
|
||||
if (r.card && !over) card = me && r.seen.includes(me) ? { known: true, id: r.card, kind: kindOf(r.card) } : { known: false };
|
||||
let my: CockroachView['my'] = null;
|
||||
if (me && !over) {
|
||||
const offering = r.holder === null && r.starter === me;
|
||||
const holding = r.holder === me;
|
||||
const why = holding ? peekBlock(s) : null;
|
||||
my = {
|
||||
canOffer: offering,
|
||||
offerTargets: offering ? after(s, me) : [],
|
||||
canCall: holding && !r.peeked,
|
||||
canPeek: holding && why === null,
|
||||
peekBlocked: holding && !r.peeked ? why : null,
|
||||
canPass: holding && r.peeked,
|
||||
passTargets: holding && r.peeked ? passTargets(s, me) : [],
|
||||
canSay: s.options.quickChat && (s.sayCount[me] ?? 0) < SAY_PER_ROUND,
|
||||
};
|
||||
}
|
||||
return {
|
||||
options: s.options,
|
||||
loseCount: s.loseCount,
|
||||
me,
|
||||
order: s.order,
|
||||
phase: over ? 'finished' : r.holder === null ? 'offer' : 'respond',
|
||||
handCounts: Object.fromEntries(s.order.map((p) => [p, s.hands[p]!.length])),
|
||||
fronts,
|
||||
myHand,
|
||||
round: {
|
||||
no: r.no,
|
||||
starter: r.starter,
|
||||
holder: r.holder,
|
||||
lastPasser: r.lastPasser,
|
||||
claim: r.claim,
|
||||
seen: [...r.seen],
|
||||
peeked: r.peeked,
|
||||
claims: s.options.claimHistory ? r.claims.map((c) => ({ ...c })) : [],
|
||||
passes: Math.max(0, r.claims.length - 1),
|
||||
},
|
||||
card,
|
||||
my,
|
||||
lastReveal: s.lastReveal,
|
||||
away: s.away,
|
||||
said: s.said,
|
||||
removedCount: s.removed.length,
|
||||
turnStartedAt: s.turnStartedAt,
|
||||
turnMs: turnMs(s),
|
||||
seq: s.seq,
|
||||
final: s.final,
|
||||
revealed: over ? { hands: s.hands, removed: s.removed } : null,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers: waiting,
|
||||
|
||||
deadline(s) {
|
||||
return finished(s) ? null : s.turnStartedAt + turnMs(s);
|
||||
},
|
||||
|
||||
timeoutPlayers: waiting,
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
/** Leaving forfeits: the leaver becomes the one loser and everyone else wins. */
|
||||
onLeave(s, player) {
|
||||
if (!s.order.includes(player) || finished(s)) return null;
|
||||
return { type: 'leave' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
const f = s.final;
|
||||
if (!f) return null;
|
||||
// Everyone but the loser wins together (§6); the detailed placings stay in the view.
|
||||
return {
|
||||
ranking: [s.order.filter((p) => p !== f.loser), [f.loser]],
|
||||
scores: Object.fromEntries(s.order.map((p) => [p, p === f.loser ? 0 : 1])),
|
||||
summary: f.summary,
|
||||
reason: f.reason === 'left' ? 'resign' : 'normal',
|
||||
};
|
||||
},
|
||||
|
||||
/** Game moves only (quick chat is left out); same-kind cards are interchangeable, so one id per kind. */
|
||||
legalActions(s, p) {
|
||||
if (!s.order.includes(p) || finished(s)) return [];
|
||||
const r = s.round;
|
||||
const out: CockroachAction[] = [];
|
||||
if (r.holder === null) {
|
||||
if (r.starter !== p) return [];
|
||||
const seenKinds = new Set<Kind>();
|
||||
for (const id of s.hands[p]!) {
|
||||
const k = kindOf(id);
|
||||
if (seenKinds.has(k)) continue;
|
||||
seenKinds.add(k);
|
||||
for (const to of after(s, p)) for (const claim of KINDS) out.push({ type: 'offer', cardId: id, to, claim });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (r.holder !== p) return [];
|
||||
if (r.peeked) {
|
||||
for (const to of passTargets(s, p)) for (const claim of KINDS) out.push({ type: 'pass', to, claim });
|
||||
return out;
|
||||
}
|
||||
out.push({ type: 'call', call: 'true' }, { type: 'call', call: 'false' });
|
||||
if (peekBlock(s) === null) out.push({ type: 'peek' });
|
||||
return out;
|
||||
},
|
||||
|
||||
/** `player`'s hand, the passed card while only they have seen it, and the removed cards (secret to all). */
|
||||
secretsOf(s, player) {
|
||||
if (finished(s)) return [];
|
||||
const out = [...(s.hands[player] ?? []), ...s.removed];
|
||||
const r = s.round;
|
||||
if (r.card && r.seen.length === 1 && r.seen[0] === player) out.push(r.card);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
678
packages/games/src/dalmuti/dalmuti.test.ts
Normal file
678
packages/games/src/dalmuti/dalmuti.test.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
|
||||
import {
|
||||
FULL_DECK,
|
||||
allCardIds,
|
||||
dalmuti,
|
||||
deckFor,
|
||||
drawFirstRanks,
|
||||
finalOf,
|
||||
type DCard,
|
||||
type DalmutiAction,
|
||||
type DalmutiOptions,
|
||||
type DalmutiState,
|
||||
type DalmutiView,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const BY_ID = new Map(FULL_DECK.map((c) => [c.id, c]));
|
||||
const card = (id: string): DCard => {
|
||||
const c = BY_ID.get(id);
|
||||
if (!c) throw new Error(`no card ${id}`);
|
||||
return c;
|
||||
};
|
||||
/** k-th copy of number n ("c7-2"). */
|
||||
const C = (n: number, k = 1) => `c${n}-${k}`;
|
||||
const J1 = 'j1';
|
||||
const J2 = 'j2';
|
||||
const P5 = ['A', 'B', 'C', 'D', 'E'];
|
||||
const P4 = ['A', 'B', 'C', 'D'];
|
||||
let clock = 1_000_000;
|
||||
|
||||
function make(opts: Partial<DalmutiOptions> = {}, players = P5, seed = 1): DalmutiState {
|
||||
const options = dalmuti.optionsSchema.parse(opts);
|
||||
return dalmuti.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
}
|
||||
|
||||
/** Seats `order`, gives hands (rest of the deck → discard) and starts a trick (optionally with a top set). */
|
||||
function rig(
|
||||
s: DalmutiState,
|
||||
r: { order?: string[]; hands: Record<string, string[]>; current?: string; top?: { by: string; ids: string[] } },
|
||||
): DalmutiState {
|
||||
if (r.order) s.order = [...r.order];
|
||||
for (const p of s.players) s.hands[p] = (r.hands[p] ?? []).map(card);
|
||||
const used = new Set([...Object.values(r.hands).flat(), ...(r.top?.ids ?? [])]);
|
||||
s.discard = deckFor(s.options.removeHighCards)
|
||||
.filter((c) => !used.has(c.id))
|
||||
.map((c) => c.id);
|
||||
s.phase = { kind: 'playing' };
|
||||
s.finishOrder = [];
|
||||
s.taxLog = [];
|
||||
const lead = r.current ?? s.order[0]!;
|
||||
s.trick = { leader: r.top?.by ?? lead, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
|
||||
if (r.top) {
|
||||
const cards = r.top.ids.map(card);
|
||||
const nums = cards.filter((c) => c.n !== 13);
|
||||
const top = { cardIds: r.top.ids, count: cards.length, rank: nums.length ? nums[0]!.n : 13, by: r.top.by };
|
||||
s.trick.top = top;
|
||||
s.trick.pile = [top];
|
||||
}
|
||||
s.current = lead;
|
||||
return s;
|
||||
}
|
||||
|
||||
const act = (s: DalmutiState, p: string, a: DalmutiAction): DalmutiState => {
|
||||
const v = dalmuti.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return dalmuti.apply(s, p, a, { rng: SeededRng.fromSeed([3, 3, 3, 3]), now: (clock += 1000) }).state;
|
||||
};
|
||||
const play = (s: DalmutiState, p: string, ids: string[]) => act(s, p, { type: 'play', cardIds: ids });
|
||||
const pass = (s: DalmutiState, p: string) => act(s, p, { type: 'pass' });
|
||||
const reject = (s: DalmutiState, p: string, a: DalmutiAction): string | null => {
|
||||
const v = dalmuti.validate(s, p, a);
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
const ids = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.id);
|
||||
const nums = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.n);
|
||||
const confirmAll = (s: DalmutiState, except: Record<string, DalmutiAction> = {}) => {
|
||||
for (const p of s.players) s = act(s, p, except[p] ?? { type: 'confirmHand' });
|
||||
return s;
|
||||
};
|
||||
|
||||
/** A revolutionCheck state with given hands (5 players, order A..E unless given). */
|
||||
function atRevolutionCheck(hands: Record<string, string[]>, opts: Partial<DalmutiOptions> = {}, order = P5): DalmutiState {
|
||||
const s = rig(make(opts), { order, hands });
|
||||
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Splits the full deck so that the given cards land where wanted and the rest fill hands round-robin. */
|
||||
function handsWith(order: string[], fixed: Record<string, string[]>): Record<string, string[]> {
|
||||
const taken = new Set(Object.values(fixed).flat());
|
||||
const rest = FULL_DECK.map((c) => c.id).filter((id) => !taken.has(id));
|
||||
const hands: Record<string, string[]> = Object.fromEntries(order.map((p) => [p, [...(fixed[p] ?? [])]]));
|
||||
const per = Math.floor(80 / order.length);
|
||||
for (const p of order) while (hands[p]!.length < per) hands[p]!.push(rest.shift()!);
|
||||
for (let i = 0; rest.length; i++) hands[order[i % order.length]!]!.push(rest.shift()!);
|
||||
return hands;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- deck / setup
|
||||
|
||||
describe('덱과 셋업 (§3, §4)', () => {
|
||||
test('80 cards: n copies of n, 2 jesters; removeHighCards → 38', () => {
|
||||
expect(FULL_DECK).toHaveLength(80);
|
||||
for (let n = 1; n <= 12; n++) expect(FULL_DECK.filter((c) => c.n === n)).toHaveLength(n);
|
||||
expect(FULL_DECK.filter((c) => c.n === 13)).toHaveLength(2);
|
||||
expect(new Set(FULL_DECK.map((c) => c.id)).size).toBe(80);
|
||||
expect(deckFor(true)).toHaveLength(38);
|
||||
expect(deckFor(true).some((c) => c.n >= 9 && c.n <= 12)).toBe(false);
|
||||
});
|
||||
|
||||
test('6 players: 14, 14, 13, 13, 13, 13 in rank order; all deals add up', () => {
|
||||
const s = make({}, ['A', 'B', 'C', 'D', 'E', 'F'], 3);
|
||||
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([14, 14, 13, 13, 13, 13]);
|
||||
const sizes: Record<number, number[]> = { 4: [20, 20, 20, 20], 5: [16, 16, 16, 16, 16], 7: [12, 12, 12, 11, 11, 11, 11], 8: [10, 10, 10, 10, 10, 10, 10, 10] };
|
||||
for (const [n, want] of Object.entries(sizes)) {
|
||||
const t = make({}, Array.from({ length: Number(n) }, (_, i) => `P${i}`), 4);
|
||||
expect(t.order.map((p) => t.hands[p]!.length)).toEqual(want);
|
||||
expect(allCardIds(t).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
|
||||
}
|
||||
const r = make({ removeHighCards: true }, Array.from({ length: 8 }, (_, i) => `P${i}`), 4);
|
||||
expect(r.order.map((p) => r.hands[p]!.length)).toEqual([5, 5, 5, 5, 5, 5, 4, 4]);
|
||||
});
|
||||
|
||||
test('player range 4..8', () => {
|
||||
expect(dalmuti.minPlayers).toBe(4);
|
||||
expect(dalmuti.maxPlayers).toBe(8);
|
||||
expect(() => make({}, ['A', 'B', 'C'])).toThrow();
|
||||
expect(() => make({}, 'ABCDEFGHI'.split(''))).toThrow();
|
||||
});
|
||||
|
||||
test('first rank draw: same seed → same result; ties re-draw until the order is fixed (lower first)', () => {
|
||||
expect(JSON.stringify(make({}, P5, 9))).toBe(JSON.stringify(make({}, P5, 9)));
|
||||
let sawTie = 0;
|
||||
for (let seed = 1; seed <= 400; seed++) {
|
||||
const players = Array.from({ length: 8 }, (_, i) => `P${i}`);
|
||||
const { order, draws } = drawFirstRanks(players, FULL_DECK as DCard[], SeededRng.fromSeed(seedN(seed)));
|
||||
expect([...order].sort()).toEqual([...players].sort());
|
||||
if (Object.values(draws).some((d) => d.length > 1)) sawTie++;
|
||||
// Lexicographic on the draw sequence: tied players share a prefix and re-drew together.
|
||||
for (let i = 0; i + 1 < order.length; i++) {
|
||||
const a = draws[order[i]!]!;
|
||||
const b = draws[order[i + 1]!]!;
|
||||
const k = a.findIndex((x, j) => x !== b[j]);
|
||||
expect(k).toBeGreaterThanOrEqual(0);
|
||||
expect(a[k]!).toBeLessThan(b[k]!);
|
||||
}
|
||||
}
|
||||
expect(sawTie).toBeGreaterThan(10);
|
||||
const s = make({}, P5, 9);
|
||||
expect(s.firstDraw).not.toBeNull();
|
||||
expect(make({ firstRanking: 'random' }, P5, 9).firstDraw).toBeNull();
|
||||
});
|
||||
|
||||
test('revolution on → everyone confirms first; off → tax right away', () => {
|
||||
expect(make().phase.kind).toBe('revolutionCheck');
|
||||
const s = make({ revolution: false });
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
const o = s.order as string[];
|
||||
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
|
||||
[o[4]!, o[0]!, 2],
|
||||
[o[3]!, o[1]!, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- tax
|
||||
|
||||
describe('세금 (§4-5)', () => {
|
||||
const bottomHand = [J1, J2, C(3, 1), C(3, 2), C(7, 1), C(9, 1), C(12, 1)];
|
||||
const hands = () => handsWith(P5, { E: bottomHand, D: [C(2, 1), C(5, 1), C(11, 1)] });
|
||||
|
||||
test('jesterTaxable off: 대농노 [광대, 광대, 3, 3, 7…] gives 3, 3; 소농노 gives its lowest number', () => {
|
||||
let s = atRevolutionCheck(hands(), { jesterTaxable: false });
|
||||
const dBefore = s.hands.D!.map((c) => c.n).filter((n) => n !== 13);
|
||||
s = confirmAll(s);
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
expect(s.taxLog[0]).toEqual({ kind: 'tax', from: 'E', to: 'A', cardIds: [C(3, 1), C(3, 2)] });
|
||||
expect(s.taxLog[1]!.from).toBe('D');
|
||||
expect(s.taxLog[1]!.to).toBe('B');
|
||||
expect(card(s.taxLog[1]!.cardIds[0]!).n).toBe(Math.min(...dBefore));
|
||||
expect(ids(s, 'E')).toContain(J1);
|
||||
expect(ids(s, 'A')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
|
||||
});
|
||||
|
||||
test('jesterTaxable on: the two jesters go', () => {
|
||||
const s = confirmAll(atRevolutionCheck(hands(), { jesterTaxable: true }));
|
||||
expect(s.taxLog[0]!.cardIds).toEqual([J1, J2]);
|
||||
});
|
||||
|
||||
test('returning: 대달무티 may give back what it just got; wrong count refused; both done → A leads', () => {
|
||||
let s = confirmAll(atRevolutionCheck(hands()));
|
||||
const got = s.taxLog[0]!.cardIds;
|
||||
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!] })).toBe('돌려줄 카드 2장을 골라 주세요.');
|
||||
expect(reject(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!, ids(s, 'B')[1]!] })).toBe('돌려줄 카드 1장을 골라 주세요.');
|
||||
expect(reject(s, 'C', { type: 'returnTax', cardIds: [ids(s, 'C')[0]!] })).toBe('돌려줄 카드가 없어요.');
|
||||
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!, ids(s, 'E')[0]!] })).toBe('손에 없는 카드예요.');
|
||||
s = act(s, 'A', { type: 'returnTax', cardIds: [...got] });
|
||||
expect(ids(s, 'E')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
expect(reject(s, 'A', { type: 'returnTax', cardIds: [...got] })).toBe('이미 카드를 돌려줬어요.');
|
||||
s = act(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!] });
|
||||
expect(s.phase.kind).toBe('playing');
|
||||
expect(s.current).toBe('A');
|
||||
expect(s.hands.A!.length).toBe(16);
|
||||
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
|
||||
});
|
||||
|
||||
test('4 players: 소달무티 ↔ 소농노 and 대달무티 ↔ 대농노 only', () => {
|
||||
const s = confirmAll(atRevolutionCheck(handsWith(P4, {}), {}, P4));
|
||||
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
|
||||
['D', 'A', 2],
|
||||
['C', 'B', 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- revolution
|
||||
|
||||
describe('혁명 (§4-4)', () => {
|
||||
test('a commoner with both jesters declares → no tax, same ranks, 대달무티 leads', () => {
|
||||
const s0 = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
|
||||
expect(reject(s0, 'A', { type: 'confirmHand', declareRevolution: true })).toBe('광대 2장이 있어야 혁명할 수 있어요.');
|
||||
const s = confirmAll(s0, { C: { type: 'confirmHand', declareRevolution: true } });
|
||||
expect(s.revolution).toBe('revolution');
|
||||
expect(s.revolutionBy).toBe('C');
|
||||
expect(s.taxLog).toEqual([]);
|
||||
expect(s.order).toEqual(P5);
|
||||
expect(s.phase.kind).toBe('playing');
|
||||
expect(s.current).toBe('A');
|
||||
});
|
||||
|
||||
test('대농노 declares → great revolution: order reversed, the new 대달무티 leads, no tax', () => {
|
||||
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2] })), { E: { type: 'confirmHand', declareRevolution: true } });
|
||||
expect(s.revolution).toBe('great');
|
||||
expect(s.order).toEqual(['E', 'D', 'C', 'B', 'A']);
|
||||
expect(s.current).toBe('E');
|
||||
expect(s.taxLog).toEqual([]);
|
||||
const v = dalmuti.view(s, 'A');
|
||||
expect(v.seats[0]!).toMatchObject({ id: 'E', title: '대달무티' });
|
||||
expect(v.seats[4]!).toMatchObject({ id: 'A', title: '대농노' });
|
||||
});
|
||||
|
||||
test('not declaring is allowed: holder confirms → normal tax', () => {
|
||||
const s = confirmAll(atRevolutionCheck(handsWith(P5, { B: [J1, J2] })));
|
||||
expect(s.revolution).toBe('none');
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
});
|
||||
|
||||
test('no info leak: the check phase looks the same with or without a holder; canDeclareRevolution only in the holder view', () => {
|
||||
const withHolder = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
|
||||
const without = atRevolutionCheck(handsWith(P5, { C: [J1], D: [J2] }));
|
||||
for (const s of [withHolder, without]) {
|
||||
expect(s.phase.kind).toBe('revolutionCheck');
|
||||
for (const p of ['A', 'B', 'D', 'E']) {
|
||||
const v = JSON.stringify(dalmuti.view(s, p));
|
||||
expect(v).not.toContain('canDeclareRevolution');
|
||||
}
|
||||
expect(JSON.stringify(dalmuti.view(s, null))).not.toContain('canDeclareRevolution');
|
||||
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
|
||||
}
|
||||
expect(dalmuti.view(withHolder, 'C').my!.canDeclareRevolution).toBe(true);
|
||||
expect(JSON.stringify(dalmuti.view(without, 'C'))).not.toContain('canDeclareRevolution');
|
||||
// Other views of both states differ only in the hidden hand contents (same shape, same keys).
|
||||
const keys = (v: DalmutiView) => JSON.stringify({ ...v, my: v.my ? Object.keys(v.my) : null });
|
||||
expect(keys(dalmuti.view(withHolder, 'A')).replace(/"hand":\[.*?\]/, '')).toBe(keys(dalmuti.view(without, 'A')).replace(/"hand":\[.*?\]/, ''));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- playing
|
||||
|
||||
describe('트릭 진행 (§5.1)', () => {
|
||||
test('following 9×3: 8×2 refused (count), 9×3 refused (same number), 4+4+광대 ok, 광대×2+1 ok (1×3)', () => {
|
||||
const s = rig(make(), {
|
||||
order: P5,
|
||||
hands: { A: [C(8, 1), C(8, 2), C(9, 4), C(9, 5), C(9, 6), C(4, 1), C(4, 2), J1, C(1, 1), J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] },
|
||||
top: { by: 'E', ids: [C(9, 1), C(9, 2), C(9, 3)] },
|
||||
current: 'A',
|
||||
});
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(8, 1), C(8, 2)] })).toBe('앞사람과 같은 장수(3장)로 내야 해요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 4), C(9, 5), C(9, 6)] })).toBe('더 낮은 숫자만 낼 수 있어요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(8, 1), J1] })).toBe('같은 숫자끼리만 낼 수 있어요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(4, 2), J1] })).toBeNull();
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [J1, J2, C(1, 1)] })).toBeNull();
|
||||
const after = play(s, 'A', [J1, J2, C(1, 1)]);
|
||||
expect(after.trick.top).toMatchObject({ count: 3, rank: 1, by: 'A' });
|
||||
// The view offers the same choices.
|
||||
const ch = dalmuti.view(s, 'A').my!.choices;
|
||||
expect(ch.find((c) => c.rank === 4)).toEqual({ rank: 4, have: 2, counts: [3], needsJester: true });
|
||||
expect(ch.find((c) => c.rank === 1)).toEqual({ rank: 1, have: 1, counts: [3], needsJester: true });
|
||||
expect(ch.find((c) => c.rank === 8)).toEqual({ rank: 8, have: 2, counts: [3], needsJester: true });
|
||||
expect(ch.some((c) => c.rank === 9 || c.rank === 13)).toBe(false);
|
||||
});
|
||||
|
||||
test('jester alone: on 12×1 refused (13 is worse); as a lead allowed (13×1), then any single number beats it', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, top: { by: 'E', ids: [C(12, 1)] }, current: 'A' });
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [J1] })).toBe('더 낮은 숫자만 낼 수 있어요.');
|
||||
s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, current: 'A' });
|
||||
s = play(s, 'A', [J1]);
|
||||
expect(s.trick.top).toMatchObject({ count: 1, rank: 13 });
|
||||
expect(reject(s, 'B', { type: 'play', cardIds: [C(12, 3)] })).toBeNull();
|
||||
});
|
||||
|
||||
test('a lead cannot pass; leads may be any size of one number', () => {
|
||||
const s = rig(make(), { order: P5, hands: { A: [C(7, 1), C(7, 2), C(7, 3), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(8, 1)], E: [C(10, 1)] }, current: 'A' });
|
||||
expect(reject(s, 'A', { type: 'pass' })).toBe('새로 시작할 때는 패스할 수 없어요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(7, 1), C(7, 2), C(7, 3), J1] })).toBeNull();
|
||||
expect(dalmuti.view(s, 'A').my!.choices.find((c) => c.rank === 7)!.counts).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('§5.3 example: everyone but D passes → D leads the next trick', () => {
|
||||
let s = rig(make(), {
|
||||
order: P5,
|
||||
hands: { A: [C(7, 1), C(7, 2), C(3, 1)], B: [C(5, 1), C(5, 2), C(9, 1)], C: [C(10, 1), C(11, 1)], D: [C(2, 1), J1, C(8, 1)], E: [C(12, 1), C(6, 1)] },
|
||||
});
|
||||
s = play(s, 'A', [C(7, 1), C(7, 2)]);
|
||||
s = play(s, 'B', [C(5, 1), C(5, 2)]);
|
||||
s = pass(s, 'C');
|
||||
s = play(s, 'D', [C(2, 1), J1]);
|
||||
for (const p of ['E', 'A', 'B']) s = pass(s, p);
|
||||
expect(s.current).toBe('C');
|
||||
expect(s.trick.top!.by).toBe('D');
|
||||
s = pass(s, 'C');
|
||||
expect(s.trick.top).toBeNull();
|
||||
expect(s.trick.leader).toBe('D');
|
||||
expect(s.current).toBe('D');
|
||||
expect(s.discard).toEqual(expect.arrayContaining([C(7, 1), C(5, 1), C(2, 1), J1]));
|
||||
});
|
||||
|
||||
test('going out with the top set → the next player with cards leads', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [C(3, 1)], B: [C(5, 1), C(5, 2)], C: [C(10, 1)], D: [C(8, 1)], E: [C(12, 1), C(12, 2)] }, current: 'E' });
|
||||
s = play(s, 'E', [C(12, 1)]);
|
||||
s = play(s, 'A', [C(3, 1)]); // A is out on the top set
|
||||
expect(s.finishOrder).toEqual(['A']);
|
||||
for (const p of ['B', 'C', 'D']) s = pass(s, p);
|
||||
expect(s.current).toBe('E');
|
||||
s = pass(s, 'E');
|
||||
expect(s.trick.top).toBeNull();
|
||||
expect(s.current).toBe('B');
|
||||
});
|
||||
|
||||
test('passLocksOut off: a passer may play when the turn comes back; on: skipped for the rest of the trick', () => {
|
||||
const hands = { A: [C(10, 1), C(1, 1)], B: [C(9, 1), C(2, 1)], C: [C(8, 1), C(12, 1)], D: [C(12, 2), C(12, 3)], E: [C(12, 4), C(12, 5)] };
|
||||
const run = (passLocksOut: boolean) => {
|
||||
let s = rig(make({ passLocksOut }), { order: P5, hands });
|
||||
s = play(s, 'A', [C(10, 1)]);
|
||||
s = pass(s, 'B');
|
||||
s = play(s, 'C', [C(8, 1)]);
|
||||
for (const p of ['D', 'E', 'A']) s = pass(s, p);
|
||||
return s;
|
||||
};
|
||||
const off = run(false);
|
||||
expect(off.current).toBe('B');
|
||||
expect(reject(off, 'B', { type: 'play', cardIds: [C(2, 1)] })).toBeNull();
|
||||
const on = run(true);
|
||||
expect(on.trick.top).toBeNull();
|
||||
expect(on.current).toBe('C');
|
||||
// Mid-trick, a locked-out player is skipped in the turn order.
|
||||
let m = rig(make({ passLocksOut: true }), { order: P5, hands });
|
||||
m = play(m, 'A', [C(10, 1)]);
|
||||
m = pass(m, 'B');
|
||||
m = play(m, 'C', [C(8, 1)]);
|
||||
m = pass(m, 'D');
|
||||
m = pass(m, 'E');
|
||||
m = play(m, 'A', [C(1, 1)]);
|
||||
expect(m.current).toBe('C');
|
||||
expect(dalmuti.view(m, null).seats.find((x) => x.id === 'B')!.lockedOut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- rounds / scoring
|
||||
|
||||
describe('라운드 종료와 점수 (§5.2, §6)', () => {
|
||||
test('one player left with cards → round over; that player is the next 대농노; next order = finish order', () => {
|
||||
let s = rig(make({ rounds: 3 }), { order: P5, hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(12, 1), C(12, 2)] }, current: 'D' });
|
||||
s = play(s, 'D', [C(6, 1)]);
|
||||
s = pass(s, 'E');
|
||||
s = play(s, 'A', [C(3, 1)]); // A out (1st)
|
||||
s = pass(s, 'B'); // B: 4 > 3
|
||||
s = pass(s, 'C');
|
||||
// D is out already; E passes → trick over, A is out → next with cards after A = B leads.
|
||||
s = pass(s, 'E');
|
||||
expect(s.current).toBe('B');
|
||||
s = play(s, 'B', [C(4, 1)]); // B out (3rd? no: D 1st, A 2nd, B 3rd)
|
||||
expect(s.finishOrder).toEqual(['D', 'A', 'B']);
|
||||
s = pass(s, 'C');
|
||||
s = pass(s, 'E');
|
||||
expect(s.current).toBe('C');
|
||||
s = play(s, 'C', [C(5, 1)]); // C out → only E left → round over
|
||||
expect(s.round).toBe(2);
|
||||
expect(s.roundResults[0]!.order).toEqual(['D', 'A', 'B', 'C', 'E']);
|
||||
expect(s.roundResults[0]!.points).toEqual({ D: 4, A: 3, B: 2, C: 1, E: 0 });
|
||||
expect(s.order).toEqual(['D', 'A', 'B', 'C', 'E']);
|
||||
expect(s.phase.kind).toBe('revolutionCheck');
|
||||
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([16, 16, 16, 16, 16]);
|
||||
expect(dalmuti.view(s, 'E').seats.find((x) => x.id === 'E')!.title).toBe('대농노');
|
||||
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
|
||||
});
|
||||
|
||||
test('5 players, 3 rounds: rank points add up; ties go to the better last-round rank; last-round mode', () => {
|
||||
const rr = (round: number, order: string[]) => ({ round, order, revolution: 'none' as const, points: Object.fromEntries(order.map((p, i) => [p, 4 - i])) });
|
||||
const roundResults = [rr(1, ['A', 'B', 'C', 'D', 'E']), rr(2, ['C', 'A', 'B', 'E', 'D']), rr(3, ['B', 'C', 'E', 'A', 'D'])];
|
||||
const totals: Record<string, number> = {};
|
||||
for (const r of roundResults) for (const [p, pt] of Object.entries(r.points)) totals[p] = (totals[p] ?? 0) + pt;
|
||||
expect(totals).toEqual({ A: 8, B: 9, C: 9, D: 1, E: 3 });
|
||||
const base = { roundResults, totals, players: P5, options: dalmuti.optionsSchema.parse({}) };
|
||||
const f = finalOf(base);
|
||||
// B and C tie on 9 → B was 1st in the last round, C 2nd.
|
||||
expect(f.ranking).toEqual([['B'], ['C'], ['A'], ['E'], ['D']]);
|
||||
expect(f.players.find((p) => p.player === 'A')!.roundRanks).toEqual([1, 2, 4]);
|
||||
expect(f.summary).toContain('같은 점수는 마지막 라운드 순위');
|
||||
expect(f.summary).toContain('3라운드');
|
||||
const last = finalOf({ ...base, options: dalmuti.optionsSchema.parse({ scoring: 'last-round' }) });
|
||||
expect(last.ranking).toEqual([['B'], ['C'], ['E'], ['A'], ['D']]);
|
||||
});
|
||||
|
||||
test('the game ends after `rounds` rounds with a result', () => {
|
||||
let s = rig(make({ rounds: 1 }), { order: P4.concat('E'), hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(7, 1)] }, current: 'E' });
|
||||
s = play(s, 'E', [C(7, 1)]);
|
||||
s = play(s, 'A', [C(3, 1)]);
|
||||
for (const p of ['B', 'C', 'D']) s = pass(s, p);
|
||||
s = play(s, 'B', [C(4, 1)]);
|
||||
s = pass(s, 'C');
|
||||
s = pass(s, 'D');
|
||||
s = play(s, 'C', [C(5, 1)]);
|
||||
expect(s.phase.kind).toBe('finished');
|
||||
const res = dalmuti.result(s)!;
|
||||
expect(res.ranking).toEqual([['E'], ['A'], ['B'], ['C'], ['D']]);
|
||||
expect(res.scores).toEqual({ E: 4, A: 3, B: 2, C: 1, D: 0 });
|
||||
expect(reject(s, 'D', { type: 'pass' })).toBe('게임이 이미 끝났어요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- timeouts / leaving
|
||||
|
||||
describe('시간 초과와 나가기 (§8.5)', () => {
|
||||
test('lead timeout plays every card of the highest number; follow timeout passes; jesters only → all jesters', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [C(2, 1), C(11, 1), C(11, 2), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
|
||||
expect(dalmuti.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
|
||||
s = act(s, 'A', { type: 'timeout' });
|
||||
expect(s.trick.top).toMatchObject({ by: 'A', count: 2, rank: 11 });
|
||||
expect(ids(s, 'A')).toEqual([C(2, 1), J1]);
|
||||
s = act(s, 'B', { type: 'timeout' });
|
||||
expect(s.trick.passesSinceTop).toEqual(['B']);
|
||||
const j = act(rig(make(), { order: P5, hands: { A: [J1, J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }), 'A', { type: 'timeout' });
|
||||
expect(j.trick.top).toMatchObject({ count: 2, rank: 13 });
|
||||
});
|
||||
|
||||
test('tax return timeout gives back the highest number cards; revolutionCheck timeout never declares', () => {
|
||||
let s = atRevolutionCheck(handsWith(P5, { A: [C(12, 1), C(12, 2), J1, J2] }));
|
||||
for (const p of P5) s = act(s, p, { type: 'timeout' });
|
||||
expect(s.revolution).toBe('none');
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
const hiA = Math.max(...nums(s, 'A').filter((n) => n !== 13));
|
||||
s = act(s, 'A', { type: 'timeout' });
|
||||
const back = s.taxLog.find((t) => t.kind === 'return' && t.from === 'A')!;
|
||||
expect(back.cardIds.map((id) => card(id).n)).toEqual([hiA, hiA]);
|
||||
expect(back.cardIds).not.toContain(J1);
|
||||
});
|
||||
|
||||
test('3 timeouts in a row → away, deadline 5 s; acting again clears it', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [C(1, 1), C(9, 1), C(9, 2), C(9, 3)], B: [C(12, 1), C(12, 2)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
|
||||
s.timeoutsInRow.B = 2;
|
||||
s = play(s, 'A', [C(9, 1)]);
|
||||
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
|
||||
s = act(s, 'B', { type: 'timeout' });
|
||||
expect(s.away.B).toBe(true);
|
||||
for (const p of ['C', 'D', 'E']) s = pass(s, p);
|
||||
s = play(s, 'A', [C(9, 2)]);
|
||||
expect(s.current).toBe('B');
|
||||
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 5000);
|
||||
expect(dalmuti.timeoutNotBefore!(s)).toBe(s.turnStartedAt + 1200);
|
||||
s = pass(s, 'B');
|
||||
expect(s.away.B).toBe(false);
|
||||
});
|
||||
|
||||
test('leaving does not eliminate: the seat stays, marked away, and keeps being auto-played', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [C(9, 1)], B: [C(12, 1), C(5, 1)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
|
||||
expect(dalmuti.onLeave!(s, 'B')).toEqual({ type: 'leave' });
|
||||
s = act(s, 'B', { type: 'leave' });
|
||||
expect(s.left.B).toBe(true);
|
||||
expect(s.away.B).toBe(true);
|
||||
expect(s.order).toContain('B');
|
||||
expect(dalmuti.onLeave!(s, 'B')).toBeNull();
|
||||
expect(dalmuti.view(s, null).seats.find((x) => x.id === 'B')!.left).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- validation / leaks / options
|
||||
|
||||
describe('검증과 정보 유출 (§8.3, §10)', () => {
|
||||
test('not my turn / card not in hand / resent packet are refused and change nothing', () => {
|
||||
let s = rig(make(), { order: P5, hands: { A: [C(9, 1), C(9, 2)], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
|
||||
const snap = JSON.stringify(s);
|
||||
expect(reject(s, 'B', { type: 'play', cardIds: [C(5, 1)] })).toBe('지금은 내 차례가 아니에요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(5, 1)] })).toBe('손에 없는 카드예요.');
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1), C(9, 1)] })).toBe('같은 카드를 두 번 고를 수 없어요.');
|
||||
expect(reject(s, 'Z', { type: 'pass' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
|
||||
expect(JSON.stringify(s)).toBe(snap);
|
||||
s = play(s, 'A', [C(9, 1)]);
|
||||
const snap2 = JSON.stringify(s);
|
||||
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1)] })).toBe('지금은 내 차례가 아니에요.');
|
||||
expect(JSON.stringify(s)).toBe(snap2);
|
||||
});
|
||||
|
||||
test('spectators and other players see counts only; tax card contents only for the two involved', () => {
|
||||
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2, C(3, 1), C(3, 2)] })));
|
||||
const taxIds = s.taxLog.flatMap((t) => t.cardIds);
|
||||
for (const viewer of [null, 'C']) {
|
||||
const v = dalmuti.view(s, viewer);
|
||||
const json = JSON.stringify(v);
|
||||
for (const p of P5) if (p !== viewer) for (const id of ids(s, p)) expect(json).not.toContain(`"${id}"`);
|
||||
for (const id of taxIds) expect(json).not.toContain(`"${id}"`);
|
||||
expect(v.tax.every((t) => t.cards === null)).toBe(true);
|
||||
expect(v.tax.map((t) => t.count)).toEqual([2, 1]);
|
||||
}
|
||||
const ve = dalmuti.view(s, 'E');
|
||||
expect(ve.tax[0]!.cards).toEqual([3, 3]);
|
||||
expect(ve.tax[1]!.cards).toBeNull();
|
||||
const va = dalmuti.view(s, 'A');
|
||||
expect(va.tax[0]!.cards).toEqual([3, 3]);
|
||||
expect(va.my!.receivedIds).toEqual([C(3, 1), C(3, 2)]);
|
||||
expect(va.my!.owe).toEqual({ to: 'E', count: 2 });
|
||||
expect(va.my!.suggestedReturn.map((id) => card(id).n)).toEqual(worst2(s, 'A'));
|
||||
// After A gives the 3s back, A's view must not keep their ids.
|
||||
const t = act(s, 'A', { type: 'returnTax', cardIds: [C(3, 1), C(3, 2)] });
|
||||
expect(JSON.stringify(dalmuti.view(t, 'A'))).not.toContain(`"${C(3, 1)}"`);
|
||||
expect(dalmuti.view(t, 'E').tax[2]!.cards).toEqual([3, 3]);
|
||||
});
|
||||
|
||||
test('options: defaults, Korean metadata on every option', () => {
|
||||
const o = dalmuti.defaultOptions;
|
||||
expect(o).toEqual({ rounds: 3, scoring: 'rank-points', firstRanking: 'draw', revolution: true, jesterTaxable: false, passLocksOut: false, removeHighCards: false, turnSeconds: 20 });
|
||||
const shape = (dalmuti.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
|
||||
for (const [k, f] of Object.entries(shape)) {
|
||||
expect(f.meta()?.title, k).toMatch(/[가-힣]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('client actions only (system actions are not in the schema)', () => {
|
||||
expect(dalmuti.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
expect(dalmuti.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||
expect(dalmuti.actionSchema.safeParse({ type: 'pass' }).success).toBe(true);
|
||||
expect(dalmuti.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function worst2(s: DalmutiState, p: string): number[] {
|
||||
const n = nums(s, p).filter((x) => x !== 13).sort((a, b) => b - a);
|
||||
return n.slice(0, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
function invariants(s: DalmutiState): void {
|
||||
const all = allCardIds(s);
|
||||
const deck = deckFor(s.options.removeHighCards);
|
||||
if (all.length !== deck.length || new Set(all).size !== deck.length) throw new Error(`card count ${all.length} (unique ${new Set(all).size}) ≠ ${deck.length}`);
|
||||
if (s.phase.kind === 'playing' && s.hands[s.current]!.length === 0) throw new Error('current player has no cards');
|
||||
if (s.phase.kind !== 'finished' && s.order.length !== s.players.length) throw new Error('order lost a player');
|
||||
}
|
||||
|
||||
function checkLeaks(s: DalmutiState, players: PlayerId[], viewers: (PlayerId | null)[] = [null, ...players]): void {
|
||||
for (const viewer of viewers) {
|
||||
const v = dalmuti.view(s, viewer);
|
||||
const json = JSON.stringify(v);
|
||||
for (const p of players) {
|
||||
if (p === viewer) continue;
|
||||
for (const c of s.hands[p]!) if (json.includes(`"${c.id}"`)) throw new Error(`view for ${viewer} leaks ${c.id} of ${p}`);
|
||||
}
|
||||
for (const t of v.tax) if (t.cards && viewer !== t.from && viewer !== t.to) throw new Error(`tax contents leak to ${viewer}`);
|
||||
const holder = viewer !== null && s.phase.kind === 'revolutionCheck' && s.hands[viewer]!.filter((c) => c.n === 13).length === 2;
|
||||
if (!holder && json.includes('canDeclareRevolution')) throw new Error(`canDeclareRevolution in the view for ${viewer}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface FastReport {
|
||||
steps: number;
|
||||
finished: boolean;
|
||||
trace: string;
|
||||
final: DalmutiState;
|
||||
}
|
||||
|
||||
/** Same chooser and selection as `randomPlayout`, with lighter per-step checks so 1,000 games stay fast. */
|
||||
function fastPlayout(players: PlayerId[], options: DalmutiOptions, seed: RngState, maxSteps: number): FastReport {
|
||||
const r = SeededRng.fromSeed(seed);
|
||||
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let now = 1_700_000_000_000;
|
||||
let s = dalmuti.setup({ players, options, rng: r, now });
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
invariants(s);
|
||||
if (step % 15 === 0) checkLeaks(s, players, [null, players[(step / 15) % players.length]!]);
|
||||
if (step % 60 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
|
||||
for (const p of dalmuti.timeoutPlayers!(s)) {
|
||||
const v = dalmuti.validate(s, p, dalmuti.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
|
||||
}
|
||||
if (dalmuti.result(s)) {
|
||||
checkLeaks(s, players);
|
||||
return { steps: step, finished: true, trace: trace.join(';'), final: s };
|
||||
}
|
||||
const actives = dalmuti.activePlayers(s);
|
||||
if (!actives.length) throw new Error('no active players');
|
||||
const actor = actives[chooser.int(actives.length)]!;
|
||||
// Some turns go through the timer path, like real away players.
|
||||
const legal = chooser.int(12) === 0 ? [] : dalmuti.legalActions!(s, actor);
|
||||
const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : dalmuti.onTimeout(s, actor);
|
||||
const v = dalmuti.validate(s, actor, action);
|
||||
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
|
||||
now += 1000;
|
||||
const before = step % 25 === 0 ? JSON.stringify(s) : null;
|
||||
const out = dalmuti.apply(s, actor, action, { rng: r, now });
|
||||
if (before !== null && JSON.stringify(s) !== before) throw new Error('apply mutated the previous state');
|
||||
if (JSON.stringify(out.events).match(/"c\d+-\d+"|"j[12]"/)) throw new Error('event carries a card id');
|
||||
s = out.state;
|
||||
trace.push(`${actor}:${JSON.stringify(action)}`);
|
||||
}
|
||||
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
|
||||
}
|
||||
|
||||
const PRESETS: Partial<DalmutiOptions>[] = [
|
||||
{ rounds: 1 },
|
||||
{ rounds: 3 },
|
||||
{ rounds: 1, passLocksOut: true, jesterTaxable: true },
|
||||
{ rounds: 3, revolution: false, firstRanking: 'random', scoring: 'last-round' },
|
||||
{ rounds: 1, removeHighCards: true },
|
||||
{ rounds: 5, removeHighCards: true, passLocksOut: true },
|
||||
];
|
||||
|
||||
describe('random playouts (1,000 seeds)', () => {
|
||||
test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => {
|
||||
let finished = 0;
|
||||
let revolutions = 0;
|
||||
let great = 0;
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const n = 4 + (i % 5);
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = dalmuti.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 = dalmuti.result(a.final)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||
expect(a.final.roundResults).toHaveLength(options.rounds);
|
||||
revolutions += a.final.roundResults.filter((x) => x.revolution === 'revolution').length;
|
||||
great += a.final.roundResults.filter((x) => x.revolution === 'great').length;
|
||||
if (i % 50 === 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);
|
||||
expect(revolutions).toBeGreaterThan(0);
|
||||
expect(great).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
[4, {}],
|
||||
[5, { rounds: 1, passLocksOut: true }],
|
||||
[8, { rounds: 1, revolution: false, removeHighCards: true }],
|
||||
] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %i players', (n, opts) => {
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = dalmuti.optionsSchema.parse(opts);
|
||||
const conserve = (st: unknown) => invariants(st as DalmutiState);
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const a = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
|
||||
expect(a.finished).toBe(true);
|
||||
if (i === 1) {
|
||||
const b = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 });
|
||||
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
749
packages/games/src/dalmuti/index.ts
Normal file
749
packages/games/src/dalmuti/index.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
/** 달무티 (docs/games/dalmuti.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 {
|
||||
JESTER,
|
||||
cardOrder,
|
||||
dalmutiActionSchema,
|
||||
dalmutiOptionsSchema,
|
||||
deckFor,
|
||||
type Choice,
|
||||
type DCard,
|
||||
type DalmutiAction,
|
||||
type DalmutiFinal,
|
||||
type DalmutiOptions,
|
||||
type DalmutiState,
|
||||
type DalmutiView,
|
||||
type LogEntry,
|
||||
type Play,
|
||||
type PlayAction,
|
||||
type PlayView,
|
||||
type Title,
|
||||
} from './types';
|
||||
|
||||
export * from './types';
|
||||
|
||||
const REVOLUTION_MS = 20_000;
|
||||
const TAX_MS = 30_000;
|
||||
const AWAY_MS = 5000;
|
||||
const AWAY_AFTER = 3;
|
||||
/** Auto moves for away / departed players wait this long so others can follow the table. */
|
||||
const MIN_TURN_MS = 1200;
|
||||
const LOG_MAX = 40;
|
||||
const LOG_VIEW = 20;
|
||||
const MAX_REDRAWS = 50;
|
||||
|
||||
type Def = GameDefinition<DalmutiState, DalmutiAction, DalmutiView, DalmutiOptions>;
|
||||
|
||||
interface Ctx {
|
||||
rng: Rng;
|
||||
now: number;
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Copy for `apply`: containers are copied; cards, plays, log entries and round results are never mutated. */
|
||||
function cloneState(s: DalmutiState): DalmutiState {
|
||||
const ph = s.phase;
|
||||
const phase: DalmutiState['phase'] =
|
||||
ph.kind === 'revolutionCheck'
|
||||
? { kind: ph.kind, confirmed: { ...ph.confirmed } }
|
||||
: ph.kind === 'taxReturn'
|
||||
? { kind: ph.kind, owed: Object.fromEntries(Object.entries(ph.owed).map(([p, o]) => [p, { ...o }])) }
|
||||
: { ...ph };
|
||||
return {
|
||||
...s,
|
||||
order: [...s.order],
|
||||
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
|
||||
discard: [...s.discard],
|
||||
phase,
|
||||
taxLog: [...s.taxLog],
|
||||
trick: { ...s.trick, passesSinceTop: [...s.trick.passesSinceTop], lockedOut: [...s.trick.lockedOut], pile: [...s.trick.pile] },
|
||||
finishOrder: [...s.finishOrder],
|
||||
roundResults: [...s.roundResults],
|
||||
totals: { ...s.totals },
|
||||
timeoutsInRow: { ...s.timeoutsInRow },
|
||||
away: { ...s.away },
|
||||
left: { ...s.left },
|
||||
log: [...s.log],
|
||||
};
|
||||
}
|
||||
|
||||
export const titleOf = (rank: number, n: number): Title =>
|
||||
rank === 0 ? '대달무티' : rank === 1 ? '소달무티' : rank === n - 1 ? '대농노' : rank === n - 2 ? '소농노' : '평민';
|
||||
|
||||
const byCard = (a: DCard, b: DCard) => cardOrder(a.id) - cardOrder(b.id);
|
||||
export const sortHand = (h: DCard[]): DCard[] => [...h].sort(byCard);
|
||||
const isJ = (c: DCard) => c.n === JESTER;
|
||||
const withCards = (s: DalmutiState): PlayerId[] => s.order.filter((p) => s.hands[p]!.length > 0);
|
||||
export const deckSize = (o: Pick<DalmutiOptions, 'removeHighCards'>): number => deckFor(o.removeHighCards).length;
|
||||
/** Every card id of the deck, exactly once, across hands, the current trick and the discard (test invariant). */
|
||||
export const allCardIds = (s: DalmutiState): string[] => [
|
||||
...s.players.flatMap((p) => s.hands[p]!.map((c) => c.id)),
|
||||
...s.trick.pile.flatMap((pl) => pl.cardIds),
|
||||
...s.discard,
|
||||
];
|
||||
|
||||
function log(s: DalmutiState, 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);
|
||||
}
|
||||
|
||||
/** Rank of a set: the number of its number cards, 13 for jesters alone; null if numbers differ. */
|
||||
export function setRank(cards: DCard[]): number | null {
|
||||
const nums = cards.filter((c) => !isJ(c));
|
||||
if (nums.length === 0) return JESTER;
|
||||
return nums.every((c) => c.n === nums[0]!.n) ? nums[0]!.n : null;
|
||||
}
|
||||
|
||||
/** Next player after `from` in turn order who still has cards and is not locked out. */
|
||||
function nextActor(s: DalmutiState, from: PlayerId): PlayerId {
|
||||
const n = s.order.length;
|
||||
const i0 = s.order.indexOf(from);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const p = s.order[(i0 + k) % n]!;
|
||||
if (s.hands[p]!.length > 0 && !s.trick.lockedOut.includes(p)) return p;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
/** Next player after `from` (exclusive) who still has cards. */
|
||||
function nextWithCards(s: DalmutiState, from: PlayerId): PlayerId {
|
||||
const n = s.order.length;
|
||||
const i0 = s.order.indexOf(from);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const p = s.order[(i0 + k) % n]!;
|
||||
if (s.hands[p]!.length > 0) return p;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
function newTurn(s: DalmutiState, p: PlayerId): void {
|
||||
s.current = p;
|
||||
s.turnNo += 1;
|
||||
}
|
||||
|
||||
function startTrick(s: DalmutiState, leader: PlayerId, ctx: Ctx): void {
|
||||
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
|
||||
s.trick = { leader, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
|
||||
ctx.events.push({ type: 'trickStart', leader });
|
||||
newTurn(s, leader);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup / rounds
|
||||
|
||||
/** §4-1: everyone draws one card; ties re-draw among themselves (fresh shuffle each pass). */
|
||||
export function drawFirstRanks(players: PlayerId[], deck: DCard[], rng: Rng): { order: PlayerId[]; draws: Record<PlayerId, number[]> } {
|
||||
const draws: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p, [] as number[]]));
|
||||
let passes = 0;
|
||||
const resolve = (group: PlayerId[]): PlayerId[] => {
|
||||
if (group.length <= 1) return group;
|
||||
if (passes++ >= MAX_REDRAWS) return group;
|
||||
const shuffled = rng.shuffle(deck);
|
||||
group.forEach((p, i) => draws[p]!.push(shuffled[i]!.n));
|
||||
const drawn = new Map(group.map((p, i) => [p, shuffled[i]!.n]));
|
||||
const nums = [...new Set(drawn.values())].sort((a, b) => a - b);
|
||||
// Buckets first: re-draws inside one bucket must not change another bucket's membership.
|
||||
const buckets = nums.map((n) => group.filter((p) => drawn.get(p) === n));
|
||||
return buckets.flatMap((b) => resolve(b));
|
||||
};
|
||||
return { order: resolve([...players]), draws };
|
||||
}
|
||||
|
||||
/** §4-3: shuffle and deal one at a time from the top rank; earlier ranks get the extra cards. */
|
||||
function deal(s: DalmutiState, ctx: Ctx): void {
|
||||
const deck = ctx.rng.shuffle(deckFor(s.options.removeHighCards));
|
||||
s.hands = Object.fromEntries(s.players.map((p) => [p, [] as DCard[]]));
|
||||
deck.forEach((c, i) => s.hands[s.order[i % s.order.length]!]!.push(c));
|
||||
for (const p of s.players) s.hands[p] = sortHand(s.hands[p]!);
|
||||
s.discard = [];
|
||||
s.taxLog = [];
|
||||
s.finishOrder = [];
|
||||
s.revolution = 'none';
|
||||
s.revolutionBy = null;
|
||||
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
|
||||
s.current = s.order[0]!;
|
||||
ctx.events.push({ type: 'dealt', round: s.round, order: [...s.order], counts: s.order.map((p) => s.hands[p]!.length) });
|
||||
if (s.options.revolution) {
|
||||
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
|
||||
s.turnNo += 1;
|
||||
} else collectTax(s, ctx);
|
||||
}
|
||||
|
||||
/** Best cards for tax (§4-5): lowest numbers by card order; jesters first only with `jesterTaxable`. */
|
||||
export function taxCards(hand: DCard[], count: number, jesterTaxable: boolean): DCard[] {
|
||||
const sorted = sortHand(hand);
|
||||
const pool = jesterTaxable ? [...sorted.filter(isJ), ...sorted.filter((c) => !isJ(c))] : [...sorted.filter((c) => !isJ(c)), ...sorted.filter(isJ)];
|
||||
return pool.slice(0, count);
|
||||
}
|
||||
|
||||
/** Default return / timeout lead: highest number cards first, jesters last. */
|
||||
export function worstCards(hand: DCard[], count: number): DCard[] {
|
||||
const sorted = sortHand(hand);
|
||||
return [...sorted.filter((c) => !isJ(c)).reverse(), ...sorted.filter(isJ)].slice(0, count);
|
||||
}
|
||||
|
||||
function moveCards(s: DalmutiState, from: PlayerId, to: PlayerId, ids: string[]): void {
|
||||
const moving = s.hands[from]!.filter((c) => ids.includes(c.id));
|
||||
s.hands[from] = s.hands[from]!.filter((c) => !ids.includes(c.id));
|
||||
s.hands[to] = sortHand([...s.hands[to]!, ...moving]);
|
||||
}
|
||||
|
||||
function collectTax(s: DalmutiState, ctx: Ctx): void {
|
||||
const o = s.order;
|
||||
const n = o.length;
|
||||
const pairs: [PlayerId, PlayerId, number][] = [
|
||||
[o[n - 1]!, o[0]!, 2],
|
||||
[o[n - 2]!, o[1]!, 1],
|
||||
];
|
||||
const owed: Extract<DalmutiState['phase'], { kind: 'taxReturn' }>['owed'] = {};
|
||||
for (const [from, to, count] of pairs) {
|
||||
const ids = taxCards(s.hands[from]!, count, s.options.jesterTaxable).map((c) => c.id);
|
||||
moveCards(s, from, to, ids);
|
||||
s.taxLog.push({ kind: 'tax', from, to, cardIds: ids });
|
||||
owed[to] = { to: from, count, done: false };
|
||||
ctx.events.push({ type: 'tax', from, to, count });
|
||||
log(s, { t: 'tax', from, to, n: count });
|
||||
}
|
||||
s.phase = { kind: 'taxReturn', owed };
|
||||
s.turnNo += 1;
|
||||
}
|
||||
|
||||
function startPlaying(s: DalmutiState, ctx: Ctx): void {
|
||||
s.phase = { kind: 'playing' };
|
||||
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
|
||||
ctx.events.push({ type: 'trickStart', leader: s.order[0]! });
|
||||
newTurn(s, s.order[0]!);
|
||||
}
|
||||
|
||||
function endRevolutionCheck(s: DalmutiState, ctx: Ctx): void {
|
||||
const by = s.revolutionBy;
|
||||
if (by) {
|
||||
const great = by === s.order[s.order.length - 1];
|
||||
s.revolution = great ? 'great' : 'revolution';
|
||||
if (great) s.order = [...s.order].reverse();
|
||||
ctx.events.push({ type: 'revolution', player: by, kind: s.revolution, order: [...s.order] });
|
||||
log(s, { t: 'revolution', p: by, kind: s.revolution });
|
||||
startPlaying(s, ctx);
|
||||
return;
|
||||
}
|
||||
collectTax(s, ctx);
|
||||
}
|
||||
|
||||
function endRound(s: DalmutiState, ctx: Ctx): void {
|
||||
const last = withCards(s);
|
||||
const order = [...s.finishOrder, ...last];
|
||||
for (const p of last) {
|
||||
s.discard.push(...s.hands[p]!.map((c) => c.id));
|
||||
s.hands[p] = [];
|
||||
}
|
||||
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
|
||||
s.trick = { ...s.trick, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
|
||||
const n = order.length;
|
||||
const points = Object.fromEntries(order.map((p, i) => [p, n - 1 - i]));
|
||||
for (const p of order) s.totals[p] = (s.totals[p] ?? 0) + points[p]!;
|
||||
s.roundResults.push({ round: s.round, order, revolution: s.revolution, points });
|
||||
ctx.events.push({ type: 'roundEnd', round: s.round, order });
|
||||
log(s, { t: 'round', round: s.round });
|
||||
if (s.round >= s.options.rounds) {
|
||||
finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
s.round += 1;
|
||||
s.order = order;
|
||||
deal(s, ctx);
|
||||
}
|
||||
|
||||
/** Final ranking (§6): total points (ties → better last-round rank) or the last round's order. */
|
||||
export function finalOf(s: Pick<DalmutiState, 'roundResults' | 'totals' | 'players' | 'options'>): DalmutiFinal {
|
||||
const last = s.roundResults[s.roundResults.length - 1]!;
|
||||
const lastRank = (p: PlayerId) => last.order.indexOf(p);
|
||||
const sorted =
|
||||
s.options.scoring === 'last-round' ? [...last.order] : [...s.players].sort((a, b) => s.totals[b]! - s.totals[a]! || lastRank(a) - lastRank(b));
|
||||
const players = sorted.map((p, i) => ({
|
||||
player: p,
|
||||
rank: i + 1,
|
||||
total: s.totals[p] ?? 0,
|
||||
roundRanks: s.roundResults.map((r) => r.order.indexOf(p) + 1),
|
||||
}));
|
||||
const rounds = s.roundResults.length;
|
||||
let summary: string;
|
||||
if (s.options.scoring === 'last-round') {
|
||||
summary = `${rounds}라운드를 마쳤어요. 마지막 라운드에서 가장 먼저 손패를 다 낸 사람이 1등이에요!`;
|
||||
} else {
|
||||
const top = players[0]!;
|
||||
const tied = players.filter((x) => x.total === top.total).length > 1;
|
||||
summary =
|
||||
rounds === 1
|
||||
? '가장 먼저 손패를 다 낸 사람이 1등이에요!'
|
||||
: `${rounds}라운드 동안 순위 점수 ${top.total}점을 모아 가장 높은 신분을 지켰어요!${tied ? ' (같은 점수는 마지막 라운드 순위로 정했어요)' : ''}`;
|
||||
}
|
||||
return { ranking: sorted.map((p) => [p]), players, summary };
|
||||
}
|
||||
|
||||
function finish(s: DalmutiState, ctx: Ctx): void {
|
||||
s.final = finalOf(s);
|
||||
s.phase = { kind: 'finished' };
|
||||
ctx.events.push({ type: 'gameEnd', ranking: s.final.ranking });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- playing
|
||||
|
||||
function playCards(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
|
||||
const hand = s.hands[p]!;
|
||||
const cards = ids.map((id) => hand.find((c) => c.id === id)!);
|
||||
const rank = setRank(cards)!;
|
||||
const play: Play & { by: PlayerId } = { cardIds: [...ids], count: ids.length, rank, by: p };
|
||||
s.hands[p] = hand.filter((c) => !ids.includes(c.id));
|
||||
s.trick.top = play;
|
||||
s.trick.pile.push(play);
|
||||
s.trick.passesSinceTop = [];
|
||||
const faces = sortHand(cards).map((c) => c.n);
|
||||
ctx.events.push({ type: 'played', player: p, cards: faces, count: play.count, rank });
|
||||
log(s, { t: 'play', p, cards: faces, count: play.count, rank });
|
||||
if (s.hands[p]!.length === 0) {
|
||||
s.finishOrder.push(p);
|
||||
ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length });
|
||||
log(s, { t: 'out', p, place: s.finishOrder.length });
|
||||
}
|
||||
if (withCards(s).length <= 1) {
|
||||
endRound(s, ctx);
|
||||
return;
|
||||
}
|
||||
afterMove(s, p, ctx);
|
||||
}
|
||||
|
||||
function pass(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
|
||||
s.trick.passesSinceTop.push(p);
|
||||
if (s.options.passLocksOut && !s.trick.lockedOut.includes(p)) s.trick.lockedOut.push(p);
|
||||
ctx.events.push({ type: 'passed', player: p });
|
||||
log(s, { t: 'pass', p });
|
||||
afterMove(s, p, ctx);
|
||||
}
|
||||
|
||||
/** §8.2: trick ends when everyone with cards except the top player has passed (or is locked out). */
|
||||
function afterMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
|
||||
const top = s.trick.top!;
|
||||
const waitingOn = withCards(s).filter((x) => x !== top.by && !s.trick.passesSinceTop.includes(x) && !s.trick.lockedOut.includes(x));
|
||||
if (waitingOn.length === 0) {
|
||||
const leader = s.hands[top.by]!.length > 0 ? top.by : nextWithCards(s, top.by);
|
||||
log(s, { t: 'trick', p: leader });
|
||||
startTrick(s, leader, ctx);
|
||||
return;
|
||||
}
|
||||
newTurn(s, nextActor(s, p));
|
||||
}
|
||||
|
||||
/** What `p` may play now (rank, set sizes). */
|
||||
export function choicesFor(s: DalmutiState, p: PlayerId): Choice[] {
|
||||
if (s.phase.kind !== 'playing' || s.current !== p) return [];
|
||||
const hand = s.hands[p]!;
|
||||
const J = hand.filter(isJ).length;
|
||||
const top = s.trick.top;
|
||||
const out: Choice[] = [];
|
||||
for (let r = 1; r <= 12; r++) {
|
||||
const have = hand.filter((c) => c.n === r).length;
|
||||
if (have === 0) continue;
|
||||
if (!top) out.push({ rank: r, have, counts: Array.from({ length: have + J }, (_, i) => i + 1), needsJester: false });
|
||||
else if (r < top.rank && have + J >= top.count) out.push({ rank: r, have, counts: [top.count], needsJester: have < top.count });
|
||||
}
|
||||
if (J > 0 && !top) out.push({ rank: JESTER, have: J, counts: Array.from({ length: J }, (_, i) => i + 1), needsJester: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Card ids for a set: `count` cards of `rank`, using at least `minJesters` jesters (more if needed). */
|
||||
export function pickSet(hand: DCard[], rank: number, count: number, minJesters = 0): string[] | null {
|
||||
const jesters = sortHand(hand.filter(isJ));
|
||||
if (rank === JESTER) return count <= jesters.length ? jesters.slice(0, count).map((c) => c.id) : null;
|
||||
const nums = sortHand(hand.filter((c) => c.n === rank));
|
||||
const j = Math.max(minJesters, count - nums.length);
|
||||
if (j > jesters.length || count - j < 1 || count - j > nums.length) return null;
|
||||
return [...nums.slice(0, count - j), ...jesters.slice(0, j)].map((c) => c.id);
|
||||
}
|
||||
|
||||
/** §8.5 lead timeout: all cards of the highest number; only jesters left → all jesters. */
|
||||
export function timeoutLead(hand: DCard[]): string[] {
|
||||
const nums = hand.filter((c) => !isJ(c));
|
||||
if (nums.length === 0) return hand.map((c) => c.id);
|
||||
const hi = Math.max(...nums.map((c) => c.n));
|
||||
return sortHand(nums.filter((c) => c.n === hi)).map((c) => c.id);
|
||||
}
|
||||
|
||||
function validatePlay(s: DalmutiState, actor: PlayerId, a: PlayAction): Validation {
|
||||
if (s.phase.kind !== 'playing') return fail('지금은 카드를 낼 수 없어요.');
|
||||
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||
if (s.trick.lockedOut.includes(actor)) return fail('이번 트릭에서는 이미 패스했어요.');
|
||||
if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.');
|
||||
const hand = s.hands[actor]!;
|
||||
const cards = a.cardIds.map((id) => hand.find((c) => c.id === id));
|
||||
if (cards.some((c) => !c)) return fail('손에 없는 카드예요.');
|
||||
const rank = setRank(cards as DCard[]);
|
||||
if (rank === null) return fail('같은 숫자끼리만 낼 수 있어요.');
|
||||
const top = s.trick.top;
|
||||
if (top) {
|
||||
if (a.cardIds.length !== top.count) return fail(`앞사람과 같은 장수(${top.count}장)로 내야 해요.`);
|
||||
if (rank >= top.rank) return fail('더 낮은 숫자만 낼 수 있어요.');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function validateReturn(s: DalmutiState, actor: PlayerId, ids: string[]): Validation {
|
||||
if (s.phase.kind !== 'taxReturn') return fail('지금은 카드를 돌려줄 때가 아니에요.');
|
||||
const owe = s.phase.owed[actor];
|
||||
if (!owe) return fail('돌려줄 카드가 없어요.');
|
||||
if (owe.done) return fail('이미 카드를 돌려줬어요.');
|
||||
if (ids.length !== owe.count) return fail(`돌려줄 카드 ${owe.count}장을 골라 주세요.`);
|
||||
if (new Set(ids).size !== ids.length) return fail('같은 카드를 두 번 고를 수 없어요.');
|
||||
if (ids.some((id) => !s.hands[actor]!.some((c) => c.id === id))) return fail('손에 없는 카드예요.');
|
||||
return ok;
|
||||
}
|
||||
|
||||
function returnTax(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
|
||||
if (s.phase.kind !== 'taxReturn') throw new Error('not in taxReturn');
|
||||
const owe = s.phase.owed[p]!;
|
||||
moveCards(s, p, owe.to, ids);
|
||||
owe.done = true;
|
||||
s.taxLog.push({ kind: 'return', from: p, to: owe.to, cardIds: [...ids] });
|
||||
ctx.events.push({ type: 'taxReturned', from: p, to: owe.to, count: ids.length });
|
||||
log(s, { t: 'return', from: p, to: owe.to, n: ids.length });
|
||||
if (Object.values(s.phase.owed).every((x) => x.done)) startPlaying(s, ctx);
|
||||
}
|
||||
|
||||
function confirmHand(s: DalmutiState, p: PlayerId, declare: boolean, ctx: Ctx): void {
|
||||
if (s.phase.kind !== 'revolutionCheck') throw new Error('not in revolutionCheck');
|
||||
s.phase.confirmed[p] = true;
|
||||
if (declare) {
|
||||
s.revolutionBy = p;
|
||||
ctx.events.push({ type: 'revolutionDeclared', player: p });
|
||||
}
|
||||
if (s.players.every((x) => (s.phase as { confirmed: Record<PlayerId, boolean> }).confirmed[x])) endRevolutionCheck(s, ctx);
|
||||
}
|
||||
|
||||
const hasBothJesters = (s: DalmutiState, p: PlayerId) => s.hands[p]!.filter(isJ).length === 2;
|
||||
const canDeclare = (s: DalmutiState, p: PlayerId) =>
|
||||
s.phase.kind === 'revolutionCheck' && !s.phase.confirmed[p] && s.revolutionBy === null && hasBothJesters(s, p);
|
||||
|
||||
function waiting(s: DalmutiState): PlayerId[] {
|
||||
const ph = s.phase;
|
||||
switch (ph.kind) {
|
||||
case 'revolutionCheck':
|
||||
return s.players.filter((p) => !ph.confirmed[p]);
|
||||
case 'taxReturn':
|
||||
return s.players.filter((p) => ph.owed[p] && !ph.owed[p]!.done);
|
||||
case 'playing':
|
||||
return [s.current];
|
||||
case 'finished':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineMs(s: DalmutiState): number | null {
|
||||
const w = waiting(s);
|
||||
const allAway = w.length > 0 && w.every((p) => s.away[p]);
|
||||
switch (s.phase.kind) {
|
||||
case 'revolutionCheck':
|
||||
return allAway ? AWAY_MS : REVOLUTION_MS;
|
||||
case 'taxReturn':
|
||||
return allAway ? AWAY_MS : TAX_MS;
|
||||
case 'playing':
|
||||
return allAway ? AWAY_MS : s.options.turnSeconds * 1000;
|
||||
case 'finished':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function timeoutMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
|
||||
const ph = s.phase;
|
||||
switch (ph.kind) {
|
||||
case 'revolutionCheck':
|
||||
confirmHand(s, p, false, ctx);
|
||||
break;
|
||||
case 'taxReturn':
|
||||
returnTax(s, p, worstCards(s.hands[p]!, ph.owed[p]!.count).map((c) => c.id), ctx);
|
||||
break;
|
||||
case 'playing':
|
||||
if (s.trick.top) pass(s, p, ctx);
|
||||
else playCards(s, p, timeoutLead(s.hands[p]!), ctx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
const faceOf = (id: string): number => (id.startsWith('j') ? JESTER : Number(id.slice(1, id.indexOf('-'))));
|
||||
|
||||
function playView(pl: Play & { by: PlayerId }): PlayView {
|
||||
return { by: pl.by, count: pl.count, rank: pl.rank, cards: pl.cardIds.map(faceOf).sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const dalmuti: Def = {
|
||||
id: 'dalmuti',
|
||||
nameKo: '달무티',
|
||||
minPlayers: 4,
|
||||
maxPlayers: 8,
|
||||
stateVersion: 1,
|
||||
defaultOptions: dalmutiOptionsSchema.parse({}),
|
||||
optionsSchema: dalmutiOptionsSchema as unknown as Def['optionsSchema'],
|
||||
actionSchema: dalmutiActionSchema as unknown as Def['actionSchema'],
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 4 || players.length > 8) throw new Error(`dalmuti: ${players.length} players not allowed`);
|
||||
const deck = deckFor(options.removeHighCards);
|
||||
let order: PlayerId[];
|
||||
let firstDraw: Record<PlayerId, number[]> | null = null;
|
||||
if (options.firstRanking === 'draw') {
|
||||
const d = drawFirstRanks(players, deck, rng);
|
||||
order = d.order;
|
||||
firstDraw = d.draws;
|
||||
} else order = rng.shuffle(players);
|
||||
const s: DalmutiState = {
|
||||
options,
|
||||
players: [...players],
|
||||
order,
|
||||
round: 1,
|
||||
hands: {},
|
||||
discard: [],
|
||||
phase: { kind: 'playing' },
|
||||
revolution: 'none',
|
||||
revolutionBy: null,
|
||||
firstDraw,
|
||||
taxLog: [],
|
||||
trick: { leader: order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] },
|
||||
current: order[0]!,
|
||||
finishOrder: [],
|
||||
roundResults: [],
|
||||
totals: Object.fromEntries(players.map((p) => [p, 0])),
|
||||
turnStartedAt: now,
|
||||
turnNo: 0,
|
||||
seq: 0,
|
||||
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
|
||||
away: Object.fromEntries(players.map((p) => [p, false])),
|
||||
left: Object.fromEntries(players.map((p) => [p, false])),
|
||||
log: [],
|
||||
final: null,
|
||||
};
|
||||
deal(s, { rng, now, events: [] });
|
||||
return s;
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
if (!s.players.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
|
||||
switch (a.type) {
|
||||
case 'leave':
|
||||
return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
|
||||
case 'timeout':
|
||||
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
|
||||
case 'confirmHand': {
|
||||
const ph = s.phase;
|
||||
if (ph.kind !== 'revolutionCheck') return fail('지금은 손패를 확인할 때가 아니에요.');
|
||||
if (ph.confirmed[actor]) return fail('이미 확인했어요.');
|
||||
if (a.declareRevolution && !hasBothJesters(s, actor)) return fail('광대 2장이 있어야 혁명할 수 있어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'returnTax':
|
||||
return validateReturn(s, actor, a.cardIds);
|
||||
case 'play':
|
||||
return validatePlay(s, actor, a);
|
||||
case 'pass':
|
||||
if (s.phase.kind !== 'playing') return fail('지금은 패스할 수 없어요.');
|
||||
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||
if (!s.trick.top) return fail('새로 시작할 때는 패스할 수 없어요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = cloneState(prev);
|
||||
const ctx: Ctx = { rng, now, events: [] };
|
||||
const before = `${s.turnNo}|${s.phase.kind}`;
|
||||
s.seq += 1;
|
||||
if (a.type !== 'timeout' && a.type !== 'leave') {
|
||||
s.timeoutsInRow[actor] = 0;
|
||||
s.away[actor] = !!s.left[actor];
|
||||
}
|
||||
switch (a.type) {
|
||||
case 'confirmHand':
|
||||
confirmHand(s, actor, !!a.declareRevolution, ctx);
|
||||
break;
|
||||
case 'returnTax':
|
||||
returnTax(s, actor, a.cardIds, ctx);
|
||||
break;
|
||||
case 'play':
|
||||
playCards(s, actor, a.cardIds, ctx);
|
||||
break;
|
||||
case 'pass':
|
||||
pass(s, actor, ctx);
|
||||
break;
|
||||
case 'leave':
|
||||
// Not eliminated (§8.5): the seat keeps playing on automatic moves.
|
||||
s.left[actor] = true;
|
||||
s.away[actor] = true;
|
||||
ctx.events.push({ type: 'left', player: actor });
|
||||
log(s, { t: 'left', p: actor });
|
||||
break;
|
||||
case 'timeout': {
|
||||
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 });
|
||||
}
|
||||
ctx.events.push({ type: 'timeout', player: actor });
|
||||
log(s, { t: 'timeout', p: actor });
|
||||
timeoutMove(s, actor, ctx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now;
|
||||
return { state: s, events: ctx.events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const me = viewer && s.players.includes(viewer) ? viewer : null;
|
||||
const ph = s.phase;
|
||||
const n = s.order.length;
|
||||
const top = s.trick.top;
|
||||
const w = waiting(s);
|
||||
let my: DalmutiView['my'] = null;
|
||||
if (me) {
|
||||
const hand = s.hands[me]!;
|
||||
const mineNow = ph.kind === 'playing' && s.current === me;
|
||||
const owe = ph.kind === 'taxReturn' && ph.owed[me] && !ph.owed[me]!.done ? { to: ph.owed[me]!.to, count: ph.owed[me]!.count } : null;
|
||||
const handIds = new Set(hand.map((c) => c.id));
|
||||
const received = s.taxLog.filter((t) => t.to === me).flatMap((t) => t.cardIds).filter((id) => handIds.has(id));
|
||||
my = {
|
||||
hand,
|
||||
choices: choicesFor(s, me),
|
||||
jesters: hand.filter(isJ).length,
|
||||
canPlay: mineNow,
|
||||
canPass: mineNow && !!top,
|
||||
leading: mineNow && !top,
|
||||
lockedOut: s.trick.lockedOut.includes(me),
|
||||
canConfirm: ph.kind === 'revolutionCheck' && !ph.confirmed[me],
|
||||
...(canDeclare(s, me) ? { canDeclareRevolution: true } : {}),
|
||||
owe,
|
||||
receivedIds: received,
|
||||
suggestedReturn: owe ? worstCards(hand, owe.count).map((c) => c.id) : [],
|
||||
};
|
||||
}
|
||||
const playing = ph.kind === 'playing';
|
||||
return {
|
||||
options: s.options,
|
||||
me,
|
||||
round: s.round,
|
||||
phase: ph.kind,
|
||||
seats: s.order.map((p, i) => ({
|
||||
id: p,
|
||||
rank: i,
|
||||
title: titleOf(i, n),
|
||||
cards: s.hands[p]!.length,
|
||||
isTurn: w.includes(p),
|
||||
passed: playing && s.trick.passesSinceTop.includes(p),
|
||||
lockedOut: playing && s.trick.lockedOut.includes(p),
|
||||
place: s.finishOrder.includes(p) ? s.finishOrder.indexOf(p) + 1 : null,
|
||||
away: !!s.away[p],
|
||||
left: !!s.left[p],
|
||||
confirmed: ph.kind === 'revolutionCheck' ? !!ph.confirmed[p] : false,
|
||||
total: s.totals[p] ?? 0,
|
||||
})),
|
||||
current: playing ? s.current : null,
|
||||
trick: playing ? { leader: s.trick.leader, top: top ? playView(top) : null, pile: s.trick.pile.map((pl) => playView(pl)) } : null,
|
||||
finishOrder: [...s.finishOrder],
|
||||
revolution: s.revolution,
|
||||
revolutionBy: s.revolutionBy,
|
||||
firstDraw: s.round === 1 ? s.firstDraw : null,
|
||||
tax: s.taxLog.map((t) => ({
|
||||
kind: t.kind,
|
||||
from: t.from,
|
||||
to: t.to,
|
||||
count: t.cardIds.length,
|
||||
cards: me && (me === t.from || me === t.to) ? t.cardIds.map(faceOf).sort((a, b) => a - b) : null,
|
||||
})),
|
||||
taxPending: ph.kind === 'taxReturn' ? Object.entries(ph.owed).map(([from, o]) => ({ from, to: o.to, count: o.count, done: o.done })) : [],
|
||||
roundResults: s.roundResults,
|
||||
discardCount: s.discard.length,
|
||||
deckSize: deckSize(s.options),
|
||||
my,
|
||||
turnStartedAt: s.turnStartedAt,
|
||||
deadlineTotalMs: deadlineMs(s) ?? 0,
|
||||
turnNo: s.turnNo,
|
||||
log: s.log.slice(-LOG_VIEW),
|
||||
result: s.final,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers: waiting,
|
||||
|
||||
deadline(s) {
|
||||
const ms = deadlineMs(s);
|
||||
return ms === null ? null : s.turnStartedAt + ms;
|
||||
},
|
||||
|
||||
timeoutPlayers: waiting,
|
||||
|
||||
timeoutNotBefore(s) {
|
||||
return s.phase.kind === 'playing' ? s.turnStartedAt + MIN_TURN_MS : null;
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (!s.players.includes(player) || s.left[player] || s.phase.kind === 'finished') return null;
|
||||
return { type: 'leave' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.final) return null;
|
||||
return { ranking: s.final.ranking, scores: Object.fromEntries(s.final.players.map((p) => [p.player, p.total])), summary: s.final.summary, reason: 'normal' };
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
if (!s.players.includes(p) || s.phase.kind === 'finished') return [];
|
||||
const ph = s.phase;
|
||||
const out: DalmutiAction[] = [];
|
||||
if (ph.kind === 'revolutionCheck') {
|
||||
if (ph.confirmed[p]) return [];
|
||||
out.push({ type: 'confirmHand' });
|
||||
if (canDeclare(s, p)) out.push({ type: 'confirmHand', declareRevolution: true });
|
||||
return out;
|
||||
}
|
||||
if (ph.kind === 'taxReturn') {
|
||||
const owe = ph.owed[p];
|
||||
if (!owe || owe.done) return [];
|
||||
// One representative card per number keeps the list small; pairs of the same number included.
|
||||
const hand = sortHand(s.hands[p]!);
|
||||
const reps: DCard[] = [];
|
||||
for (const c of hand) if (reps.filter((r) => r.n === c.n).length < owe.count) reps.push(c);
|
||||
if (owe.count === 1) return reps.map((c) => ({ type: 'returnTax', cardIds: [c.id] }));
|
||||
for (let i = 0; i < reps.length; i++) for (let j = i + 1; j < reps.length; j++) out.push({ type: 'returnTax', cardIds: [reps[i]!.id, reps[j]!.id] });
|
||||
return out;
|
||||
}
|
||||
if (s.current !== p) return [];
|
||||
const hand = s.hands[p]!;
|
||||
const J = hand.filter(isJ).length;
|
||||
for (const ch of choicesFor(s, p)) {
|
||||
for (const count of ch.counts) {
|
||||
if (ch.rank === JESTER) {
|
||||
out.push({ type: 'play', cardIds: pickSet(hand, JESTER, count)! });
|
||||
continue;
|
||||
}
|
||||
const minJ = Math.max(0, count - ch.have);
|
||||
for (let j = minJ; j <= Math.min(J, count - 1); j++) {
|
||||
const ids = pickSet(hand, ch.rank, count, j);
|
||||
if (ids) out.push({ type: 'play', cardIds: ids });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s.trick.top) out.push({ type: 'pass' });
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Own hand (card ids exactly as they would appear in a view). */
|
||||
secretsOf(s, player) {
|
||||
return (s.hands[player] ?? []).map((c) => c.id);
|
||||
},
|
||||
};
|
||||
287
packages/games/src/dalmuti/types.ts
Normal file
287
packages/games/src/dalmuti/types.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/** 달무티 options, cards, actions, state and view types (docs/games/dalmuti.md §2, §3, §8). */
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
/** A card: n = 1..12, jester (광대) = 13. */
|
||||
export interface DCard {
|
||||
id: string;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export const JESTER = 13;
|
||||
|
||||
/**
|
||||
* Card names shown on screen. The original rank names are the publisher's wording (§12), so the site
|
||||
* uses its own generic names with the same numbers.
|
||||
*/
|
||||
export const CARD_NAME: Record<number, string> = {
|
||||
1: '왕',
|
||||
2: '왕비',
|
||||
3: '재상',
|
||||
4: '공작',
|
||||
5: '백작',
|
||||
6: '기사',
|
||||
7: '학자',
|
||||
8: '상인',
|
||||
9: '요리사',
|
||||
10: '목동',
|
||||
11: '광부',
|
||||
12: '농부',
|
||||
13: '광대',
|
||||
};
|
||||
|
||||
/** 80 cards: n copies of n (1..12) + 2 jesters, in a fixed order (n, copy). */
|
||||
export const FULL_DECK: readonly DCard[] = [
|
||||
...Array.from({ length: 12 }, (_, i) => i + 1).flatMap((n) => Array.from({ length: n }, (_, k) => ({ id: `c${n}-${k + 1}`, n }))),
|
||||
{ id: 'j1', n: JESTER },
|
||||
{ id: 'j2', n: JESTER },
|
||||
];
|
||||
|
||||
const ORDER = new Map(FULL_DECK.map((c, i) => [c.id, i]));
|
||||
/** Fixed card order (number, then copy) used for tie-breaks and sorting. */
|
||||
export const cardOrder = (id: string): number => ORDER.get(id) ?? 999;
|
||||
|
||||
export function deckFor(removeHighCards: boolean): DCard[] {
|
||||
return FULL_DECK.filter((c) => !removeHighCards || c.n <= 8 || c.n === JESTER);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options / actions
|
||||
|
||||
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 dalmutiOptionsSchema = z.object({
|
||||
rounds: lit<1 | 3 | 5 | 7>(1, 3, 5, 7).default(3).meta({ title: '라운드 수', labels: { 1: '1라운드', 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
|
||||
scoring: z
|
||||
.enum(['rank-points', 'last-round'])
|
||||
.default('rank-points')
|
||||
.meta({ title: '최종 순위 방식', labels: { 'rank-points': '라운드별 순위 점수 합', 'last-round': '마지막 라운드 순위' } }),
|
||||
firstRanking: z
|
||||
.enum(['draw', 'random'])
|
||||
.default('draw')
|
||||
.meta({ title: '첫 라운드 신분 정하기', labels: { draw: '카드 한 장씩 뽑기', random: '무작위' } }),
|
||||
revolution: z.boolean().default(true).meta({ title: '혁명 규칙 사용(광대 2장으로 선언)' }),
|
||||
jesterTaxable: z.boolean().default(false).meta({ title: '세금 낼 때 광대도 좋은 카드로 내기' }),
|
||||
passLocksOut: z.boolean().default(false).meta({ title: '한 번 패스하면 그 트릭에서 다시 못 내기' }),
|
||||
removeHighCards: z.boolean().default(false).meta({ title: '9~12 카드를 빼고 하기(38장)' }),
|
||||
turnSeconds: lit<15 | 20 | 30 | 45>(15, 20, 30, 45)
|
||||
.default(20)
|
||||
.meta({ title: '한 턴 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 45: '45초' } }),
|
||||
});
|
||||
export type DalmutiOptions = z.infer<typeof dalmutiOptionsSchema>;
|
||||
|
||||
export const dalmutiActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('confirmHand'), declareRevolution: z.boolean().optional() }),
|
||||
z.object({ type: z.literal('returnTax'), cardIds: z.array(z.string().max(8)).min(1).max(2) }),
|
||||
z.object({ type: z.literal('play'), cardIds: z.array(z.string().max(8)).min(1).max(14) }),
|
||||
z.object({ type: z.literal('pass') }),
|
||||
]);
|
||||
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
|
||||
export type DalmutiAction = z.infer<typeof dalmutiActionSchema> | { type: 'timeout' } | { type: 'leave' };
|
||||
export type PlayAction = Extract<DalmutiAction, { type: 'play' }>;
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
/** A set on the table. rank: the number of the number cards, or 13 for jesters alone. */
|
||||
export interface Play {
|
||||
cardIds: string[];
|
||||
count: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export type RevolutionKind = 'none' | 'revolution' | 'great';
|
||||
|
||||
/**
|
||||
* The draw (§4-1) and the end of a round (§8.2 `roundOver`) are resolved at once; their results stay in
|
||||
* `firstDraw` / `roundResults` for the screen. So the state only rests in these phases.
|
||||
*/
|
||||
export type Phase =
|
||||
| { kind: 'revolutionCheck'; confirmed: Record<PlayerId, boolean> }
|
||||
| { kind: 'taxReturn'; owed: Record<PlayerId, { to: PlayerId; count: number; done: boolean }> }
|
||||
| { kind: 'playing' }
|
||||
| { kind: 'finished' };
|
||||
|
||||
export interface TaxMove {
|
||||
kind: 'tax' | 'return';
|
||||
from: PlayerId;
|
||||
to: PlayerId;
|
||||
cardIds: string[];
|
||||
}
|
||||
|
||||
export interface Trick {
|
||||
leader: PlayerId;
|
||||
top: (Play & { by: PlayerId }) | null;
|
||||
passesSinceTop: PlayerId[];
|
||||
lockedOut: PlayerId[];
|
||||
pile: (Play & { by: PlayerId })[];
|
||||
}
|
||||
|
||||
export interface RoundResult {
|
||||
round: number;
|
||||
/** Finish order = next round's seating; [0] got the most points. */
|
||||
order: PlayerId[];
|
||||
revolution: RevolutionKind;
|
||||
points: Record<PlayerId, number>;
|
||||
}
|
||||
|
||||
/** Public log line (structured; the screen turns it into Korean with nicknames). */
|
||||
export type LogEntry =
|
||||
| { seq: number; t: 'play'; p: PlayerId; cards: number[]; count: number; rank: number }
|
||||
| { seq: number; t: 'pass'; p: PlayerId }
|
||||
| { seq: number; t: 'trick'; p: PlayerId }
|
||||
| { seq: number; t: 'out'; p: PlayerId; place: number }
|
||||
| { seq: number; t: 'revolution'; p: PlayerId; kind: 'revolution' | 'great' }
|
||||
| { seq: number; t: 'tax'; from: PlayerId; to: PlayerId; n: number }
|
||||
| { seq: number; t: 'return'; from: PlayerId; to: PlayerId; n: number }
|
||||
| { seq: number; t: 'round'; round: number }
|
||||
| { seq: number; t: 'timeout'; p: PlayerId }
|
||||
| { seq: number; t: 'left'; p: PlayerId };
|
||||
|
||||
export interface PlayerFinal {
|
||||
player: PlayerId;
|
||||
rank: number;
|
||||
total: number;
|
||||
roundRanks: number[];
|
||||
}
|
||||
|
||||
export interface DalmutiFinal {
|
||||
ranking: PlayerId[][];
|
||||
players: PlayerFinal[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface DalmutiState {
|
||||
options: DalmutiOptions;
|
||||
/** Participants (fixed). */
|
||||
players: PlayerId[];
|
||||
/** This round's ranks = turn order; [0] = 대달무티. */
|
||||
order: PlayerId[];
|
||||
round: number;
|
||||
/** Secret. */
|
||||
hands: Record<PlayerId, DCard[]>;
|
||||
/** Cards from finished tricks and the last player's leftovers (not sent to clients). */
|
||||
discard: string[];
|
||||
phase: Phase;
|
||||
revolution: RevolutionKind;
|
||||
revolutionBy: PlayerId | null;
|
||||
/** First-round rank draw (§4-1): numbers each player drew, re-draws included. null for `random`. */
|
||||
firstDraw: Record<PlayerId, number[]> | null;
|
||||
/** This round's tax moves; card contents only for the two people involved. */
|
||||
taxLog: TaxMove[];
|
||||
trick: Trick;
|
||||
current: PlayerId;
|
||||
finishOrder: PlayerId[];
|
||||
roundResults: RoundResult[];
|
||||
totals: Record<PlayerId, number>;
|
||||
turnStartedAt: number;
|
||||
/** Changes whenever a new turn or phase starts (resets the timer). */
|
||||
turnNo: number;
|
||||
seq: number;
|
||||
timeoutsInRow: Record<PlayerId, number>;
|
||||
away: Record<PlayerId, boolean>;
|
||||
left: Record<PlayerId, boolean>;
|
||||
log: LogEntry[];
|
||||
final: DalmutiFinal | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
export type Title = '대달무티' | '소달무티' | '평민' | '소농노' | '대농노';
|
||||
|
||||
export interface SeatView {
|
||||
id: PlayerId;
|
||||
/** 0-based rank this round (= turn order). */
|
||||
rank: number;
|
||||
title: Title;
|
||||
cards: number;
|
||||
isTurn: boolean;
|
||||
/** Passed since the current top set. */
|
||||
passed: boolean;
|
||||
lockedOut: boolean;
|
||||
/** Finish place this round (1-based) once out of cards. */
|
||||
place: number | null;
|
||||
away: boolean;
|
||||
left: boolean;
|
||||
/** revolutionCheck: has pressed "확인". */
|
||||
confirmed: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PlayView {
|
||||
by: PlayerId;
|
||||
count: number;
|
||||
rank: number;
|
||||
/** Faces (13 = jester), no ids. */
|
||||
cards: number[];
|
||||
}
|
||||
|
||||
/** One number the player can play now. */
|
||||
export interface Choice {
|
||||
/** 1..12, or 13 for jesters alone. */
|
||||
rank: number;
|
||||
/** Number cards of this rank in hand (jesters for rank 13). */
|
||||
have: number;
|
||||
/** Legal set sizes. */
|
||||
counts: number[];
|
||||
/** Only possible by mixing in jesters (following). */
|
||||
needsJester: boolean;
|
||||
}
|
||||
|
||||
export interface TaxMoveView {
|
||||
kind: 'tax' | 'return';
|
||||
from: PlayerId;
|
||||
to: PlayerId;
|
||||
count: number;
|
||||
/** Faces, only for the giver and the receiver. */
|
||||
cards: number[] | null;
|
||||
}
|
||||
|
||||
export interface DalmutiView {
|
||||
options: DalmutiOptions;
|
||||
me: PlayerId | null;
|
||||
round: number;
|
||||
phase: Phase['kind'];
|
||||
seats: SeatView[];
|
||||
current: PlayerId | null;
|
||||
trick: {
|
||||
leader: PlayerId;
|
||||
top: PlayView | null;
|
||||
pile: PlayView[];
|
||||
} | null;
|
||||
finishOrder: PlayerId[];
|
||||
revolution: RevolutionKind;
|
||||
revolutionBy: PlayerId | null;
|
||||
firstDraw: Record<PlayerId, number[]> | null;
|
||||
tax: TaxMoveView[];
|
||||
/** taxReturn: who still has to give cards back. */
|
||||
taxPending: { from: PlayerId; to: PlayerId; count: number; done: boolean }[];
|
||||
roundResults: RoundResult[];
|
||||
discardCount: number;
|
||||
deckSize: number;
|
||||
my: {
|
||||
hand: DCard[];
|
||||
choices: Choice[];
|
||||
jesters: number;
|
||||
canPlay: boolean;
|
||||
canPass: boolean;
|
||||
/** Leading a new trick (must play). */
|
||||
leading: boolean;
|
||||
lockedOut: boolean;
|
||||
canConfirm: boolean;
|
||||
/** Only in the viewer's own view (§8.3). */
|
||||
canDeclareRevolution?: boolean;
|
||||
/** Cards to give back now (taxReturn). */
|
||||
owe: { to: PlayerId; count: number } | null;
|
||||
/** Received tax cards still in hand (highlight). */
|
||||
receivedIds: string[];
|
||||
/** Default suggestion for the return: the highest number cards. */
|
||||
suggestedReturn: string[];
|
||||
} | null;
|
||||
turnStartedAt: number;
|
||||
deadlineTotalMs: number;
|
||||
turnNo: number;
|
||||
log: LogEntry[];
|
||||
result: DalmutiFinal | null;
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import { gostop } from './gostop';
|
||||
import { bang } from './bang';
|
||||
import { halliGalli } from './halli-galli';
|
||||
import { marble } from './marble';
|
||||
import { dalmuti } from './dalmuti';
|
||||
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,
|
||||
@@ -35,4 +41,10 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[bang.id]: bang,
|
||||
[halliGalli.id]: halliGalli,
|
||||
[marble.id]: marble,
|
||||
[dalmuti.id]: dalmuti,
|
||||
[splendor.id]: splendor,
|
||||
[quoridor.id]: quoridor,
|
||||
[lasVegas.id]: lasVegas,
|
||||
[cockroachPoker.id]: cockroachPoker,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
};
|
||||
|
||||
492
packages/games/src/las-vegas/index.ts
Normal file
492
packages/games/src/las-vegas/index.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 라스베가스 (docs/games/las-vegas.md). Hidden info: the bill pile (only its size is public) and, with
|
||||
* `hideMoney`, the amounts other players won (only the number of bills is public until the end).
|
||||
*/
|
||||
import type { ZodType } from 'zod';
|
||||
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||
import {
|
||||
ALL_NOTES,
|
||||
AWAY_AFTER,
|
||||
AWAY_MS,
|
||||
CASINO_VALUES,
|
||||
HOUSE,
|
||||
OWN_DICE,
|
||||
autoPlaceValue,
|
||||
diceLeft,
|
||||
houseExtraFor,
|
||||
lasVegasActionSchema,
|
||||
lasVegasOptionsSchema,
|
||||
neutralPerFor,
|
||||
ownerOrder,
|
||||
placementPreview,
|
||||
refillCasinos,
|
||||
settleCasino,
|
||||
sum,
|
||||
type CasinoValue,
|
||||
type LasVegasAction,
|
||||
type LasVegasOptions,
|
||||
type LasVegasState,
|
||||
type Note,
|
||||
type Owner,
|
||||
type Payout,
|
||||
} from './rules';
|
||||
|
||||
export * from './rules';
|
||||
|
||||
// ---------------------------------------------------------------- result & view types
|
||||
|
||||
export interface LasVegasRankRow {
|
||||
rank: number;
|
||||
player: PlayerId;
|
||||
total: number;
|
||||
notes: Note[];
|
||||
conceded: boolean;
|
||||
}
|
||||
|
||||
export interface LasVegasResult {
|
||||
ranking: LasVegasRankRow[];
|
||||
winners: PlayerId[];
|
||||
/** Points won per round. */
|
||||
roundTotals: Record<PlayerId, number[]>;
|
||||
summary: string;
|
||||
reason: GameResult['reason'];
|
||||
}
|
||||
|
||||
export interface LasVegasMoneyView {
|
||||
count: number;
|
||||
/** null when hidden from this viewer (`hideMoney`). */
|
||||
total: number | null;
|
||||
notes: Note[] | null;
|
||||
}
|
||||
|
||||
export interface LasVegasPayoutView {
|
||||
casino: number;
|
||||
removedTies: Owner[];
|
||||
/** `note`/`key` are null for another player's bill while `hideMoney` hides amounts. */
|
||||
awards: { owner: Owner; note: Note | null; key: string | null }[];
|
||||
returned: Note[];
|
||||
}
|
||||
|
||||
export interface LasVegasChoice {
|
||||
value: number;
|
||||
own: number;
|
||||
neutral: number;
|
||||
/** For the player to move, after placing these dice (§9 미리 보기). */
|
||||
standing: 'first' | 'award' | 'tie' | 'none';
|
||||
note: Note | null;
|
||||
countAfter: number;
|
||||
}
|
||||
|
||||
export interface LasVegasView {
|
||||
options: LasVegasOptions;
|
||||
me: PlayerId | null;
|
||||
seats: PlayerId[];
|
||||
round: number;
|
||||
rounds: number;
|
||||
startPlayer: PlayerId;
|
||||
/** Player to act, null once finished. */
|
||||
current: PlayerId | null;
|
||||
phase: LasVegasState['phase'];
|
||||
pileCount: number;
|
||||
casinos: { value: CasinoValue; notes: Note[]; dice: Partial<Record<Owner, number>> }[];
|
||||
hand: Record<PlayerId, { own: number; neutral: number }>;
|
||||
neutralPer: number;
|
||||
houseExtra: number;
|
||||
houseRoll: LasVegasState['houseRoll'];
|
||||
lastRoll: LasVegasState['lastRoll'];
|
||||
/** Number groups of the current roll with previews (phase `place`). */
|
||||
choices: LasVegasChoice[];
|
||||
/** What a timeout would pick now (phase `place`). */
|
||||
autoValue: number | null;
|
||||
lastPlace: LasVegasState['lastPlace'];
|
||||
money: Record<PlayerId, LasVegasMoneyView>;
|
||||
roundResults: { round: number; payouts: LasVegasPayoutView[] }[];
|
||||
conceded: PlayerId[];
|
||||
away: PlayerId[];
|
||||
rollSeq: number;
|
||||
turnSeq: number;
|
||||
deadlineAt: number | null;
|
||||
result: LasVegasResult | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- state machine
|
||||
|
||||
type S = LasVegasState;
|
||||
|
||||
const curId = (s: S): PlayerId => s.seats[s.current]!;
|
||||
const isIn = (s: S, p: PlayerId) => !s.conceded.includes(p);
|
||||
const activeIds = (s: S) => s.seats.filter((p) => isIn(s, p));
|
||||
/** Stable React key / leak-check token of one received bill. */
|
||||
export const awardKey = (round: number, casino: number, owner: Owner, note: Note): string => `lv-r${round}c${casino}-${owner}-${note}`;
|
||||
|
||||
function activeOf(s: S): PlayerId[] {
|
||||
return s.phase === 'finished' ? [] : [curId(s)];
|
||||
}
|
||||
|
||||
function refreshDeadline(s: S, now: number): void {
|
||||
if (s.phase === 'finished') {
|
||||
s.deadlineAt = null;
|
||||
return;
|
||||
}
|
||||
const away = (s.timeouts[curId(s)] ?? 0) >= AWAY_AFTER;
|
||||
if (away) s.deadlineAt = now + AWAY_MS;
|
||||
else s.deadlineAt = s.options.turnSeconds === null ? null : now + s.options.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
function finish(s: S, ev: GameEvent[]): void {
|
||||
s.phase = 'finished';
|
||||
s.lastRoll = null;
|
||||
s.deadlineAt = null;
|
||||
ev.push({ type: 'gameOver' });
|
||||
}
|
||||
|
||||
function startRound(s: S, rng: Rng, ev: GameEvent[]): void {
|
||||
for (const p of s.seats) s.hand[p] = isIn(s, p) ? { own: OWN_DICE, neutral: s.neutralPer } : { own: 0, neutral: 0 };
|
||||
refillCasinos(s);
|
||||
ev.push({ type: 'casinosRefilled', round: s.round, casinos: s.casinos.map((c) => [...c.notes]) });
|
||||
s.houseRoll = null;
|
||||
if (s.houseExtra > 0) {
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < s.houseExtra; i++) values.push(1 + rng.int(6));
|
||||
for (const v of values) {
|
||||
const c = s.casinos[v - 1]!;
|
||||
c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + 1;
|
||||
}
|
||||
const player = s.seats[s.startSeat]!;
|
||||
s.houseRoll = { round: s.round, player, values };
|
||||
ev.push({ type: 'houseDicePlaced', player, values: [...values] });
|
||||
}
|
||||
s.current = diceLeft(s, s.seats[s.startSeat]!) > 0 ? s.startSeat : nextSeat(s.startSeat, s.seats.length, (i) => diceLeft(s, s.seats[i]!) === 0);
|
||||
s.phase = 'roll';
|
||||
s.lastRoll = null;
|
||||
s.turnSeq++;
|
||||
ev.push({ type: 'roundStart', round: s.round, player: curId(s) });
|
||||
}
|
||||
|
||||
function settleRound(s: S, ev: GameEvent[]): void {
|
||||
const payouts: Payout[] = [];
|
||||
const back: Note[] = [];
|
||||
const order = ownerOrder(s);
|
||||
for (const c of s.casinos) {
|
||||
const { removedTies, awards, returned } = settleCasino(c.notes, c.dice, order);
|
||||
if (removedTies.length) ev.push({ type: 'tiesRemoved', casino: c.value, owners: [...removedTies] });
|
||||
for (const a of awards) {
|
||||
if (a.owner !== HOUSE) s.money[a.owner]!.push(a.note);
|
||||
ev.push({ type: 'paid', casino: c.value, owner: a.owner, ...(a.owner === HOUSE || !s.options.hideMoney ? { note: a.note } : {}) });
|
||||
}
|
||||
back.push(...returned);
|
||||
payouts.push({ casino: c.value, removedTies, awards, returned });
|
||||
c.notes = [];
|
||||
c.dice = {};
|
||||
}
|
||||
s.pile.push(...back);
|
||||
if (back.length) ev.push({ type: 'notesReturned', notes: back });
|
||||
s.roundResults.push({ round: s.round, payouts });
|
||||
ev.push({ type: 'roundEnd', round: s.round });
|
||||
}
|
||||
|
||||
/** Next seat with dice left (the same player again if only they have dice); settles the round when nobody has. */
|
||||
function advance(s: S, rng: Rng, ev: GameEvent[]): void {
|
||||
s.lastRoll = null;
|
||||
const n = s.seats.length;
|
||||
const hasDice = (i: number) => diceLeft(s, s.seats[i]!) > 0;
|
||||
const next = nextSeat(s.current, n, (i) => !hasDice(i));
|
||||
if (hasDice(next)) {
|
||||
s.current = next;
|
||||
s.phase = 'roll';
|
||||
s.turnSeq++;
|
||||
ev.push({ type: 'turnChanged', player: curId(s) });
|
||||
return;
|
||||
}
|
||||
settleRound(s, ev);
|
||||
if (s.round >= s.options.rounds) {
|
||||
finish(s, ev);
|
||||
return;
|
||||
}
|
||||
s.round++;
|
||||
s.startSeat = nextSeat(s.startSeat, n, (i) => !isIn(s, s.seats[i]!));
|
||||
startRound(s, rng, ev);
|
||||
}
|
||||
|
||||
function doRoll(s: S, rng: Rng, ev: GameEvent[]): void {
|
||||
const p = curId(s);
|
||||
const h = s.hand[p]!;
|
||||
const own: number[] = [];
|
||||
const neutral: number[] = [];
|
||||
for (let i = 0; i < h.own; i++) own.push(1 + rng.int(6));
|
||||
for (let i = 0; i < h.neutral; i++) neutral.push(1 + rng.int(6));
|
||||
s.lastRoll = { own, neutral };
|
||||
s.phase = 'place';
|
||||
s.rollSeq++;
|
||||
ev.push({ type: 'rolled', player: p, own: [...own], neutral: [...neutral] });
|
||||
}
|
||||
|
||||
function doPlace(s: S, value: number, auto: boolean, rng: Rng, ev: GameEvent[]): void {
|
||||
const p = curId(s);
|
||||
const roll = s.lastRoll;
|
||||
if (!roll) throw new Error('las-vegas: place without a roll');
|
||||
const ownCount = roll.own.filter((d) => d === value).length;
|
||||
const neutralCount = roll.neutral.filter((d) => d === value).length;
|
||||
if (ownCount + neutralCount === 0) throw new Error('las-vegas: value not rolled');
|
||||
const c = s.casinos[value - 1]!;
|
||||
if (ownCount) c.dice[p] = (c.dice[p] ?? 0) + ownCount;
|
||||
if (neutralCount) c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + neutralCount;
|
||||
s.hand[p]!.own -= ownCount;
|
||||
s.hand[p]!.neutral -= neutralCount;
|
||||
s.lastPlace = { player: p, value, own: ownCount, neutral: neutralCount, auto };
|
||||
ev.push({ type: 'placed', player: p, value, ownCount, neutralCount, auto });
|
||||
advance(s, rng, ev);
|
||||
}
|
||||
|
||||
function doConcede(s: S, p: PlayerId, rng: Rng, ev: GameEvent[]): void {
|
||||
const wasCurrent = curId(s) === p;
|
||||
s.conceded.push(p);
|
||||
s.hand[p] = { own: 0, neutral: 0 };
|
||||
for (const c of s.casinos) delete c.dice[p];
|
||||
ev.push({ type: 'conceded', player: p });
|
||||
if (activeIds(s).length <= 1) {
|
||||
finish(s, ev);
|
||||
return;
|
||||
}
|
||||
if (wasCurrent) advance(s, rng, ev);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- result
|
||||
|
||||
function rankRows(s: S): LasVegasRankRow[] {
|
||||
const rows = s.seats.map((p) => ({ player: p, total: sum(s.money[p]!), notes: [...s.money[p]!].sort((a, b) => b - a), conceded: !isIn(s, p) }));
|
||||
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total || b.notes.length - a.notes.length);
|
||||
const out: LasVegasRankRow[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
const prev = out[i - 1];
|
||||
const tied = prev && prev.conceded === r.conceded && prev.total === r.total && prev.notes.length === r.notes.length;
|
||||
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildResult(s: S): LasVegasResult {
|
||||
const ranking = rankRows(s);
|
||||
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
|
||||
const roundTotals: Record<PlayerId, number[]> = {};
|
||||
for (const p of s.seats)
|
||||
roundTotals[p] = s.roundResults.map((rr) => sum(rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === p).map((a) => a.note))));
|
||||
const top = ranking[0]!;
|
||||
const early = s.conceded.length > 0 && activeIds(s).length <= 1;
|
||||
let summary: string;
|
||||
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${top.total}점 (지폐 ${top.notes.length}장)`;
|
||||
else {
|
||||
const second = ranking[1];
|
||||
summary = `1위 ${top.total}점 (지폐 ${top.notes.length}장)${second ? ` · 2위 ${second.total}점` : ''}`;
|
||||
}
|
||||
if (early) summary = `다른 사람이 모두 기권했어요. ${summary}`;
|
||||
return { ranking, winners, roundTotals, summary, reason: early ? 'resign' : 'normal' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const lasVegas: GameDefinition<LasVegasState, LasVegasAction, LasVegasView, LasVegasOptions> = {
|
||||
id: 'las-vegas',
|
||||
nameKo: '라스베가스',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 5,
|
||||
stateVersion: 1,
|
||||
defaultOptions: lasVegasOptionsSchema.parse({}),
|
||||
optionsSchema: lasVegasOptionsSchema as unknown as ZodType<LasVegasOptions>,
|
||||
actionSchema: lasVegasActionSchema as unknown as ZodType<LasVegasAction>,
|
||||
|
||||
playerCountError(options, count) {
|
||||
return neutralPerFor(options.neutralDice, count) === null ? '5명일 때는 중립(흰) 주사위를 켤 수 없어요. "자동"이나 "끄기"로 바꿔 주세요.' : null;
|
||||
},
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 2 || players.length > 5) throw new Error('las-vegas: 2~5명이 할 수 있어요.');
|
||||
if (players.includes(HOUSE)) throw new Error('las-vegas: reserved player id');
|
||||
const neutralPer = neutralPerFor(options.neutralDice, players.length);
|
||||
if (neutralPer === null) throw new Error('las-vegas: 5명일 때는 중립 주사위를 켤 수 없어요.');
|
||||
const pile = rng.shuffle(ALL_NOTES);
|
||||
const startSeat = rng.int(players.length);
|
||||
const s: S = {
|
||||
options,
|
||||
seats: [...players],
|
||||
round: 1,
|
||||
startSeat,
|
||||
current: startSeat,
|
||||
phase: 'roll',
|
||||
pile,
|
||||
casinos: CASINO_VALUES.map((value) => ({ value, notes: [], dice: {} })),
|
||||
hand: {},
|
||||
lastRoll: null,
|
||||
money: Object.fromEntries(players.map((p) => [p, []])),
|
||||
roundResults: [],
|
||||
neutralPer,
|
||||
houseExtra: houseExtraFor(neutralPer, players.length),
|
||||
houseRoll: null,
|
||||
lastPlace: null,
|
||||
conceded: [],
|
||||
timeouts: Object.fromEntries(players.map((p) => [p, 0])),
|
||||
rollSeq: 0,
|
||||
turnSeq: 0,
|
||||
deadlineAt: null,
|
||||
};
|
||||
startRound(s, rng, []);
|
||||
refreshDeadline(s, now);
|
||||
return s;
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
if (!s.money[actor]) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
|
||||
if (!isIn(s, actor)) return fail('이미 기권했어요.');
|
||||
if (a.type === 'CONCEDE') return ok;
|
||||
if (a.type === 'TIMEOUT') return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.');
|
||||
if (curId(s) !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||
switch (a.type) {
|
||||
case 'ROLL':
|
||||
if (s.phase !== 'roll' || diceLeft(s, actor) === 0) return fail('지금은 굴릴 차례가 아니에요.');
|
||||
return ok;
|
||||
case 'PLACE': {
|
||||
if (s.phase !== 'place' || !s.lastRoll) return fail('먼저 주사위를 굴려 주세요.');
|
||||
const v = a.value;
|
||||
if (!Number.isInteger(v) || !s.lastRoll.own.concat(s.lastRoll.neutral).includes(v)) return fail('그 숫자는 나오지 않았어요.');
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
return fail('알 수 없는 행동이에요.');
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const ev: GameEvent[] = [];
|
||||
if (a.type === 'TIMEOUT') {
|
||||
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
|
||||
ev.push({ type: 'timeout', player: actor });
|
||||
if (s.timeouts[actor] === AWAY_AFTER) ev.push({ type: 'away', player: actor });
|
||||
if (s.phase === 'roll') doRoll(s, rng, ev);
|
||||
else doPlace(s, autoPlaceValue(s, s.lastRoll!), true, rng, ev);
|
||||
} else {
|
||||
if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
|
||||
switch (a.type) {
|
||||
case 'ROLL':
|
||||
if (s.phase !== 'roll') throw new Error('las-vegas: not the roll phase');
|
||||
doRoll(s, rng, ev);
|
||||
break;
|
||||
case 'PLACE':
|
||||
if (s.phase !== 'place') throw new Error('las-vegas: not the place phase');
|
||||
doPlace(s, a.value, false, rng, ev);
|
||||
break;
|
||||
case 'CONCEDE':
|
||||
doConcede(s, actor, rng, ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
refreshDeadline(s, now);
|
||||
return { state: s, events: ev };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const me = viewer !== null && s.money[viewer] ? viewer : null;
|
||||
const done = s.phase === 'finished';
|
||||
const visible = (p: Owner) => done || !s.options.hideMoney || p === me || p === HOUSE;
|
||||
const money: LasVegasView['money'] = {};
|
||||
for (const p of s.seats) {
|
||||
const notes = s.money[p]!;
|
||||
money[p] = visible(p)
|
||||
? { count: notes.length, total: sum(notes), notes: [...notes].sort((a, b) => b - a) }
|
||||
: { count: notes.length, total: null, notes: null };
|
||||
}
|
||||
const roundResults = s.roundResults.map((rr) => ({
|
||||
round: rr.round,
|
||||
payouts: rr.payouts.map((po) => ({
|
||||
casino: po.casino,
|
||||
removedTies: [...po.removedTies],
|
||||
awards: po.awards.map((aw) =>
|
||||
visible(aw.owner)
|
||||
? { owner: aw.owner, note: aw.note, key: awardKey(rr.round, po.casino, aw.owner, aw.note) }
|
||||
: { owner: aw.owner, note: null, key: null },
|
||||
),
|
||||
returned: [...po.returned],
|
||||
})),
|
||||
}));
|
||||
const cur = done ? null : curId(s);
|
||||
const choices: LasVegasChoice[] = [];
|
||||
if (cur && s.phase === 'place' && s.lastRoll) {
|
||||
for (const v of CASINO_VALUES) {
|
||||
const own = s.lastRoll.own.filter((d) => d === v).length;
|
||||
const neutral = s.lastRoll.neutral.filter((d) => d === v).length;
|
||||
if (own + neutral === 0) continue;
|
||||
const pv = placementPreview(s, cur, v, own, neutral);
|
||||
choices.push({ value: v, own, neutral, standing: pv.standing, note: pv.note, countAfter: pv.count });
|
||||
}
|
||||
}
|
||||
return {
|
||||
options: s.options,
|
||||
me,
|
||||
seats: [...s.seats],
|
||||
round: s.round,
|
||||
rounds: s.options.rounds,
|
||||
startPlayer: s.seats[s.startSeat]!,
|
||||
current: cur,
|
||||
phase: s.phase,
|
||||
pileCount: s.pile.length,
|
||||
casinos: s.casinos.map((c) => ({ value: c.value, notes: [...c.notes], dice: { ...c.dice } })),
|
||||
hand: Object.fromEntries(s.seats.map((p) => [p, { ...s.hand[p]! }])),
|
||||
neutralPer: s.neutralPer,
|
||||
houseExtra: s.houseExtra,
|
||||
houseRoll: s.houseRoll,
|
||||
lastRoll: s.lastRoll,
|
||||
choices,
|
||||
autoValue: cur && s.phase === 'place' && s.lastRoll ? autoPlaceValue(s, s.lastRoll) : null,
|
||||
lastPlace: s.lastPlace,
|
||||
money,
|
||||
roundResults,
|
||||
conceded: [...s.conceded],
|
||||
away: s.seats.filter((p) => isIn(s, p) && (s.timeouts[p] ?? 0) >= AWAY_AFTER),
|
||||
rollSeq: s.rollSeq,
|
||||
turnSeq: s.turnSeq,
|
||||
deadlineAt: s.deadlineAt,
|
||||
result: done ? buildResult(s) : null,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers: activeOf,
|
||||
|
||||
deadline(s) {
|
||||
return s.phase === 'finished' ? null : s.deadlineAt;
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'TIMEOUT' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.phase === 'finished' || !s.money[player] || !isIn(s, player)) return null;
|
||||
return { type: 'CONCEDE' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (s.phase !== 'finished') return null;
|
||||
const r = buildResult(s);
|
||||
const groups = new Map<number, PlayerId[]>();
|
||||
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
|
||||
const scores: Record<PlayerId, number> = {};
|
||||
for (const row of r.ranking) scores[row.player] = row.total;
|
||||
return { ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), scores, summary: r.summary, reason: r.reason };
|
||||
},
|
||||
|
||||
/** Test helper: ROLL, or one PLACE per rolled number (CONCEDE left out). */
|
||||
legalActions(s, player) {
|
||||
if (s.phase === 'finished' || curId(s) !== player || !isIn(s, player)) return [];
|
||||
if (s.phase === 'roll') return [{ type: 'ROLL' }];
|
||||
const vals = [...new Set([...s.lastRoll!.own, ...s.lastRoll!.neutral])].sort((a, b) => a - b);
|
||||
return vals.map((value) => ({ type: 'PLACE' as const, value }));
|
||||
},
|
||||
|
||||
/** Test helper: keys of this player's bills that other viewers must not see (while amounts are hidden). */
|
||||
secretsOf(s, player) {
|
||||
if (s.phase === 'finished' || !s.options.hideMoney) return [];
|
||||
return s.roundResults.flatMap((rr) =>
|
||||
rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === player).map((a) => awardKey(rr.round, po.casino, player, a.note))),
|
||||
);
|
||||
},
|
||||
};
|
||||
712
packages/games/src/las-vegas/las-vegas.test.ts
Normal file
712
packages/games/src/las-vegas/las-vegas.test.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
|
||||
import {
|
||||
ALL_NOTES,
|
||||
AWAY_AFTER,
|
||||
AWAY_MS,
|
||||
HOUSE,
|
||||
MIN_CASINO_SUM,
|
||||
NOTE_COUNTS,
|
||||
OWN_DICE,
|
||||
autoPlaceValue,
|
||||
lasVegas as lv,
|
||||
lasVegasActionSchema,
|
||||
lasVegasOptionsSchema,
|
||||
refillCasinos,
|
||||
settleCasino,
|
||||
sum,
|
||||
type LasVegasAction,
|
||||
type LasVegasOptions,
|
||||
type LasVegasState,
|
||||
type LasVegasView,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
let clock = 1_000_000;
|
||||
const rng = () => SeededRng.fromSeed([5, 4, 3, 2]);
|
||||
|
||||
function make(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C'], seed = 1): LasVegasState {
|
||||
const options = lasVegasOptionsSchema.parse(opts);
|
||||
return lv.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
|
||||
}
|
||||
|
||||
/** A game whose round-1 first player is seat 0 (seeds are searched). */
|
||||
function makeFirst(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C']): LasVegasState {
|
||||
for (let seed = 1; seed < 500; seed++) {
|
||||
const s = make(opts, players, seed);
|
||||
if (s.startSeat === 0 && (s.houseExtra === 0 || s.houseRoll)) return s;
|
||||
}
|
||||
throw new Error('no seed starts at seat 0');
|
||||
}
|
||||
|
||||
function actE(s: LasVegasState, p: string, a: LasVegasAction, r: SeededRng = rng()): { state: LasVegasState; events: GameEvent[] } {
|
||||
const v = lv.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return lv.apply(s, p, a, { rng: r, now: (clock += 1000) });
|
||||
}
|
||||
const act = (s: LasVegasState, p: string, a: LasVegasAction, r?: SeededRng) => actE(s, p, a, r).state;
|
||||
const reject = (s: LasVegasState, p: string, a: LasVegasAction) => {
|
||||
const v = lv.validate(s, p, a);
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
const cur = (s: LasVegasState) => s.seats[s.current]!;
|
||||
|
||||
/** Rolls for the current player, then forces the dice (lengths must match the hand). */
|
||||
function rolled(s: LasVegasState, own: number[], neutral: number[] = []): LasVegasState {
|
||||
const p = cur(s);
|
||||
const t = act(s, p, { type: 'ROLL' });
|
||||
expect(t.lastRoll!.own.length).toBe(own.length);
|
||||
expect(t.lastRoll!.neutral.length).toBe(neutral.length);
|
||||
t.lastRoll = { own: [...own], neutral: [...neutral] };
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Sets every casino's bills (missing = no bills); bills come out of the pile so 54 are conserved. */
|
||||
function setBills(s: LasVegasState, bills: Record<number, number[]>): void {
|
||||
const pool = [...s.pile, ...s.casinos.flatMap((c) => c.notes)];
|
||||
for (const c of s.casinos) {
|
||||
c.notes = [...(bills[c.value] ?? [])].sort((a, b) => b - a);
|
||||
for (const n of c.notes) {
|
||||
const i = pool.indexOf(n);
|
||||
if (i < 0) throw new Error(`no bill ${n} left`);
|
||||
pool.splice(i, 1);
|
||||
}
|
||||
}
|
||||
s.pile = pool;
|
||||
}
|
||||
|
||||
/** Puts dice on a casino straight from the owners' hands (house dice need no hand). */
|
||||
function putDice(s: LasVegasState, value: number, dice: Record<string, number>): void {
|
||||
const c = s.casinos[value - 1]!;
|
||||
for (const [o, n] of Object.entries(dice)) {
|
||||
c.dice[o] = (c.dice[o] ?? 0) + n;
|
||||
if (o !== HOUSE) s.hand[o]!.own -= n;
|
||||
}
|
||||
}
|
||||
|
||||
const allBills = (s: LasVegasState) => [...s.pile, ...s.casinos.flatMap((c) => c.notes), ...Object.values(s.money).flat()].sort((a, b) => a - b);
|
||||
const SORTED_NOTES = [...ALL_NOTES].sort((a, b) => a - b);
|
||||
|
||||
// ---------------------------------------------------------------- setup
|
||||
|
||||
describe('setup (§3, §4, §10)', () => {
|
||||
test('54 bills 6·8·8·6·6·5·5·5·5 = 250', () => {
|
||||
expect(ALL_NOTES.length).toBe(54);
|
||||
expect(sum(ALL_NOTES)).toBe(250);
|
||||
expect(Object.values(NOTE_COUNTS)).toEqual([6, 8, 8, 6, 6, 5, 5, 5, 5]);
|
||||
for (let seed = 1; seed <= 50; seed++) expect(allBills(make({}, ['A', 'B'], seed))).toEqual(SORTED_NOTES);
|
||||
});
|
||||
|
||||
test('each casino ≥ 5, and without the last bill dealt it would be < 5 (dealt from the shuffled pile, top first)', () => {
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const s = make({}, ['A', 'B', 'C'], seed);
|
||||
// Re-create the shuffle: setup shuffles first.
|
||||
const pile = SeededRng.fromSeed(seedN(seed)).shuffle(ALL_NOTES);
|
||||
for (const c of s.casinos) {
|
||||
const dealt: number[] = [];
|
||||
while (sum(dealt) < MIN_CASINO_SUM) dealt.push(pile.shift()!);
|
||||
expect(c.notes).toEqual([...dealt].sort((a, b) => b - a));
|
||||
expect(sum(c.notes)).toBeGreaterThanOrEqual(5);
|
||||
expect(sum(dealt) - dealt[dealt.length - 1]!).toBeLessThan(5);
|
||||
}
|
||||
expect(s.pile).toEqual(pile);
|
||||
}
|
||||
});
|
||||
|
||||
test('refill: [1,1,1,1,9,…] gives casino 1 four 1s + 9; an empty pile leaves casinos short', () => {
|
||||
const t = { pile: [1, 1, 1, 1, 9, 5, 2, 3, 6], casinos: [1, 2, 3, 4, 5, 6].map((v) => ({ value: v as 1, notes: [] as number[], dice: {} })) };
|
||||
refillCasinos(t);
|
||||
expect(t.casinos.map((c) => c.notes)).toEqual([[9, 1, 1, 1, 1], [5], [3, 2], [6], [], []]);
|
||||
expect(t.pile).toEqual([]);
|
||||
});
|
||||
|
||||
test('dice per player: 8 own; neutral auto = 4 each for 2 players only; on = 4 (2p) / 2 (3–4p); off = 0', () => {
|
||||
expect(make({}, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 4 });
|
||||
expect(make({}, ['A', 'B', 'C']).hand.A).toEqual({ own: 8, neutral: 0 });
|
||||
expect(make({ neutralDice: 'on' }, ['A', 'B']).hand.B).toEqual({ own: 8, neutral: 4 });
|
||||
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).hand.D).toEqual({ own: 8, neutral: 2 });
|
||||
expect(make({ neutralDice: 'off' }, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 0 });
|
||||
expect(make({}, ['A', 'B', 'C', 'D', 'E']).hand.E).toEqual({ own: 8, neutral: 0 });
|
||||
});
|
||||
|
||||
test('5 players + neutral on is rejected (option check), auto/off are fine', () => {
|
||||
const on = lasVegasOptionsSchema.parse({ neutralDice: 'on' });
|
||||
expect(lv.playerCountError!(on, 5)).toContain('5명');
|
||||
expect(lv.playerCountError!(on, 4)).toBeNull();
|
||||
expect(lv.playerCountError!(lasVegasOptionsSchema.parse({}), 5)).toBeNull();
|
||||
expect(() => make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D', 'E'])).toThrow();
|
||||
});
|
||||
|
||||
test('the first player is chosen by the RNG (varies by seed)', () => {
|
||||
const starts = new Set<number>();
|
||||
for (let seed = 1; seed <= 40; seed++) starts.add(make({}, ['A', 'B', 'C', 'D'], seed).startSeat);
|
||||
expect(starts.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- turns
|
||||
|
||||
describe('ROLL / PLACE (§5, §8.2, §10)', () => {
|
||||
test('PLACE moves every die of that number (own + neutral); unrolled numbers and out-of-turn are rejected', () => {
|
||||
let s = makeFirst({ neutralDice: 'on' }, ['A', 'B']);
|
||||
expect(reject(s, 'A', { type: 'PLACE', value: 3 })).toBe('먼저 주사위를 굴려 주세요.');
|
||||
expect(reject(s, 'B', { type: 'ROLL' })).toBe('지금은 내 차례가 아니에요.');
|
||||
s = rolled(s, [3, 3, 5, 3, 1, 1, 2, 2], [3, 6, 6, 4]);
|
||||
expect(reject(s, 'A', { type: 'ROLL' })).toBe('지금은 굴릴 차례가 아니에요.');
|
||||
expect(reject(s, 'A', { type: 'PLACE', value: 4.5 } as never)).toBe('그 숫자는 나오지 않았어요.');
|
||||
const v = lv.view(s, 'A');
|
||||
expect(v.choices.map((c) => [c.value, c.own, c.neutral])).toEqual([
|
||||
[1, 2, 0],
|
||||
[2, 2, 0],
|
||||
[3, 3, 1],
|
||||
[4, 0, 1],
|
||||
[5, 1, 0],
|
||||
[6, 0, 2],
|
||||
]);
|
||||
const { state, events } = actE(s, 'A', { type: 'PLACE', value: 3 });
|
||||
expect(state.casinos[2]!.dice).toEqual({ A: 3, house: 1 });
|
||||
expect(state.hand.A).toEqual({ own: 5, neutral: 3 });
|
||||
expect(events.find((e) => e.type === 'placed')).toMatchObject({ player: 'A', value: 3, ownCount: 3, neutralCount: 1 });
|
||||
expect(cur(state)).toBe('B');
|
||||
expect(state.phase).toBe('roll');
|
||||
});
|
||||
|
||||
test('a number that only came up on neutral dice may be chosen: only the neutral dice move', () => {
|
||||
let s = makeFirst({}, ['A', 'B']);
|
||||
s = rolled(s, [1, 1, 2, 2, 3, 3, 4, 4], [6, 6, 5, 1]);
|
||||
expect(reject(s, 'A', { type: 'PLACE', value: 6 })).toBeNull();
|
||||
s = act(s, 'A', { type: 'PLACE', value: 6 });
|
||||
expect(s.casinos[5]!.dice).toEqual({ house: 2 });
|
||||
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
|
||||
});
|
||||
|
||||
test('players without dice are skipped; the last one with dice takes turns in a row', () => {
|
||||
let s = makeFirst({}, ['A', 'B', 'C']);
|
||||
// A places all 8 at once, B 7 of 8, C all 8.
|
||||
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
|
||||
s = act(s, 'A', { type: 'PLACE', value: 2 });
|
||||
s = rolled(s, [4, 4, 4, 4, 4, 4, 4, 1]);
|
||||
s = act(s, 'B', { type: 'PLACE', value: 4 });
|
||||
expect(cur(s)).toBe('C');
|
||||
s = rolled(s, [5, 5, 5, 5, 5, 5, 5, 5]);
|
||||
s = act(s, 'C', { type: 'PLACE', value: 5 });
|
||||
// A has no dice: B again.
|
||||
expect(cur(s)).toBe('B');
|
||||
expect(s.hand.B).toEqual({ own: 1, neutral: 0 });
|
||||
s = rolled(s, [6]);
|
||||
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 6 });
|
||||
expect(events.some((e) => e.type === 'roundEnd')).toBe(true);
|
||||
expect(state.round).toBe(2);
|
||||
});
|
||||
|
||||
test('3 players: A,B used up → C rolls several times in a row', () => {
|
||||
let s = makeFirst({}, ['A', 'B', 'C']);
|
||||
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
|
||||
s = act(s, 'A', { type: 'PLACE', value: 2 });
|
||||
s = rolled(s, [3, 3, 3, 3, 3, 3, 3, 3]);
|
||||
s = act(s, 'B', { type: 'PLACE', value: 3 });
|
||||
s = rolled(s, [1, 1, 1, 1, 4, 4, 4, 4]);
|
||||
s = act(s, 'C', { type: 'PLACE', value: 1 });
|
||||
expect(cur(s)).toBe('C');
|
||||
s = rolled(s, [4, 4, 5, 6]);
|
||||
s = act(s, 'C', { type: 'PLACE', value: 4 });
|
||||
expect(cur(s)).toBe('C');
|
||||
expect(s.round).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- settlement
|
||||
|
||||
describe('settlement (§5, §5.1, §10)', () => {
|
||||
const order = ['A', 'B', 'C', 'D', HOUSE];
|
||||
test('example 1: [8,3,1] A5 B3 C3 D1 → B·C removed, A 8, D 3, 1 returned', () => {
|
||||
const r = settleCasino([8, 3, 1], { A: 5, B: 3, C: 3, D: 1 }, order);
|
||||
expect(r.removedTies).toEqual(['B', 'C']);
|
||||
expect(r.awards).toEqual([
|
||||
{ owner: 'A', note: 8 },
|
||||
{ owner: 'D', note: 3 },
|
||||
]);
|
||||
expect(r.returned).toEqual([1]);
|
||||
});
|
||||
test('example 2: [4,4] A2 C2 B1 D1 → everyone removed, both bills returned', () => {
|
||||
const r = settleCasino([4, 4], { A: 2, C: 2, B: 1, D: 1 }, order);
|
||||
expect(r.removedTies).toEqual(['A', 'B', 'C', 'D']);
|
||||
expect(r.awards).toEqual([]);
|
||||
expect(r.returned).toEqual([4, 4]);
|
||||
});
|
||||
test('example 3: [7,2] C4 B2 D1 → C 7, B 2, D nothing', () => {
|
||||
const r = settleCasino([7, 2], { C: 4, B: 2, D: 1 }, order);
|
||||
expect(r.removedTies).toEqual([]);
|
||||
expect(r.awards).toEqual([
|
||||
{ owner: 'C', note: 7 },
|
||||
{ owner: 'B', note: 2 },
|
||||
]);
|
||||
expect(r.returned).toEqual([]);
|
||||
});
|
||||
test('example 4 (neutral, 2 players): [9] house 3, A 2, B 1 → the house takes 9 (returned), A·B nothing', () => {
|
||||
const r = settleCasino([9], { house: 3, A: 2, B: 1 }, ['A', 'B', HOUSE]);
|
||||
expect(r.awards).toEqual([{ owner: HOUSE, note: 9 }]);
|
||||
expect(r.returned).toEqual([9]);
|
||||
});
|
||||
test('house and a player tie → both removed', () => {
|
||||
const r = settleCasino([6, 5], { house: 2, A: 2, B: 1 }, ['A', 'B', HOUSE]);
|
||||
expect(r.removedTies).toEqual(['A', HOUSE]);
|
||||
expect(r.awards).toEqual([{ owner: 'B', note: 6 }]);
|
||||
expect(r.returned).toEqual([5]);
|
||||
});
|
||||
|
||||
test('a full round in play: payouts, money, roundResults and bills back to the bottom in casino 1→6 order, high first', () => {
|
||||
let s = makeFirst({}, ['A', 'B']);
|
||||
setBills(s, { 1: [9], 2: [8, 3, 1], 3: [4, 4], 4: [7, 2], 5: [5], 6: [6, 1] });
|
||||
const pileBefore = [...s.pile];
|
||||
// Board before the last placement: casino 1 house 3/A 2/B 1, casino 2 A3/B3, casino 3 A2/B2, casino 4 B1, casino 6 house 1/A 1.
|
||||
putDice(s, 1, { house: 3, A: 2, B: 1 });
|
||||
putDice(s, 2, { A: 3, B: 3 });
|
||||
putDice(s, 3, { A: 2, B: 2 });
|
||||
putDice(s, 4, { B: 1 });
|
||||
putDice(s, 6, { house: 1, A: 1 });
|
||||
s.hand.A = { own: 0, neutral: 0 };
|
||||
s.hand.B = { own: 1, neutral: 0 };
|
||||
s.current = 1;
|
||||
s = rolled(s, [4]);
|
||||
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 4 });
|
||||
const rr = state.roundResults[0]!;
|
||||
expect(rr.round).toBe(1);
|
||||
expect(rr.payouts.map((p) => [p.casino, p.removedTies, p.awards.map((a) => `${a.owner}:${a.note}`), p.returned])).toEqual([
|
||||
[1, [], ['house:9'], [9]],
|
||||
[2, ['A', 'B'], [], [8, 3, 1]],
|
||||
[3, ['A', 'B'], [], [4, 4]],
|
||||
[4, [], ['B:7'], [2]],
|
||||
[5, [], [], [5]],
|
||||
[6, ['A', 'house'], [], [6, 1]],
|
||||
]);
|
||||
expect(state.money).toEqual({ A: [], B: [7] });
|
||||
// Returned bills go under the old pile (then casinos are refilled from the top for round 2).
|
||||
const expectedPile = [...pileBefore, 9, 8, 3, 1, 4, 4, 2, 5, 6, 1];
|
||||
const dealtRound2 = state.casinos.flatMap((c) => c.notes).length;
|
||||
expect(state.pile).toEqual(expectedPile.slice(dealtRound2));
|
||||
expect(events.filter((e) => e.type === 'tiesRemoved').map((e) => e.casino)).toEqual([2, 3, 6]);
|
||||
expect(events.find((e) => e.type === 'notesReturned')).toMatchObject({ notes: [9, 8, 3, 1, 4, 4, 2, 5, 6, 1] });
|
||||
// hideMoney (default): the paid event hides B's amount, the house's is public.
|
||||
expect(events.filter((e) => e.type === 'paid')).toEqual([
|
||||
{ type: 'paid', casino: 1, owner: HOUSE, note: 9 },
|
||||
{ type: 'paid', casino: 4, owner: 'B' },
|
||||
]);
|
||||
expect(allBills(state)).toEqual(SORTED_NOTES);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- rounds
|
||||
|
||||
describe('rounds (§4.5, §5, §8.2, §10)', () => {
|
||||
/** Plays the round out: everyone puts all dice on `value` (cycling 1..6 per player). */
|
||||
function playRound(s: LasVegasState): LasVegasState {
|
||||
const round = s.round;
|
||||
let guard = 0;
|
||||
while (s.round === round && s.phase !== 'finished' && guard++ < 200) {
|
||||
const p = cur(s);
|
||||
const h = s.hand[p]!;
|
||||
const v = 1 + (s.seats.indexOf(p) % 6);
|
||||
s = rolled(s, Array(h.own).fill(v), Array(h.neutral).fill(v));
|
||||
s = act(s, p, { type: 'PLACE', value: v });
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
test('the first player moves one seat left each round; the game ends after `rounds` (4 by default)', () => {
|
||||
let s = make({}, ['A', 'B', 'C'], 3);
|
||||
const firsts: number[] = [s.startSeat];
|
||||
for (let r = 1; r <= 4; r++) {
|
||||
expect(s.round).toBe(r);
|
||||
expect(cur(s)).toBe(s.seats[s.startSeat]!);
|
||||
s = playRound(s);
|
||||
if (r < 4) firsts.push(s.startSeat);
|
||||
}
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(s.roundResults.length).toBe(4);
|
||||
expect(firsts).toEqual([0, 1, 2, 3].map((i) => (firsts[0]! + i) % 3));
|
||||
expect(lv.result(s)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('rounds = 3 ends after three rounds; dice come back each round', () => {
|
||||
let s = make({ rounds: 3 }, ['A', 'B'], 2);
|
||||
s = playRound(s);
|
||||
expect(s.hand).toEqual({ A: { own: 8, neutral: 4 }, B: { own: 8, neutral: 4 } });
|
||||
expect(s.casinos.every((c) => Object.keys(c.dice).length === 0 && sum(c.notes) >= 5)).toBe(true);
|
||||
s = playRound(playRound(s));
|
||||
expect(s.phase).toBe('finished');
|
||||
});
|
||||
|
||||
test('3 players + neutral on: at every round start the first player rolls the 2 spare house dice onto casinos', () => {
|
||||
let s = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
|
||||
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
|
||||
const house = () => s.casinos.reduce((n, c) => n + (c.dice.house ?? 0), 0);
|
||||
expect(house()).toBe(2);
|
||||
expect(s.houseRoll).toMatchObject({ round: 1, player: s.seats[s.startSeat] });
|
||||
for (const v of s.houseRoll!.values) expect(s.casinos[v - 1]!.dice.house).toBeGreaterThanOrEqual(1);
|
||||
const r = rng();
|
||||
const t = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
|
||||
t.hand = { A: { own: 0, neutral: 0 }, B: { own: 0, neutral: 0 }, C: { own: 1, neutral: 0 } };
|
||||
t.current = 2;
|
||||
t.phase = 'place';
|
||||
t.lastRoll = { own: [1], neutral: [] };
|
||||
const { state, events } = lv.apply(t, 'C', { type: 'PLACE', value: 1 }, { rng: r, now: 5 });
|
||||
expect(events.find((e) => e.type === 'houseDicePlaced')).toMatchObject({ player: state.seats[state.startSeat] });
|
||||
s = state;
|
||||
expect(s.round).toBe(2);
|
||||
expect(house()).toBe(2);
|
||||
expect(s.houseRoll!.round).toBe(2);
|
||||
// 4 players + on: no spare dice.
|
||||
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).houseRoll).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- result
|
||||
|
||||
describe('final ranking (§6, §8.6, §10)', () => {
|
||||
function finished(money: Record<string, number[]>): LasVegasState {
|
||||
const s = make({}, Object.keys(money), 1);
|
||||
s.money = money;
|
||||
s.phase = 'finished';
|
||||
return s;
|
||||
}
|
||||
test('higher total wins; equal totals → more bills wins; equal both → joint rank', () => {
|
||||
let r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [5] }))!;
|
||||
expect(r.ranking).toEqual([['B'], ['A'], ['C']]);
|
||||
r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [9, 4, 5] }))!;
|
||||
expect(r.ranking).toEqual([['B', 'C'], ['A']]);
|
||||
expect(r.summary).toBe('공동 1위 2명, 18점 (지폐 3장)');
|
||||
r = lv.result(finished({ A: [9, 8], B: [6, 6] }))!;
|
||||
expect(r.summary).toBe('1위 17점 (지폐 2장) · 2위 12점');
|
||||
expect(r.scores).toEqual({ A: 17, B: 12 });
|
||||
expect(r.reason).toBe('normal');
|
||||
const v = lv.view(finished({ A: [9, 8], B: [6, 6] }), null);
|
||||
expect(v.result!.ranking.map((x) => [x.rank, x.player, x.total, x.notes])).toEqual([
|
||||
[1, 'A', 17, [9, 8]],
|
||||
[2, 'B', 12, [6, 6]],
|
||||
]);
|
||||
// Everything is revealed at the end.
|
||||
expect(v.money.B).toEqual({ count: 2, total: 12, notes: [6, 6] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- timeout / away / leave
|
||||
|
||||
describe('timeout, away and leaving (§8.5, §10)', () => {
|
||||
test('timeout rolls in the roll phase, then places the number with the most dice', () => {
|
||||
let s = makeFirst({ turnSeconds: 20 }, ['A', 'B']);
|
||||
expect(s.deadlineAt).toBe(clock + 20_000);
|
||||
expect(lv.onTimeout(s, 'A')).toEqual({ type: 'TIMEOUT' });
|
||||
s = act(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(s.phase).toBe('place');
|
||||
expect(s.deadlineAt).toBe(clock + 20_000);
|
||||
s.lastRoll = { own: [2, 2, 5, 5, 5, 1, 1, 6], neutral: [2, 3, 4, 6] };
|
||||
// 2 and 5 both have 3 dice (one of the 2s is neutral) → the better top bill decides.
|
||||
setBills(s, { 2: [3, 2], 5: [9] });
|
||||
expect(autoPlaceValue(s, s.lastRoll)).toBe(5);
|
||||
expect(lv.view(s, 'B').autoValue).toBe(5);
|
||||
setBills(s, { 2: [9], 5: [9], 6: [9] });
|
||||
expect(autoPlaceValue(s, s.lastRoll)).toBe(2); // same count and bill → smaller number
|
||||
s.lastRoll = { own: [4, 4, 4, 4, 1, 2, 3, 6], neutral: [6, 6, 1, 1] };
|
||||
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(state.casinos[3]!.dice.A).toBe(4);
|
||||
expect(events.find((e) => e.type === 'placed')).toMatchObject({ value: 4, auto: true });
|
||||
expect(state.lastPlace!.auto).toBe(true);
|
||||
});
|
||||
|
||||
test('turnSeconds null → no deadline; 3 timeouts in a row → away (5 s deadline); acting again clears it', () => {
|
||||
let s = makeFirst({ turnSeconds: null }, ['A', 'B']);
|
||||
expect(lv.deadline!(s)).toBeNull();
|
||||
for (let i = 0; i < AWAY_AFTER; i++) s = act(s, cur(s), { type: 'TIMEOUT' });
|
||||
// A: roll, place (B's turn), B: roll — B 1 timeout, A 2.
|
||||
expect(lv.view(s, null).away).toEqual([]);
|
||||
s = act(s, 'B', { type: 'TIMEOUT' }); // B place → A
|
||||
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); // A's 3rd
|
||||
expect(events.some((e) => e.type === 'away' && e.player === 'A')).toBe(true);
|
||||
expect(lv.view(state, null).away).toEqual(['A']);
|
||||
expect(state.deadlineAt).toBe(clock + AWAY_MS);
|
||||
s = act(state, 'A', { type: 'PLACE', value: state.lastRoll!.own[0] ?? state.lastRoll!.neutral[0]! });
|
||||
expect(lv.view(s, null).away).toEqual([]);
|
||||
});
|
||||
|
||||
test('a disconnected player (only timeouts) does not block the game: rounds finish', () => {
|
||||
let s = make({}, ['A', 'B', 'C'], 7);
|
||||
const r = SeededRng.fromSeed(seedN(70));
|
||||
let steps = 0;
|
||||
while (s.phase !== 'finished' && steps++ < 2000) {
|
||||
const p = cur(s);
|
||||
const a: LasVegasAction = p === 'B' ? lv.onTimeout(s, p) : lv.legalActions!(s, p)[0]!;
|
||||
expect(lv.validate(s, p, a)).toEqual({ ok: true });
|
||||
s = lv.apply(s, p, a, { rng: r, now: (clock += 1000) }).state;
|
||||
}
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(s.roundResults.length).toBe(4);
|
||||
});
|
||||
|
||||
test('concede: dice discarded (also those on the board), skipped afterwards, bills kept, ranked last; last one standing ends the game', () => {
|
||||
let s = makeFirst({}, ['A', 'B', 'C']);
|
||||
s.money.B = [9, 9, 9];
|
||||
for (let i = 0; i < 3; i++) s.pile.splice(s.pile.indexOf(9), 1);
|
||||
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
|
||||
s = act(s, 'A', { type: 'PLACE', value: 2 });
|
||||
s = rolled(s, [1, 1, 1, 1, 1, 1, 1, 6]);
|
||||
s = act(s, 'B', { type: 'PLACE', value: 1 });
|
||||
expect(lv.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
|
||||
s = act(s, 'B', { type: 'CONCEDE' }); // not B's turn
|
||||
expect(s.casinos[0]!.dice.B).toBeUndefined();
|
||||
expect(s.hand.B).toEqual({ own: 0, neutral: 0 });
|
||||
expect(reject(s, 'B', { type: 'ROLL' })).toBe('이미 기권했어요.');
|
||||
expect(lv.onLeave!(s, 'B')).toBeNull();
|
||||
expect(cur(s)).toBe('C');
|
||||
s = rolled(s, [6, 6, 6, 6, 6, 6, 6, 6]);
|
||||
s = act(s, 'C', { type: 'PLACE', value: 6 });
|
||||
expect(cur(s)).toBe('A'); // B skipped
|
||||
const { state, events } = actE(s, 'C', { type: 'CONCEDE' });
|
||||
expect(events.some((e) => e.type === 'gameOver')).toBe(true);
|
||||
const r = lv.result(state)!;
|
||||
expect(r.ranking).toEqual([['A'], ['B'], ['C']]); // conceded players last (B has more bills than C)
|
||||
expect(r.reason).toBe('resign');
|
||||
expect(r.summary.startsWith('다른 사람이 모두 기권했어요.')).toBe(true);
|
||||
expect(allBills(state)).toEqual([...SORTED_NOTES]);
|
||||
});
|
||||
|
||||
test('the current player conceding passes the turn on', () => {
|
||||
let s = makeFirst({}, ['A', 'B', 'C']);
|
||||
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
|
||||
s = act(s, 'A', { type: 'CONCEDE' });
|
||||
expect(cur(s)).toBe('B');
|
||||
expect(s.phase).toBe('roll');
|
||||
expect(s.lastRoll).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- view / hidden info
|
||||
|
||||
describe('view and hidden info (§8.3, §10)', () => {
|
||||
function withMoney(opts: Partial<LasVegasOptions>) {
|
||||
const s = makeFirst(opts, ['A', 'B', 'C']);
|
||||
s.money = { A: [9, 2], B: [7], C: [] };
|
||||
s.roundResults = [
|
||||
{
|
||||
round: 1,
|
||||
payouts: [
|
||||
{
|
||||
casino: 3,
|
||||
removedTies: ['C'],
|
||||
awards: [
|
||||
{ owner: 'A', note: 9 },
|
||||
{ owner: 'B', note: 7 },
|
||||
{ owner: HOUSE, note: 5 },
|
||||
{ owner: 'A', note: 2 },
|
||||
],
|
||||
returned: [5],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// Keep 54 bills: take them out of the pile.
|
||||
for (const n of [9, 2, 7]) s.pile.splice(s.pile.indexOf(n), 1);
|
||||
return s;
|
||||
}
|
||||
|
||||
test('hideMoney: other players and spectators see bill counts only; own amounts and house/returned bills are public', () => {
|
||||
const s = withMoney({});
|
||||
const vb = lv.view(s, 'B');
|
||||
expect(vb.money.A).toEqual({ count: 2, total: null, notes: null });
|
||||
expect(vb.money.B).toEqual({ count: 1, total: 7, notes: [7] });
|
||||
const aw = vb.roundResults[0]!.payouts[0]!.awards;
|
||||
expect(aw.map((a) => [a.owner, a.note])).toEqual([
|
||||
['A', null],
|
||||
['B', 7],
|
||||
[HOUSE, 5],
|
||||
['A', null],
|
||||
]);
|
||||
const spec = lv.view(s, null);
|
||||
expect(Object.values(spec.money).every((m) => m.total === null && m.notes === null)).toBe(true);
|
||||
expect(spec.roundResults[0]!.payouts[0]!.awards.filter((a) => a.owner !== HOUSE).every((a) => a.note === null && a.key === null)).toBe(true);
|
||||
for (const viewer of ['A', 'B', 'C', null]) {
|
||||
const json = JSON.stringify(lv.view(s, viewer));
|
||||
expect(json).not.toContain('"pile"');
|
||||
expect(json).not.toContain('"rng"');
|
||||
expect(json).not.toContain('"timeouts"');
|
||||
for (const other of ['A', 'B', 'C']) {
|
||||
if (other === viewer) continue;
|
||||
for (const secret of lv.secretsOf!(s, other)) expect(json).not.toContain(secret);
|
||||
}
|
||||
}
|
||||
expect(lv.view(s, 'A').pileCount).toBe(s.pile.length);
|
||||
expect(lv.secretsOf!(s, 'A').length).toBe(2);
|
||||
});
|
||||
|
||||
test('hideMoney = false: amounts are public', () => {
|
||||
const s = withMoney({ hideMoney: false });
|
||||
expect(lv.view(s, null).money.A).toEqual({ count: 2, total: 11, notes: [9, 2] });
|
||||
expect(lv.secretsOf!(s, 'A')).toEqual([]);
|
||||
});
|
||||
|
||||
test('choices preview for the player to move: 1등 / 동점 주의 / lower bill / nothing', () => {
|
||||
let s = makeFirst({ neutralDice: 'off' }, ['A', 'B']);
|
||||
setBills(s, { 1: [9, 4], 2: [8], 3: [6], 4: [5] });
|
||||
putDice(s, 1, { B: 3 });
|
||||
putDice(s, 2, { B: 2 });
|
||||
putDice(s, 3, { B: 1 });
|
||||
putDice(s, 4, { B: 2 });
|
||||
s = rolled(s, [1, 2, 2, 3, 3, 3, 4, 5]);
|
||||
const v: LasVegasView = lv.view(s, 'B');
|
||||
const by = Object.fromEntries(v.choices.map((c) => [c.value, [c.standing, c.note, c.countAfter]]));
|
||||
expect(by).toEqual({ 1: ['award', 4, 1], 2: ['tie', null, 2], 3: ['first', 6, 3], 4: ['none', null, 1], 5: ['none', null, 1] });
|
||||
});
|
||||
|
||||
test('no RNG/pile in the view; same seed + same actions → same result (replay)', () => {
|
||||
const run = () => {
|
||||
let s = make({}, ['A', 'B', 'C'], 11);
|
||||
const r = SeededRng.fromSeed(seedN(12));
|
||||
let t = 0;
|
||||
while (s.phase !== 'finished') {
|
||||
const p = cur(s);
|
||||
const legal = lv.legalActions!(s, p);
|
||||
s = lv.apply(s, p, legal[t++ % legal.length]!, { rng: r, now: t * 1000 }).state;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
|
||||
expect(run().money).toEqual(run().money);
|
||||
});
|
||||
|
||||
test('schema: defaults, Korean metadata for every option, client cannot send TIMEOUT', () => {
|
||||
expect(lv.defaultOptions).toEqual({ rounds: 4, neutralDice: 'auto', hideMoney: true, turnSeconds: 30 });
|
||||
const shape = lasVegasOptionsSchema.shape;
|
||||
for (const k of Object.keys(shape) as (keyof typeof shape)[]) {
|
||||
const meta = shape[k].meta() as { title?: string };
|
||||
expect(meta?.title).toMatch(/[가-힣]/);
|
||||
}
|
||||
expect(lasVegasActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
|
||||
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 7 }).success).toBe(false);
|
||||
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 3 }).success).toBe(true);
|
||||
expect(lasVegasOptionsSchema.safeParse({ turnSeconds: null }).success).toBe(true);
|
||||
expect(lasVegasOptionsSchema.safeParse({ rounds: 6 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
describe('random playouts (docs/09 §7)', () => {
|
||||
const OPTS: Partial<LasVegasOptions>[] = [
|
||||
{},
|
||||
{ neutralDice: 'on' },
|
||||
{ neutralDice: 'off', hideMoney: false },
|
||||
{ rounds: 3, turnSeconds: null },
|
||||
{ rounds: 5, turnSeconds: 20 },
|
||||
];
|
||||
|
||||
function invariant(s: LasVegasState, total: { neutral: number }): void {
|
||||
// 54 bills always: pile + casinos + money.
|
||||
const bills = allBills(s);
|
||||
if (bills.length !== 54 || bills.join() !== SORTED_NOTES.join()) throw new Error('bills not conserved');
|
||||
if (s.phase === 'finished') return;
|
||||
// Dice: active players hold 8 own dice between hand and casinos; house dice never exceed the neutral pool.
|
||||
let house = 0;
|
||||
for (const c of s.casinos) {
|
||||
house += c.dice.house ?? 0;
|
||||
for (const [o, n] of Object.entries(c.dice)) if (o !== HOUSE && (n! <= 0 || s.conceded.includes(o))) throw new Error('bad dice count');
|
||||
if (c.notes.some((n, i) => i > 0 && n > c.notes[i - 1]!)) throw new Error('casino bills not descending');
|
||||
}
|
||||
for (const p of s.seats) {
|
||||
const placed = s.casinos.reduce((n, c) => n + (c.dice[p] ?? 0), 0);
|
||||
if (!s.conceded.includes(p) && placed + s.hand[p]!.own !== OWN_DICE) throw new Error(`own dice of ${p} not conserved`);
|
||||
}
|
||||
const neutralHands = s.seats.reduce((n, p) => n + s.hand[p]!.neutral, 0);
|
||||
if (s.conceded.length === 0 && house + neutralHands !== total.neutral) throw new Error('neutral dice not conserved');
|
||||
if (house + neutralHands > total.neutral) throw new Error('too many neutral dice');
|
||||
// The player to act always has dice.
|
||||
if (s.hand[cur(s)]!.own + s.hand[cur(s)]!.neutral === 0) throw new Error('current player has no dice');
|
||||
}
|
||||
|
||||
function leakCheck(s: LasVegasState): void {
|
||||
for (const viewer of [...s.seats, null]) {
|
||||
const v = lv.view(s, viewer);
|
||||
const json = JSON.stringify(v);
|
||||
if (json.includes('"pile"') || json.includes('"rng"')) throw new Error('pile/rng in view');
|
||||
if (s.phase === 'finished' || !s.options.hideMoney) continue;
|
||||
for (const p of s.seats) {
|
||||
if (p === viewer) continue;
|
||||
if (v.money[p]!.total !== null || v.money[p]!.notes !== null) throw new Error(`money of ${p} leaked to ${viewer}`);
|
||||
for (const rr of v.roundResults)
|
||||
for (const po of rr.payouts) for (const a of po.awards) if (a.owner === p && a.note !== null) throw new Error('award leaked');
|
||||
for (const secret of lv.secretsOf!(s, p)) if (json.includes(secret)) throw new Error('secret leaked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('1,000 seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic', () => {
|
||||
let finished = 0;
|
||||
const play = (i: number, check: boolean): string => {
|
||||
const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0);
|
||||
const players = Array.from({ length: Math.min(5, n) }, (_, k) => `p${k}`);
|
||||
let opts = OPTS[i % OPTS.length]!;
|
||||
if (players.length === 5 && opts.neutralDice === 'on') opts = { ...opts, neutralDice: 'auto' };
|
||||
const options = lasVegasOptionsSchema.parse(opts);
|
||||
const rngG = SeededRng.fromSeed(seedN(i));
|
||||
const chooser = SeededRng.fromSeed(seedN(i + 100_000));
|
||||
let s = lv.setup({ players, options, rng: rngG, now: 0 });
|
||||
const total = { neutral: s.neutralPer * players.length + s.houseExtra };
|
||||
let now = 0;
|
||||
for (let step = 0; step < 2000; step++) {
|
||||
if (check) {
|
||||
invariant(s, total);
|
||||
if (step % 5 === 0) leakCheck(s);
|
||||
}
|
||||
if (lv.result(s)) {
|
||||
if (check) leakCheck(s);
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
const p = cur(s);
|
||||
if (check) {
|
||||
const t = lv.onTimeout(s, p);
|
||||
const v = lv.validate(s, p, t);
|
||||
if (!v.ok) throw new Error(`timeout rejected: ${v.reason}`);
|
||||
}
|
||||
const roll = chooser.int(100);
|
||||
let actor = p;
|
||||
let a: LasVegasAction;
|
||||
if (roll < 1) {
|
||||
// A random seated player concedes now and then.
|
||||
const alive = s.seats.filter((q) => !s.conceded.includes(q));
|
||||
actor = alive[chooser.int(alive.length)]!;
|
||||
a = { type: 'CONCEDE' };
|
||||
} else if (roll < 10) a = { type: 'TIMEOUT' };
|
||||
else {
|
||||
const legal = lv.legalActions!(s, p);
|
||||
a = legal[chooser.int(legal.length)]!;
|
||||
}
|
||||
const v = lv.validate(s, actor, a);
|
||||
if (!v.ok) throw new Error(`generated action rejected: ${v.reason}`);
|
||||
s = lv.apply(s, actor, a, { rng: rngG, now: (now += 1000) }).state;
|
||||
}
|
||||
throw new Error(`seed ${i} did not finish`);
|
||||
};
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const a = play(i, true);
|
||||
finished++;
|
||||
if (i % 50 === 0) expect(play(i, false)).toBe(a);
|
||||
}
|
||||
expect(finished).toBe(1000);
|
||||
}, 60_000);
|
||||
|
||||
test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => {
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4));
|
||||
const opts = lasVegasOptionsSchema.parse(OPTS[i % OPTS.length]!.neutralDice === 'on' && players.length === 5 ? {} : OPTS[i % OPTS.length]!);
|
||||
const r = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
|
||||
expect(r.finished).toBe(true);
|
||||
const st = r.finalState as LasVegasState;
|
||||
expect(st.roundResults.length).toBe(opts.rounds);
|
||||
expect(allBills(st)).toEqual(SORTED_NOTES);
|
||||
if (i % 20 === 0) {
|
||||
const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
|
||||
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
199
packages/games/src/las-vegas/rules.ts
Normal file
199
packages/games/src/las-vegas/rules.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 라스베가스 규칙 계산 (docs/games/las-vegas.md §3–§5, §8). Pure helpers shared by the definition,
|
||||
* the view and the tests. State-mutating helpers work on a state `apply` has already cloned.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
// ---------------------------------------------------------------- options & actions
|
||||
|
||||
const lit = <T extends number | null>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
export const lasVegasOptionsSchema = z.object({
|
||||
rounds: lit(3, 4, 5)
|
||||
.default(4)
|
||||
.meta({ title: '라운드 수', labels: { 3: '3라운드(짧게)', 4: '4라운드(기본)', 5: '5라운드' } }),
|
||||
neutralDice: z
|
||||
.enum(['auto', 'on', 'off'])
|
||||
.default('auto')
|
||||
.meta({
|
||||
title: '중립(흰) 주사위',
|
||||
labels: { auto: '자동(2명일 때만, 1인당 4개)', on: '켜기(2명 4개, 3~4명 2개씩)', off: '끄기' },
|
||||
}),
|
||||
hideMoney: z.boolean().default(true).meta({ title: '다른 사람이 받은 지폐 금액 숨기기(장 수만 공개)' }),
|
||||
turnSeconds: lit(20, 30, 60, null)
|
||||
.default(30)
|
||||
.meta({ title: '굴리기·놓기 제한 시간', labels: { 20: '20초', 30: '30초', 60: '60초', null: '무제한' } }),
|
||||
});
|
||||
export type LasVegasOptions = z.infer<typeof lasVegasOptionsSchema>;
|
||||
|
||||
export const lasVegasActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('ROLL') }),
|
||||
z.object({ type: z.literal('PLACE'), value: z.number().int().min(1).max(6) }),
|
||||
z.object({ type: z.literal('CONCEDE') }),
|
||||
]);
|
||||
export type LasVegasClientAction = z.infer<typeof lasVegasActionSchema>;
|
||||
/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */
|
||||
export type LasVegasAction = LasVegasClientAction | { type: 'TIMEOUT' };
|
||||
|
||||
// ---------------------------------------------------------------- components
|
||||
|
||||
export type Note = number;
|
||||
export const HOUSE = 'house' as const;
|
||||
export type Owner = PlayerId | typeof HOUSE;
|
||||
export type CasinoValue = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** Bill denominations → count (§3): 54 bills, 250 in total. */
|
||||
export const NOTE_COUNTS: Record<Note, number> = { 1: 6, 2: 8, 3: 8, 4: 6, 5: 6, 6: 5, 7: 5, 8: 5, 9: 5 };
|
||||
export const ALL_NOTES: Note[] = Object.entries(NOTE_COUNTS).flatMap(([v, n]) => Array<Note>(n).fill(Number(v)));
|
||||
export const OWN_DICE = 8;
|
||||
export const MIN_CASINO_SUM = 5;
|
||||
export const CASINO_VALUES: CasinoValue[] = [1, 2, 3, 4, 5, 6];
|
||||
/** Consecutive automatic actions before a player is shown as "자리 비움" (§8.5). */
|
||||
export const AWAY_AFTER = 3;
|
||||
/** Deadline for away players (keeps the game moving). */
|
||||
export const AWAY_MS = 5000;
|
||||
|
||||
export interface Casino {
|
||||
value: CasinoValue;
|
||||
/** Descending. */
|
||||
notes: Note[];
|
||||
dice: Partial<Record<Owner, number>>;
|
||||
}
|
||||
|
||||
export interface Payout {
|
||||
casino: number;
|
||||
removedTies: Owner[];
|
||||
awards: { owner: Owner; note: Note }[];
|
||||
returned: Note[];
|
||||
}
|
||||
|
||||
export interface RoundResult {
|
||||
round: number;
|
||||
payouts: Payout[];
|
||||
}
|
||||
|
||||
export interface LasVegasState {
|
||||
options: LasVegasOptions;
|
||||
seats: PlayerId[];
|
||||
round: number;
|
||||
/** This round's first player (seat index). */
|
||||
startSeat: number;
|
||||
/** Seat index of the player to act. */
|
||||
current: number;
|
||||
phase: 'roll' | 'place' | 'finished';
|
||||
/** Hidden draw pile, index 0 = top. */
|
||||
pile: Note[];
|
||||
casinos: Casino[];
|
||||
/** Dice still in hand. */
|
||||
hand: Record<PlayerId, { own: number; neutral: number }>;
|
||||
lastRoll: { own: number[]; neutral: number[] } | null;
|
||||
money: Record<PlayerId, Note[]>;
|
||||
roundResults: RoundResult[];
|
||||
/** Neutral dice each player gets per round (0 = variant off). */
|
||||
neutralPer: number;
|
||||
/** Neutral dice the round's first player rolls straight onto the casinos (3 players + variant). */
|
||||
houseExtra: number;
|
||||
/** Last round-start house roll (3 players + variant). */
|
||||
houseRoll: { round: number; player: PlayerId; values: number[] } | null;
|
||||
lastPlace: { player: PlayerId; value: number; own: number; neutral: number; auto: boolean } | null;
|
||||
conceded: PlayerId[];
|
||||
timeouts: Record<PlayerId, number>;
|
||||
rollSeq: number;
|
||||
turnSeq: number;
|
||||
deadlineAt: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup helpers
|
||||
|
||||
/** Neutral dice per player for a player count; null = not allowed (5 players + `on`). */
|
||||
export function neutralPerFor(option: LasVegasOptions['neutralDice'], players: number): number | null {
|
||||
if (option === 'off') return 0;
|
||||
if (option === 'auto') return players === 2 ? 4 : 0;
|
||||
if (players === 2) return 4;
|
||||
if (players <= 4) return 2;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Leftover neutral dice the first player rolls each round (only 3 players: 8 − 3×2 = 2). */
|
||||
export const houseExtraFor = (neutralPer: number, players: number): number => (neutralPer > 0 && players === 3 ? 2 : 0);
|
||||
|
||||
/** Deals bills from the top of the pile until each casino holds ≥ 5 (§4.2). Casinos are emptied first. */
|
||||
export function refillCasinos(s: Pick<LasVegasState, 'pile' | 'casinos'>): void {
|
||||
for (const c of s.casinos) {
|
||||
c.notes = [];
|
||||
c.dice = {};
|
||||
let sum = 0;
|
||||
while (sum < MIN_CASINO_SUM && s.pile.length > 0) {
|
||||
const n = s.pile.shift()!;
|
||||
c.notes.push(n);
|
||||
sum += n;
|
||||
}
|
||||
c.notes.sort((a, b) => b - a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settlement
|
||||
|
||||
/**
|
||||
* One casino's payout (§5 정산): owners with equal counts are all removed (the house included), the rest
|
||||
* take bills in descending order. Bills the house wins and bills nobody wins are returned.
|
||||
* `order` fixes the listing order of tied owners (seat order, house last).
|
||||
*/
|
||||
export function settleCasino(notes: Note[], dice: Partial<Record<Owner, number>>, order: Owner[]): Payout & { ranked: Owner[] } {
|
||||
const owners = order.filter((o) => (dice[o] ?? 0) > 0);
|
||||
const byCount = new Map<number, Owner[]>();
|
||||
for (const o of owners) byCount.set(dice[o]!, [...(byCount.get(dice[o]!) ?? []), o]);
|
||||
const removedTies = owners.filter((o) => byCount.get(dice[o]!)!.length > 1);
|
||||
const ranked = owners.filter((o) => !removedTies.includes(o)).sort((a, b) => dice[b]! - dice[a]!);
|
||||
const sorted = [...notes].sort((a, b) => b - a);
|
||||
const awards: Payout['awards'] = [];
|
||||
ranked.forEach((o, i) => {
|
||||
if (i < sorted.length) awards.push({ owner: o, note: sorted[i]! });
|
||||
});
|
||||
const returned = [...awards.filter((a) => a.owner === HOUSE).map((a) => a.note), ...sorted.slice(ranked.length)].sort((a, b) => b - a);
|
||||
return { casino: 0, removedTies, awards, returned, ranked };
|
||||
}
|
||||
|
||||
export const ownerOrder = (s: Pick<LasVegasState, 'seats'>): Owner[] => [...s.seats, HOUSE];
|
||||
|
||||
/** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */
|
||||
export function placementPreview(
|
||||
s: Pick<LasVegasState, 'seats' | 'casinos'>,
|
||||
player: PlayerId,
|
||||
value: number,
|
||||
own: number,
|
||||
neutral: number,
|
||||
): { standing: 'first' | 'award' | 'tie' | 'none'; note: Note | null; count: number } {
|
||||
const c = s.casinos[value - 1]!;
|
||||
const dice = { ...c.dice };
|
||||
if (own > 0) dice[player] = (dice[player] ?? 0) + own;
|
||||
if (neutral > 0) dice[HOUSE] = (dice[HOUSE] ?? 0) + neutral;
|
||||
const count = dice[player] ?? 0;
|
||||
if (count === 0) return { standing: 'none', note: null, count };
|
||||
const out = settleCasino(c.notes, dice, ownerOrder(s));
|
||||
if (out.removedTies.includes(player)) return { standing: 'tie', note: null, count };
|
||||
const idx = out.ranked.indexOf(player);
|
||||
const note = out.awards.find((a) => a.owner === player)?.note ?? null;
|
||||
return { standing: note === null ? 'none' : idx === 0 ? 'first' : 'award', note, count };
|
||||
}
|
||||
|
||||
/** Deterministic timeout choice (§8.5): most dice, then the casino's best bill, then the smaller number. */
|
||||
export function autoPlaceValue(s: Pick<LasVegasState, 'casinos'>, roll: { own: number[]; neutral: number[] }): number {
|
||||
let best = 0;
|
||||
let bestKey: [number, number] = [-1, -1];
|
||||
for (const v of CASINO_VALUES) {
|
||||
const n = roll.own.filter((d) => d === v).length + roll.neutral.filter((d) => d === v).length;
|
||||
if (n === 0) continue;
|
||||
const key: [number, number] = [n, s.casinos[v - 1]!.notes[0] ?? 0];
|
||||
if (key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
|
||||
best = v;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export const sum = (notes: Note[]): number => notes.reduce((a, b) => a + b, 0);
|
||||
export const diceLeft = (s: Pick<LasVegasState, 'hand'>, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0);
|
||||
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;
|
||||
}
|
||||
222
packages/games/src/quoridor/board.ts
Normal file
222
packages/games/src/quoridor/board.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 벽 쌓기 길찾기 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로.
|
||||
* 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점.
|
||||
*/
|
||||
export type Seat = 'S' | 'W' | 'N' | 'E';
|
||||
export interface Cell {
|
||||
c: number;
|
||||
r: number;
|
||||
}
|
||||
export type Orientation = 'h' | 'v';
|
||||
export interface Wall {
|
||||
c: number;
|
||||
r: number;
|
||||
o: Orientation;
|
||||
}
|
||||
/** 8×8 앵커별 점유, 인덱스 r*8+c. */
|
||||
export interface WallIndex {
|
||||
h: boolean[];
|
||||
v: boolean[];
|
||||
}
|
||||
export type JumpTrigger = 'wallEdgePawn' | 'wallOnly';
|
||||
|
||||
export const SIZE = 9;
|
||||
export const ANCHORS = 8;
|
||||
const CELLS = SIZE * SIZE;
|
||||
|
||||
export const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
|
||||
export const START: Record<Seat, Cell> = { S: { c: 4, r: 0 }, W: { c: 0, r: 4 }, N: { c: 4, r: 8 }, E: { c: 8, r: 4 } };
|
||||
/** 목표 줄: 남 → 9행, 북 → 1행, 서 → i열, 동 → a열. */
|
||||
export const isGoal = (seat: Seat, c: number, r: number): boolean =>
|
||||
seat === 'S' ? r === SIZE - 1 : seat === 'N' ? r === 0 : seat === 'W' ? c === SIZE - 1 : c === 0;
|
||||
|
||||
export const emptyWallIndex = (): WallIndex => ({ h: new Array<boolean>(64).fill(false), v: new Array<boolean>(64).fill(false) });
|
||||
export const onBoard = (c: number, r: number): boolean => c >= 0 && r >= 0 && c < SIZE && r < SIZE;
|
||||
export const anchorInRange = (c: number, r: number): boolean => Number.isInteger(c) && Number.isInteger(r) && c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS;
|
||||
export const cellIdx = (c: number, r: number): number => r * SIZE + c;
|
||||
export const sameCell = (a: Cell, b: Cell): boolean => a.c === b.c && a.r === b.r;
|
||||
export const cellLabel = (p: Cell): string => `${'abcdefghi'[p.c]}${p.r + 1}`;
|
||||
export const wallLabel = (w: Wall): string => `${w.o === 'h' ? '가로' : '세로'} ${cellLabel(w)}`;
|
||||
|
||||
const hasH = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.h[r * ANCHORS + c]!;
|
||||
const hasV = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.v[r * ANCHORS + c]!;
|
||||
|
||||
/** (c,r)에서 (dc,dr) 방향 이웃으로 가는 길이 벽에 막혔는지. 판 안 여부는 호출하는 쪽이 본다. */
|
||||
export function blocked(w: WallIndex, c: number, r: number, dc: number, dr: number): boolean {
|
||||
if (dr === 1) return hasH(w, c, r) || hasH(w, c - 1, r);
|
||||
if (dr === -1) return hasH(w, c, r - 1) || hasH(w, c - 1, r - 1);
|
||||
if (dc === 1) return hasV(w, c, r) || hasV(w, c, r - 1);
|
||||
return hasV(w, c - 1, r) || hasV(w, c - 1, r - 1);
|
||||
}
|
||||
|
||||
/** 북·남·동·서 순서(결정적). */
|
||||
export const DIRS: readonly [number, number][] = [
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
];
|
||||
|
||||
/** 기존 벽과 겹치는지(§5.3 조건 3): 같은 방향 반 칸 겹침, 같은 앵커 십자 교차. */
|
||||
export function overlaps(w: WallIndex, c: number, r: number, o: Orientation): boolean {
|
||||
if (o === 'h') return hasH(w, c - 1, r) || hasH(w, c, r) || hasH(w, c + 1, r) || hasV(w, c, r);
|
||||
return hasV(w, c, r - 1) || hasV(w, c, r) || hasV(w, c, r + 1) || hasH(w, c, r);
|
||||
}
|
||||
|
||||
export function setWall(w: WallIndex, wall: Wall, on: boolean): void {
|
||||
(wall.o === 'h' ? w.h : w.v)[wall.r * ANCHORS + wall.c] = on;
|
||||
}
|
||||
|
||||
// BFS 작업 공간(호출마다 stamp로 초기화 → 할당 없음). 순수 함수 결과에는 영향 없음.
|
||||
const queue = new Int16Array(CELLS);
|
||||
const seen = new Int32Array(CELLS);
|
||||
const dist = new Int16Array(CELLS);
|
||||
const parent = new Int16Array(CELLS);
|
||||
let stamp = 0;
|
||||
|
||||
/** 말은 무시하고 벽만 보는 최단 거리(칸 수). 길이 없으면 -1. §5.3 경로 검사. */
|
||||
export function pathLength(w: WallIndex, start: Cell, seat: Seat): number {
|
||||
if (isGoal(seat, start.c, start.r)) return 0;
|
||||
stamp++;
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
const s0 = cellIdx(start.c, start.r);
|
||||
queue[tail++] = s0;
|
||||
seen[s0] = stamp;
|
||||
dist[s0] = 0;
|
||||
while (head < tail) {
|
||||
const cur = queue[head++]!;
|
||||
const c = cur % SIZE;
|
||||
const r = (cur - c) / SIZE;
|
||||
for (const [dc, dr] of DIRS) {
|
||||
const nc = c + dc;
|
||||
const nr = r + dr;
|
||||
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
|
||||
const ni = nr * SIZE + nc;
|
||||
if (seen[ni] === stamp) continue;
|
||||
seen[ni] = stamp;
|
||||
dist[ni] = dist[cur]! + 1;
|
||||
if (isGoal(seat, nc, nr)) return dist[ni]!;
|
||||
queue[tail++] = ni;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 출발 칸 다음부터 목표 줄의 칸까지 최단 경로(결정적). 길이 없으면 null. */
|
||||
export function shortestPath(w: WallIndex, start: Cell, seat: Seat): Cell[] | null {
|
||||
if (isGoal(seat, start.c, start.r)) return [];
|
||||
stamp++;
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
const s0 = cellIdx(start.c, start.r);
|
||||
queue[tail++] = s0;
|
||||
seen[s0] = stamp;
|
||||
parent[s0] = -1;
|
||||
while (head < tail) {
|
||||
const cur = queue[head++]!;
|
||||
const c = cur % SIZE;
|
||||
const r = (cur - c) / SIZE;
|
||||
for (const [dc, dr] of DIRS) {
|
||||
const nc = c + dc;
|
||||
const nr = r + dr;
|
||||
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
|
||||
const ni = nr * SIZE + nc;
|
||||
if (seen[ni] === stamp) continue;
|
||||
seen[ni] = stamp;
|
||||
parent[ni] = cur;
|
||||
if (isGoal(seat, nc, nr)) {
|
||||
const out: Cell[] = [];
|
||||
for (let i = ni; i !== s0; i = parent[i]!) out.push({ c: i % SIZE, r: Math.floor(i / SIZE) });
|
||||
return out.reverse();
|
||||
}
|
||||
queue[tail++] = ni;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface Pawn {
|
||||
seat: Seat;
|
||||
pos: Cell;
|
||||
}
|
||||
|
||||
/** §5.2 이동 가능 칸(중복 없음, 결정적 순서). `pawns`는 판에 있는 모든 말(나 포함). */
|
||||
export function legalPawnMoves(w: WallIndex, pawns: readonly Pawn[], me: Cell, trigger: JumpTrigger): Cell[] {
|
||||
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
|
||||
const out: Cell[] = [];
|
||||
const seenOut = new Set<number>();
|
||||
const add = (c: number, r: number) => {
|
||||
const i = cellIdx(c, r);
|
||||
if (!seenOut.has(i)) {
|
||||
seenOut.add(i);
|
||||
out.push({ c, r });
|
||||
}
|
||||
};
|
||||
for (const [dc, dr] of DIRS) {
|
||||
const nc = me.c + dc;
|
||||
const nr = me.r + dr;
|
||||
if (!onBoard(nc, nr) || blocked(w, me.c, me.r, dc, dr)) continue;
|
||||
if (!occ.has(cellIdx(nc, nr))) {
|
||||
add(nc, nr);
|
||||
continue;
|
||||
}
|
||||
// 인접 칸에 말 Q: 곧게 뛰어넘기 → 안 되면 조건에 따라 대각.
|
||||
const jc = nc + dc;
|
||||
const jr = nr + dr;
|
||||
const jOn = onBoard(jc, jr);
|
||||
const wallBehind = jOn && blocked(w, nc, nr, dc, dr);
|
||||
if (jOn && !wallBehind && !occ.has(cellIdx(jc, jr))) {
|
||||
add(jc, jr);
|
||||
continue;
|
||||
}
|
||||
if (trigger === 'wallOnly' && !wallBehind) continue;
|
||||
for (const [pc, pr] of dc === 0 ? [[1, 0], [-1, 0]] : [[0, 1], [0, -1]]) {
|
||||
const tc = nc + pc!;
|
||||
const tr = nr + pr!;
|
||||
if (onBoard(tc, tr) && !blocked(w, nc, nr, pc!, pr!) && !occ.has(cellIdx(tc, tr))) add(tc, tr);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이동할 수 없는 칸의 이유(한국어). */
|
||||
export function moveRefusal(w: WallIndex, pawns: readonly Pawn[], me: Cell, to: Cell, trigger: JumpTrigger): string {
|
||||
if (!onBoard(to.c, to.r)) return '판 밖이에요.';
|
||||
if (sameCell(me, to)) return '지금 있는 칸이에요.';
|
||||
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
|
||||
if (occ.has(cellIdx(to.c, to.r))) return '다른 말이 있는 칸이에요.';
|
||||
const dc = to.c - me.c;
|
||||
const dr = to.r - me.r;
|
||||
const ad = Math.abs(dc) + Math.abs(dr);
|
||||
if (ad === 1) return blocked(w, me.c, me.r, dc, dr) ? '벽이 가로막고 있어요.' : '그 칸으로는 갈 수 없어요.';
|
||||
if (Math.abs(dc) === 1 && Math.abs(dr) === 1) {
|
||||
// 대각: 옆에 말이 있고 곧게 넘을 수 있으면 대각은 안 됨.
|
||||
for (const [sc, sr] of [
|
||||
[dc, 0],
|
||||
[0, dr],
|
||||
] as const) {
|
||||
if (!occ.has(cellIdx(me.c + sc, me.r + sr)) || blocked(w, me.c, me.r, sc, sr)) continue;
|
||||
const jc = me.c + 2 * sc;
|
||||
const jr = me.r + 2 * sr;
|
||||
if (onBoard(jc, jr) && !blocked(w, me.c + sc, me.r + sr, sc, sr) && !occ.has(cellIdx(jc, jr))) {
|
||||
return '곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.';
|
||||
}
|
||||
if (trigger === 'wallOnly' && !(onBoard(jc, jr) && blocked(w, me.c + sc, me.r + sr, sc, sr))) {
|
||||
return '이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.';
|
||||
}
|
||||
const mc = me.c + sc;
|
||||
const mr = me.r + sr;
|
||||
if (blocked(w, mc, mr, to.c - mc, to.r - mr)) return '벽이 가로막고 있어요.';
|
||||
}
|
||||
return '대각선으로는 상대 말을 넘을 때만 갈 수 있어요.';
|
||||
}
|
||||
if (ad === 2 && (dc === 0 || dr === 0)) {
|
||||
const sc = Math.sign(dc);
|
||||
const sr = Math.sign(dr);
|
||||
if (!occ.has(cellIdx(me.c + sc, me.r + sr))) return '한 번에 한 칸씩만 움직일 수 있어요.';
|
||||
return '벽이 가로막고 있어요.';
|
||||
}
|
||||
if (ad === 3 && (dc === 0 || dr === 0)) return '말은 한 번에 하나만 뛰어넘을 수 있어요.';
|
||||
return '그 칸으로는 갈 수 없어요.';
|
||||
}
|
||||
838
packages/games/src/quoridor/index.ts
Normal file
838
packages/games/src/quoridor/index.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */
|
||||
import { z } from 'zod';
|
||||
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||
import {
|
||||
ANCHORS,
|
||||
SEAT_KO,
|
||||
SIZE,
|
||||
START,
|
||||
anchorInRange,
|
||||
emptyWallIndex,
|
||||
isGoal,
|
||||
legalPawnMoves,
|
||||
moveRefusal,
|
||||
overlaps,
|
||||
pathLength,
|
||||
sameCell,
|
||||
setWall,
|
||||
shortestPath,
|
||||
type Cell,
|
||||
type Pawn,
|
||||
type Seat,
|
||||
type Wall,
|
||||
type WallIndex,
|
||||
} from './board';
|
||||
|
||||
export type { Cell, Seat, Wall, Orientation, JumpTrigger } from './board';
|
||||
export { SEAT_KO, cellLabel, wallLabel } from './board';
|
||||
|
||||
const ORDER_2: Seat[] = ['S', 'N'];
|
||||
/** 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동. */
|
||||
const ORDER_4: Seat[] = ['S', 'W', 'N', 'E'];
|
||||
const ALL_SEATS: Seat[] = ['S', 'W', 'N', 'E'];
|
||||
export const WALLS_4P = 5;
|
||||
const COUNT_ERROR = '2명 또는 4명이 필요해요.';
|
||||
|
||||
export const quoridorOptionsSchema = z.object({
|
||||
wallsPerPlayer: z.number().int().min(6).max(10).default(10).meta({ title: '2명일 때 1인당 벽 수(4명은 5개 고정)' }),
|
||||
diagonalJumpTrigger: z
|
||||
.enum(['wallEdgePawn', 'wallOnly'])
|
||||
.default('wallEdgePawn')
|
||||
.meta({
|
||||
title: '대각선으로 넘어갈 수 있는 때',
|
||||
labels: { wallEdgePawn: '넘을 말 뒤가 벽·판 끝·다른 말일 때', wallOnly: '넘을 말 뒤가 벽일 때만' },
|
||||
}),
|
||||
seatOrder: z.enum(['random', 'joinOrder']).default('random').meta({ title: '시작 자리·순서', labels: { random: '무작위', joinOrder: '들어온 순서' } }),
|
||||
continueForRanking: z.boolean().default(false).meta({ title: '4명일 때 1등이 나와도 계속해서 2·3등 가리기' }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z
|
||||
.enum(['none', 'perMove', 'fischer'])
|
||||
.default('perMove')
|
||||
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
|
||||
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
|
||||
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
|
||||
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
|
||||
})
|
||||
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
timeoutPolicy: z
|
||||
.enum(['autoMove', 'lose'])
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
|
||||
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }),
|
||||
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
.meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||
showPath: z.boolean().default(true).meta({ title: '내 최단 길 보여 주기(힌트)' }),
|
||||
});
|
||||
export type QuoridorOptions = z.infer<typeof quoridorOptionsSchema>;
|
||||
|
||||
const coord = z.number().int().min(-20).max(20);
|
||||
export const quoridorActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('move'), to: z.object({ c: coord, r: coord }) }),
|
||||
z.object({ type: z.literal('placeWall'), c: coord, r: coord, o: z.enum(['h', 'v']) }),
|
||||
z.object({ type: z.literal('requestUndo') }),
|
||||
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('offerDraw') }),
|
||||
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('cancelOffer') }),
|
||||
z.object({ type: z.literal('resign') }),
|
||||
]);
|
||||
/** 클라이언트 행동 + 시스템 전용 timeout(스키마에 없음). */
|
||||
export type QuoridorAction = z.infer<typeof quoridorActionSchema> | { type: 'timeout' };
|
||||
|
||||
export type SeatStatus = 'playing' | 'finished' | 'resigned' | 'timedOut';
|
||||
export interface SeatState {
|
||||
seat: Seat;
|
||||
playerId: PlayerId;
|
||||
/** null = 판에서 제거(4인 기권·시간패, 순위 계속 모드의 도착). */
|
||||
pos: Cell | null;
|
||||
wallsLeft: number;
|
||||
status: SeatStatus;
|
||||
finishOrder: number | null;
|
||||
/** 기권·시간패 순서(늦을수록 높은 순위). */
|
||||
outOrder: number | null;
|
||||
}
|
||||
|
||||
export type QuoridorMove =
|
||||
| { kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
|
||||
| { kind: 'wall'; seat: Seat; wall: Wall }
|
||||
| { kind: 'pass'; seat: Seat };
|
||||
|
||||
export type QuoridorReason = 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed' | 'stalemate';
|
||||
|
||||
export interface QuoridorResult {
|
||||
ranking: { playerId: PlayerId; rank: number }[];
|
||||
reason: QuoridorReason;
|
||||
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number | null; wallsLeft: number; status: SeatStatus }[];
|
||||
moveCount: number;
|
||||
summaryKo: string;
|
||||
}
|
||||
|
||||
export interface QuoridorState {
|
||||
options: QuoridorOptions;
|
||||
seats: SeatState[];
|
||||
turnIndex: number;
|
||||
walls: Wall[];
|
||||
wallIndex: WallIndex;
|
||||
moves: QuoridorMove[];
|
||||
phase: 'playing' | 'finished';
|
||||
/** 네 자리 키가 항상 있음(2인이면 W/E는 쓰지 않음). */
|
||||
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
|
||||
/** `plies` = 수락 시 `moves` 끝에서 되돌릴 항목 수. */
|
||||
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[]; plies: number } | null };
|
||||
/** lastOfferMove: 마지막 제안 때의 moves.length(-1 = 없음). */
|
||||
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
|
||||
outCount: number;
|
||||
result: QuoridorResult | null;
|
||||
}
|
||||
|
||||
export interface QuoridorLegalWall extends Wall {
|
||||
/** 이 벽을 놓으면 상대(들) 최단 거리 합이 몇 칸 늘어나는지. */
|
||||
opp: number;
|
||||
/** 내 최단 거리가 몇 칸 늘어나는지. */
|
||||
me: number;
|
||||
}
|
||||
|
||||
export interface QuoridorSeatView {
|
||||
seat: Seat;
|
||||
playerId: PlayerId;
|
||||
pos: Cell | null;
|
||||
wallsLeft: number;
|
||||
status: SeatStatus;
|
||||
finishOrder: number | null;
|
||||
/** 말 무시, 벽만 본 최단 거리(판에 없으면 null). */
|
||||
distance: number | null;
|
||||
autoMoveStreak: number;
|
||||
}
|
||||
|
||||
export interface QuoridorView {
|
||||
size: number;
|
||||
seats: QuoridorSeatView[];
|
||||
mySeat: Seat | null;
|
||||
turn: Seat;
|
||||
phase: QuoridorState['phase'];
|
||||
walls: Wall[];
|
||||
moves: QuoridorMove[];
|
||||
moveCount: number;
|
||||
lastMove: QuoridorMove | null;
|
||||
/** 차례인 플레이어에게만(그 외 null). */
|
||||
legalPawnMoves: Cell[] | null;
|
||||
legalWalls: QuoridorLegalWall[] | null;
|
||||
myShortestPath: Cell[] | null;
|
||||
wallsPerPlayer: number;
|
||||
diagonalJumpTrigger: QuoridorOptions['diagonalJumpTrigger'];
|
||||
continueForRanking: boolean;
|
||||
showPath: boolean;
|
||||
timeControl: QuoridorOptions['timeControl'];
|
||||
timeoutPolicy: QuoridorOptions['timeoutPolicy'];
|
||||
remainingMs: Record<Seat, number>;
|
||||
turnStartedAt: number;
|
||||
undo: { allowed: boolean; remaining: Record<Seat, number | null>; pending: { by: Seat; accepted: Seat[]; plies: number } | null; responders: Seat[] };
|
||||
draw: { allowed: boolean; pendingBy: Seat | null; accepted: Seat[]; responders: Seat[] };
|
||||
result: QuoridorResult | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const undoLimit = (o: QuoridorOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||
const seatIndexOf = (s: QuoridorState, p: PlayerId): number => s.seats.findIndex((x) => x.playerId === p);
|
||||
const bySeat = (s: QuoridorState, seat: Seat): SeatState => s.seats.find((x) => x.seat === seat)!;
|
||||
const playing = (s: QuoridorState): SeatState[] => s.seats.filter((x) => x.status === 'playing');
|
||||
const pawnsOf = (s: QuoridorState): Pawn[] => s.seats.filter((x) => x.pos).map((x) => ({ seat: x.seat, pos: x.pos! }));
|
||||
const recordOf = <T>(v: T): Record<Seat, T> => ({ S: v, W: v, N: v, E: v });
|
||||
const four = (s: QuoridorState) => s.seats.length === 4;
|
||||
export const wallsFor = (o: QuoridorOptions, players: number): number => (players === 4 ? WALLS_4P : o.wallsPerPlayer);
|
||||
|
||||
export function playerCountError(n: number): string | null {
|
||||
return n === 2 || n === 4 ? null : COUNT_ERROR;
|
||||
}
|
||||
|
||||
export function pawnMovesFor(s: QuoridorState, i: number): Cell[] {
|
||||
const x = s.seats[i]!;
|
||||
if (!x.pos || x.status !== 'playing') return [];
|
||||
return legalPawnMoves(s.wallIndex, pawnsOf(s), x.pos, s.options.diagonalJumpTrigger);
|
||||
}
|
||||
|
||||
/** 앵커 범위·남은 벽·겹침(§5.3 조건 1~3)만 본 이유. 통과하면 null. */
|
||||
function wallShapeRefusal(s: QuoridorState, i: number, c: number, r: number, o: 'h' | 'v'): string | null {
|
||||
if (s.seats[i]!.wallsLeft <= 0) return '남은 벽이 없어요.';
|
||||
if (!anchorInRange(c, r)) return '벽은 판 밖으로 나갈 수 없어요.';
|
||||
if (overlaps(s.wallIndex, c, r, o)) return '다른 벽과 겹쳐요.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 후보 벽을 잠시 세우고 남아 있는 모든 플레이어의 최단 거리를 잰다(-1 = 막힘). */
|
||||
function distancesWith(s: QuoridorState, w: Wall | null): number[] {
|
||||
if (w) setWall(s.wallIndex, w, true);
|
||||
const out = s.seats.map((x) => (x.status === 'playing' && x.pos ? pathLength(s.wallIndex, x.pos, x.seat) : 0));
|
||||
if (w) setWall(s.wallIndex, w, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** §5.3 조건 4. `s.wallIndex`를 잠시 바꿨다가 되돌린다 — 반드시 사본 상태로 부른다. */
|
||||
function blocksSomeone(s: QuoridorState, w: Wall): boolean {
|
||||
setWall(s.wallIndex, w, true);
|
||||
let bad = false;
|
||||
for (const x of s.seats) {
|
||||
if (x.status === 'playing' && x.pos && pathLength(s.wallIndex, x.pos, x.seat) < 0) {
|
||||
bad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setWall(s.wallIndex, w, false);
|
||||
return bad;
|
||||
}
|
||||
|
||||
export function wallRefusal(s: QuoridorState, i: number, w: Wall): string | null {
|
||||
const shape = wallShapeRefusal(s, i, w.c, w.r, w.o);
|
||||
if (shape) return shape;
|
||||
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||
return blocksSomeone(t, { c: w.c, r: w.r, o: w.o }) ? '누군가의 길을 완전히 막을 수 없어요.' : null;
|
||||
}
|
||||
|
||||
/** 합법 벽 목록(최대 128 후보 × 남은 사람 수만큼 BFS). `withDelta`면 거리 변화량도. */
|
||||
export function legalWallsFor(s: QuoridorState, i: number, withDelta = false): QuoridorLegalWall[] {
|
||||
if (s.seats[i]!.wallsLeft <= 0 || s.seats[i]!.status !== 'playing') return [];
|
||||
// wallIndex를 잠시 바꾸므로 사본에서 계산(state는 불변 취급).
|
||||
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||
const base = withDelta ? distancesWith(t, null) : [];
|
||||
const out: QuoridorLegalWall[] = [];
|
||||
for (let r = 0; r < ANCHORS; r++) {
|
||||
for (let c = 0; c < ANCHORS; c++) {
|
||||
for (const o of ['h', 'v'] as const) {
|
||||
if (overlaps(t.wallIndex, c, r, o)) continue;
|
||||
const w = { c, r, o };
|
||||
if (!withDelta) {
|
||||
if (!blocksSomeone(t, w)) out.push({ ...w, opp: 0, me: 0 });
|
||||
continue;
|
||||
}
|
||||
const d = distancesWith(t, w);
|
||||
if (d.some((v) => v < 0)) continue;
|
||||
let opp = 0;
|
||||
t.seats.forEach((x, k) => {
|
||||
if (k !== i && x.status === 'playing') opp += d[k]! - base[k]!;
|
||||
});
|
||||
out.push({ ...w, opp, me: d[i]! - base[i]! });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasLegalWall(s: QuoridorState, i: number): boolean {
|
||||
if (s.seats[i]!.wallsLeft <= 0) return false;
|
||||
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||
for (let r = 0; r < ANCHORS; r++) {
|
||||
for (let c = 0; c < ANCHORS; c++) {
|
||||
for (const o of ['h', 'v'] as const) if (!overlaps(t.wallIndex, c, r, o) && !blocksSomeone(t, { c, r, o })) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const canAct = (s: QuoridorState, i: number): boolean => pawnMovesFor(s, i).length > 0 || hasLegalWall(s, i);
|
||||
|
||||
function distanceOf(s: QuoridorState, x: SeatState): number | null {
|
||||
if (x.status === 'finished') return 0;
|
||||
if (!x.pos) return null;
|
||||
return pathLength(s.wallIndex, x.pos, x.seat);
|
||||
}
|
||||
|
||||
/** 요청자 외 아직 남아 있는 플레이어. */
|
||||
function respondersOf(s: QuoridorState, by: Seat): Seat[] {
|
||||
return playing(s)
|
||||
.map((x) => x.seat)
|
||||
.filter((x) => x !== by);
|
||||
}
|
||||
|
||||
function clearPending(s: QuoridorState): void {
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
s.draw.accepted = [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- finishing
|
||||
|
||||
function rankAndFinish(s: QuoridorState, reason: QuoridorReason, events: GameEvent[]): void {
|
||||
s.phase = 'finished';
|
||||
clearPending(s);
|
||||
const ranking: QuoridorResult['ranking'] = [];
|
||||
let next = 1;
|
||||
const pushGroup = (group: SeatState[]) => {
|
||||
for (const x of group) ranking.push({ playerId: x.playerId, rank: next });
|
||||
next += group.length;
|
||||
};
|
||||
const finished = s.seats.filter((x) => x.status === 'finished').sort((a, b) => a.finishOrder! - b.finishOrder!);
|
||||
for (const x of finished) pushGroup([x]);
|
||||
const alive = playing(s);
|
||||
if (reason === 'drawAgreed') pushGroup(alive);
|
||||
else {
|
||||
const byDist = new Map<number, SeatState[]>();
|
||||
for (const x of alive) {
|
||||
const d = distanceOf(s, x) ?? 999;
|
||||
byDist.set(d, [...(byDist.get(d) ?? []), x]);
|
||||
}
|
||||
for (const d of [...byDist.keys()].sort((a, b) => a - b)) pushGroup(byDist.get(d)!);
|
||||
}
|
||||
const out = s.seats.filter((x) => x.status === 'resigned' || x.status === 'timedOut').sort((a, b) => b.outOrder! - a.outOrder!);
|
||||
for (const x of out) pushGroup([x]);
|
||||
|
||||
const moveCount = s.moves.filter((m) => m.kind !== 'pass').length;
|
||||
const first = ranking.filter((r) => r.rank === 1).map((r) => s.seats.find((x) => x.playerId === r.playerId)!.seat);
|
||||
const firstKo = first.map((x) => SEAT_KO[x]).join('·');
|
||||
const lastOut = out[0];
|
||||
let summaryKo: string;
|
||||
switch (reason) {
|
||||
case 'reachedGoal':
|
||||
summaryKo = `${firstKo} 승리 — ${moveCount}수 만에 도착`;
|
||||
break;
|
||||
case 'lastPlayerStanding':
|
||||
summaryKo = `${finished.map((x) => SEAT_KO[x.seat]).join(' → ')} 순서로 도착`;
|
||||
break;
|
||||
case 'resign':
|
||||
summaryKo = four(s) && out.length > 1 ? `다른 사람이 모두 기권해 ${firstKo} 승리` : `${SEAT_KO[lastOut!.seat]} 기권으로 ${firstKo} 승리`;
|
||||
break;
|
||||
case 'timeLoss':
|
||||
summaryKo = `${SEAT_KO[lastOut!.seat]} 시간패로 ${firstKo} 승리`;
|
||||
break;
|
||||
case 'drawAgreed':
|
||||
summaryKo = '합의 무승부';
|
||||
break;
|
||||
case 'stalemate':
|
||||
summaryKo = '아무도 움직일 수 없어 남은 거리로 순위를 정했어요';
|
||||
break;
|
||||
}
|
||||
s.result = {
|
||||
ranking,
|
||||
reason,
|
||||
perPlayer: s.seats.map((x) => ({ playerId: x.playerId, seat: x.seat, distanceLeft: distanceOf(s, x), wallsLeft: x.wallsLeft, status: x.status })),
|
||||
moveCount,
|
||||
summaryKo,
|
||||
};
|
||||
events.push({ type: 'gameEnded', reason, ranking });
|
||||
}
|
||||
|
||||
/**
|
||||
* 차례 넘기기(§8.2): `from` 다음의 남은 플레이어. 이동·벽 모두 불가하면 자동 패스.
|
||||
* 모두가 아무것도 못 하면(극히 드묾) 남은 거리로 순위를 정해 끝낸다.
|
||||
*/
|
||||
function advance(s: QuoridorState, from: number, now: number, events: GameEvent[]): void {
|
||||
const n = s.seats.length;
|
||||
let i = from;
|
||||
for (let k = 0; k < n; k++) {
|
||||
i = nextSeat(i, n, (j) => s.seats[j]!.status !== 'playing');
|
||||
if (canAct(s, i)) {
|
||||
s.turnIndex = i;
|
||||
s.clock.turnStartedAt = now;
|
||||
return;
|
||||
}
|
||||
const seat = s.seats[i]!.seat;
|
||||
s.moves.push({ kind: 'pass', seat });
|
||||
events.push({ type: 'forcedPass', seat });
|
||||
}
|
||||
s.turnIndex = i;
|
||||
rankAndFinish(s, 'stalemate', events);
|
||||
}
|
||||
|
||||
/** 기권·시간패 처리(§6, §8.5). 2인은 상대 승, 4인은 말 제거·남은 벽 소멸. */
|
||||
function removeSeat(s: QuoridorState, i: number, status: 'resigned' | 'timedOut', now: number, events: GameEvent[]): void {
|
||||
const x = s.seats[i]!;
|
||||
clearPending(s);
|
||||
x.status = status;
|
||||
x.outOrder = ++s.outCount;
|
||||
events.push({ type: status === 'resigned' ? 'playerResigned' : 'timeLoss', seat: x.seat });
|
||||
if (four(s)) {
|
||||
x.pos = null;
|
||||
x.wallsLeft = 0;
|
||||
}
|
||||
if (playing(s).length <= 1) {
|
||||
rankAndFinish(s, status === 'resigned' ? 'resign' : 'timeLoss', events);
|
||||
return;
|
||||
}
|
||||
if (s.turnIndex === i) advance(s, i, now, events);
|
||||
}
|
||||
|
||||
/** 이미 검증된 말 이동. 도착 판정 → 종료 또는 계속 → 차례 넘기기. */
|
||||
function movePawn(s: QuoridorState, i: number, to: Cell, auto: boolean, now: number, events: GameEvent[]): void {
|
||||
const x = s.seats[i]!;
|
||||
const from = x.pos!;
|
||||
x.pos = { c: to.c, r: to.r };
|
||||
s.moves.push({ kind: 'move', seat: x.seat, from, to: x.pos, auto });
|
||||
clearPending(s);
|
||||
events.push({ type: 'pawnMoved', seat: x.seat, from, to: x.pos, auto });
|
||||
if (isGoal(x.seat, to.c, to.r)) {
|
||||
x.status = 'finished';
|
||||
x.finishOrder = s.seats.filter((y) => y.finishOrder !== null).length + 1;
|
||||
events.push({ type: 'playerFinished', seat: x.seat, order: x.finishOrder });
|
||||
if (!(four(s) && s.options.continueForRanking)) {
|
||||
rankAndFinish(s, 'reachedGoal', events);
|
||||
return;
|
||||
}
|
||||
x.pos = null;
|
||||
if (playing(s).length <= 1) {
|
||||
// 마지막 1명은 자동으로 다음 순위.
|
||||
rankAndFinish(s, 'lastPlayerStanding', events);
|
||||
return;
|
||||
}
|
||||
}
|
||||
advance(s, i, now, events);
|
||||
}
|
||||
|
||||
function placeWall(s: QuoridorState, i: number, w: Wall, now: number, events: GameEvent[]): void {
|
||||
const x = s.seats[i]!;
|
||||
const wall = { c: w.c, r: w.r, o: w.o };
|
||||
s.walls.push(wall);
|
||||
setWall(s.wallIndex, wall, true);
|
||||
x.wallsLeft--;
|
||||
s.moves.push({ kind: 'wall', seat: x.seat, wall });
|
||||
clearPending(s);
|
||||
events.push({ type: 'wallPlaced', seat: x.seat, wall });
|
||||
advance(s, i, now, events);
|
||||
}
|
||||
|
||||
/** §8.5 autoMove: 목표까지 거리가 가장 짧아지는 칸(동률은 RNG). */
|
||||
function autoTarget(s: QuoridorState, i: number, rng: Rng): Cell | null {
|
||||
const x = s.seats[i]!;
|
||||
const cands = pawnMovesFor(s, i);
|
||||
if (cands.length === 0) return null;
|
||||
let best = Infinity;
|
||||
let pool: Cell[] = [];
|
||||
for (const c of cands) {
|
||||
const d = pathLength(s.wallIndex, c, x.seat);
|
||||
const dd = d < 0 ? 999 : d;
|
||||
if (dd < best) {
|
||||
best = dd;
|
||||
pool = [c];
|
||||
} else if (dd === best) pool.push(c);
|
||||
}
|
||||
return pool[rng.int(pool.length)]!;
|
||||
}
|
||||
|
||||
/** 직전 행동부터 되돌릴 기록 시작 인덱스. 없으면 -1. */
|
||||
function undoStart(s: QuoridorState, seat: Seat): number {
|
||||
let lastAction = -1;
|
||||
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||
if (s.moves[k]!.kind !== 'pass') {
|
||||
lastAction = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAction < 0) return -1;
|
||||
if (four(s)) return s.moves[lastAction]!.seat === seat ? lastAction : -1;
|
||||
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||
const m = s.moves[k]!;
|
||||
if (m.kind !== 'pass' && m.seat === seat) return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 기록 끝에서 `plies`개를 거꾸로 되돌린다(벽 개수 포함). */
|
||||
function rewind(s: QuoridorState, plies: number): void {
|
||||
for (let k = 0; k < plies; k++) {
|
||||
const m = s.moves.pop()!;
|
||||
if (m.kind === 'move') {
|
||||
const x = bySeat(s, m.seat);
|
||||
if (!x.pos || !sameCell(x.pos, m.to)) throw new Error('undo: move record does not match the board');
|
||||
x.pos = { ...m.from };
|
||||
} else if (m.kind === 'wall') {
|
||||
const last = s.walls.pop();
|
||||
if (!last || last.c !== m.wall.c || last.r !== m.wall.r || last.o !== m.wall.o) throw new Error('undo: wall record mismatch');
|
||||
setWall(s.wallIndex, last, false);
|
||||
bySeat(s, m.seat).wallsLeft++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chargeClock(s: QuoridorState, i: number, now: number): boolean {
|
||||
if (s.options.timeControl.kind !== 'fischer') return true;
|
||||
const seat = s.seats[i]!.seat;
|
||||
s.clock.remainingMs[seat] -= now - s.clock.turnStartedAt;
|
||||
if (s.clock.remainingMs[seat] <= 0) {
|
||||
s.clock.remainingMs[seat] = 0;
|
||||
return false;
|
||||
}
|
||||
s.clock.remainingMs[seat] += s.options.timeControl.incSec * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
|
||||
id: 'quoridor',
|
||||
nameKo: '벽 쌓기 길찾기',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
defaultOptions: quoridorOptionsSchema.parse({}),
|
||||
optionsSchema: quoridorOptionsSchema as unknown as z.ZodType<QuoridorOptions>,
|
||||
actionSchema: quoridorActionSchema as z.ZodType<QuoridorAction>,
|
||||
playerCountError: (_o, n) => playerCountError(n),
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
const err = playerCountError(players.length);
|
||||
if (err) throw new Error(`quoridor: ${err}`);
|
||||
const order = players.length === 4 ? ORDER_4 : ORDER_2;
|
||||
const assigned = options.seatOrder === 'random' ? rng.shuffle(players) : [...players];
|
||||
const walls = wallsFor(options, players.length);
|
||||
const base = options.timeControl.baseSec * 1000;
|
||||
return {
|
||||
options,
|
||||
seats: order.map((seat, k) => ({ seat, playerId: assigned[k]!, pos: { ...START[seat] }, wallsLeft: walls, status: 'playing' as const, finishOrder: null, outOrder: null })),
|
||||
turnIndex: 0,
|
||||
walls: [],
|
||||
wallIndex: emptyWallIndex(),
|
||||
moves: [],
|
||||
phase: 'playing',
|
||||
clock: { remainingMs: recordOf(base), turnStartedAt: now, autoMoveStreak: recordOf(0) },
|
||||
undo: { usedBy: recordOf(0), pending: null },
|
||||
draw: { pendingBy: null, accepted: [], lastOfferMove: recordOf(-1) },
|
||||
outCount: 0,
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
const i = seatIndexOf(s, actor);
|
||||
if (i < 0) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
|
||||
const x = s.seats[i]!;
|
||||
if (x.status !== 'playing') return fail(x.status === 'finished' ? '이미 목표에 도착했어요.' : '이미 게임에서 빠졌어요.');
|
||||
const myTurn = s.turnIndex === i;
|
||||
switch (a.type) {
|
||||
case 'move': {
|
||||
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
|
||||
const to = a.to;
|
||||
if (pawnMovesFor(s, i).some((m) => sameCell(m, to))) return ok;
|
||||
return fail(moveRefusal(s.wallIndex, pawnsOf(s), x.pos!, to, s.options.diagonalJumpTrigger));
|
||||
}
|
||||
case 'placeWall': {
|
||||
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
|
||||
const why = wallRefusal(s, i, { c: a.c, r: a.r, o: a.o });
|
||||
return why ? fail(why) : ok;
|
||||
}
|
||||
case 'timeout':
|
||||
return myTurn ? ok : fail('지금은 내 차례가 아니에요.');
|
||||
case 'requestUndo': {
|
||||
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
if (s.undo.usedBy[x.seat] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
|
||||
if (undoStart(s, x.seat) < 0) return fail(four(s) ? '방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.' : '아직 무를 수가 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const p = s.undo.pending;
|
||||
if (!p || p.by === x.seat || p.accepted.includes(x.seat)) return fail('응답할 무르기 요청이 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'offerDraw': {
|
||||
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
const last = s.draw.lastOfferMove[x.seat];
|
||||
if (last >= 0 && !s.moves.slice(last).some((m) => m.kind !== 'pass' && m.seat === x.seat)) {
|
||||
return fail('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case 'respondDraw': {
|
||||
const by = s.draw.pendingBy;
|
||||
if (!by || by === x.seat || s.draw.accepted.includes(x.seat)) return fail('응답할 무승부 제안이 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by !== x.seat && s.draw.pendingBy !== x.seat) return fail('취소할 요청이 없어요.');
|
||||
return ok;
|
||||
case 'resign':
|
||||
return ok;
|
||||
}
|
||||
return fail('알 수 없는 행동이에요.');
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const i = seatIndexOf(s, actor);
|
||||
const x = s.seats[i]!;
|
||||
const seat = x.seat;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
case 'move':
|
||||
case 'placeWall': {
|
||||
if (!chargeClock(s, i, now)) {
|
||||
removeSeat(s, i, 'timedOut', now, events);
|
||||
break;
|
||||
}
|
||||
s.clock.autoMoveStreak[seat] = 0;
|
||||
if (a.type === 'move') {
|
||||
if (!pawnMovesFor(s, i).some((m) => sameCell(m, a.to))) throw new Error('illegal pawn move');
|
||||
movePawn(s, i, a.to, false, now, events);
|
||||
} else {
|
||||
if (wallRefusal(s, i, a)) throw new Error('illegal wall');
|
||||
placeWall(s, i, a, now, events);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'timeout': {
|
||||
const tc = s.options.timeControl.kind;
|
||||
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[seat] < s.options.autoMoveLimit) {
|
||||
s.clock.autoMoveStreak[seat]++;
|
||||
events.push({ type: 'timeoutAutoMove', seat });
|
||||
const to = autoTarget(s, i, rng);
|
||||
if (to) movePawn(s, i, to, true, now, events);
|
||||
else {
|
||||
s.moves.push({ kind: 'pass', seat });
|
||||
clearPending(s);
|
||||
events.push({ type: 'forcedPass', seat });
|
||||
advance(s, i, now, events);
|
||||
}
|
||||
} else {
|
||||
if (tc === 'fischer') s.clock.remainingMs[seat] = 0;
|
||||
removeSeat(s, i, 'timedOut', now, events);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'requestUndo': {
|
||||
const start = undoStart(s, seat);
|
||||
s.undo.pending = { by: seat, accepted: [], plies: s.moves.length - start };
|
||||
events.push({ type: 'undoRequested', seat });
|
||||
break;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const p = s.undo.pending!;
|
||||
if (!a.accept) {
|
||||
s.undo.pending = null;
|
||||
events.push({ type: 'undoDeclined', seat: p.by, by: seat });
|
||||
break;
|
||||
}
|
||||
p.accepted.push(seat);
|
||||
if (!respondersOf(s, p.by).every((y) => p.accepted.includes(y))) {
|
||||
events.push({ type: 'undoAcceptedBy', seat: p.by, by: seat });
|
||||
break;
|
||||
}
|
||||
s.undo.pending = null;
|
||||
// 생각하던 사람이 쓴 시간은 차감하고, 시계는 그대로 둔다.
|
||||
if (s.options.timeControl.kind === 'fischer') {
|
||||
const thinker = s.seats[s.turnIndex]!.seat;
|
||||
s.clock.remainingMs[thinker] = Math.max(0, s.clock.remainingMs[thinker] - (now - s.clock.turnStartedAt));
|
||||
}
|
||||
rewind(s, p.plies);
|
||||
s.undo.usedBy[p.by]++;
|
||||
s.turnIndex = s.seats.findIndex((y) => y.seat === p.by);
|
||||
s.clock.turnStartedAt = now;
|
||||
for (const y of ALL_SEATS) s.draw.lastOfferMove[y] = Math.min(s.draw.lastOfferMove[y], s.moves.length);
|
||||
events.push({ type: 'undoAccepted', seat: p.by, plies: p.plies });
|
||||
break;
|
||||
}
|
||||
case 'offerDraw':
|
||||
s.draw.pendingBy = seat;
|
||||
s.draw.accepted = [];
|
||||
s.draw.lastOfferMove[seat] = s.moves.length;
|
||||
events.push({ type: 'drawOffered', seat });
|
||||
break;
|
||||
case 'respondDraw': {
|
||||
if (!a.accept) {
|
||||
s.draw.pendingBy = null;
|
||||
s.draw.accepted = [];
|
||||
events.push({ type: 'drawDeclined', by: seat });
|
||||
break;
|
||||
}
|
||||
s.draw.accepted.push(seat);
|
||||
if (respondersOf(s, s.draw.pendingBy!).every((y) => s.draw.accepted.includes(y))) {
|
||||
events.push({ type: 'drawAccepted' });
|
||||
rankAndFinish(s, 'drawAgreed', events);
|
||||
} else events.push({ type: 'drawAcceptedBy', by: seat });
|
||||
break;
|
||||
}
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by === seat) s.undo.pending = null;
|
||||
if (s.draw.pendingBy === seat) {
|
||||
s.draw.pendingBy = null;
|
||||
s.draw.accepted = [];
|
||||
}
|
||||
events.push({ type: 'offerCancelled', seat });
|
||||
break;
|
||||
case 'resign':
|
||||
removeSeat(s, i, 'resigned', now, events);
|
||||
break;
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const vi = viewer === null ? -1 : seatIndexOf(s, viewer);
|
||||
const mySeat = vi >= 0 ? s.seats[vi]!.seat : null;
|
||||
const mine = s.phase === 'playing' && vi >= 0 && vi === s.turnIndex && s.seats[vi]!.status === 'playing';
|
||||
const limit = undoLimit(s.options);
|
||||
const me = vi >= 0 ? s.seats[vi]! : null;
|
||||
let lastMove: QuoridorMove | null = null;
|
||||
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||
if (s.moves[k]!.kind !== 'pass') {
|
||||
lastMove = s.moves[k]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
size: SIZE,
|
||||
seats: s.seats.map((x) => ({
|
||||
seat: x.seat,
|
||||
playerId: x.playerId,
|
||||
pos: x.pos,
|
||||
wallsLeft: x.wallsLeft,
|
||||
status: x.status,
|
||||
finishOrder: x.finishOrder,
|
||||
distance: distanceOf(s, x),
|
||||
autoMoveStreak: s.clock.autoMoveStreak[x.seat],
|
||||
})),
|
||||
mySeat,
|
||||
turn: s.seats[s.turnIndex]!.seat,
|
||||
phase: s.phase,
|
||||
walls: s.walls,
|
||||
moves: s.moves,
|
||||
moveCount: s.moves.length,
|
||||
lastMove,
|
||||
legalPawnMoves: mine ? pawnMovesFor(s, vi) : null,
|
||||
legalWalls: mine ? legalWallsFor(s, vi, true) : null,
|
||||
myShortestPath: mine && s.options.showPath && me?.pos ? shortestPath(s.wallIndex, me.pos, me.seat) : null,
|
||||
wallsPerPlayer: wallsFor(s.options, s.seats.length),
|
||||
diagonalJumpTrigger: s.options.diagonalJumpTrigger,
|
||||
continueForRanking: four(s) && s.options.continueForRanking,
|
||||
showPath: s.options.showPath,
|
||||
timeControl: s.options.timeControl,
|
||||
timeoutPolicy: s.options.timeoutPolicy,
|
||||
remainingMs: s.clock.remainingMs,
|
||||
turnStartedAt: s.clock.turnStartedAt,
|
||||
undo: {
|
||||
allowed: s.options.undo !== 'off',
|
||||
remaining: Object.fromEntries(ALL_SEATS.map((y) => [y, Number.isFinite(limit) ? limit - s.undo.usedBy[y] : null])) as Record<Seat, number | null>,
|
||||
pending: s.undo.pending,
|
||||
responders: s.undo.pending ? respondersOf(s, s.undo.pending.by) : [],
|
||||
},
|
||||
draw: {
|
||||
allowed: s.options.drawOffer,
|
||||
pendingBy: s.draw.pendingBy,
|
||||
accepted: s.draw.accepted,
|
||||
responders: s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy) : [],
|
||||
},
|
||||
result: s.result,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.phase !== 'playing') return [];
|
||||
const out = [s.seats[s.turnIndex]!.playerId];
|
||||
const p = s.undo.pending;
|
||||
const waiting = p ? respondersOf(s, p.by).filter((y) => !p.accepted.includes(y)) : s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy).filter((y) => !s.draw.accepted.includes(y)) : [];
|
||||
for (const y of waiting) {
|
||||
const id = bySeat(s, y).playerId;
|
||||
if (!out.includes(id)) out.push(id);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase !== 'playing') return null;
|
||||
const tc = s.options.timeControl;
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
return s.phase === 'playing' ? [s.seats[s.turnIndex]!.playerId] : [];
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
const i = seatIndexOf(s, player);
|
||||
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return null;
|
||||
return { type: 'resign' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
const r = s.result;
|
||||
if (!r) return null;
|
||||
const groups = new Map<number, PlayerId[]>();
|
||||
for (const x of r.ranking) groups.set(x.rank, [...(groups.get(x.rank) ?? []), x.playerId]);
|
||||
const reason: Record<QuoridorReason, GameResult['reason']> = {
|
||||
reachedGoal: 'normal',
|
||||
lastPlayerStanding: 'normal',
|
||||
stalemate: 'normal',
|
||||
resign: 'resign',
|
||||
timeLoss: 'timeout',
|
||||
drawAgreed: 'draw-agreed',
|
||||
};
|
||||
return {
|
||||
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
|
||||
summary: r.summaryKo,
|
||||
reason: reason[r.reason],
|
||||
};
|
||||
},
|
||||
|
||||
/** 테스트용: 차례인 사람의 모든 말 이동·벽, 대기 중인 요청의 응답. */
|
||||
legalActions(s, p) {
|
||||
const i = seatIndexOf(s, p);
|
||||
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return [];
|
||||
const seat = s.seats[i]!.seat;
|
||||
const out: QuoridorAction[] = [];
|
||||
const u = s.undo.pending;
|
||||
if (u && u.by !== seat && !u.accepted.includes(seat)) out.push({ type: 'respondUndo', accept: true }, { type: 'respondUndo', accept: false });
|
||||
const d = s.draw.pendingBy;
|
||||
if (d && d !== seat && !s.draw.accepted.includes(seat)) out.push({ type: 'respondDraw', accept: false });
|
||||
if (s.turnIndex !== i) return out;
|
||||
for (const to of pawnMovesFor(s, i)) out.push({ type: 'move', to });
|
||||
for (const w of legalWallsFor(s, i)) out.push({ type: 'placeWall', c: w.c, r: w.r, o: w.o });
|
||||
return out;
|
||||
},
|
||||
|
||||
/** 완전 정보 게임: 숨길 패가 없다(RNG는 상태 밖, view의 힌트는 차례인 사람에게만 — 테스트에서 따로 확인). */
|
||||
secretsOf() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
696
packages/games/src/quoridor/quoridor.test.ts
Normal file
696
packages/games/src/quoridor/quoridor.test.ts
Normal file
@@ -0,0 +1,696 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||
import {
|
||||
legalWallsFor,
|
||||
pawnMovesFor,
|
||||
quoridor,
|
||||
type Cell,
|
||||
type QuoridorAction,
|
||||
type QuoridorOptions,
|
||||
type QuoridorState,
|
||||
type Seat,
|
||||
} from './index';
|
||||
import { cellLabel, emptyWallIndex, isGoal, pathLength, setWall, type Wall } from './board';
|
||||
|
||||
const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]);
|
||||
/** 'e5' → { c: 4, r: 4 } (열 a~i 왼→오, 행 1~9 아래→위). */
|
||||
const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 });
|
||||
/** 'h:d4' → 앵커 d4 (c=3, r=3)의 가로벽. */
|
||||
const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) });
|
||||
|
||||
/** joinOrder: 2인 A=남 B=북, 4인 A=남 B=서 C=북 D=동. */
|
||||
function make(n: 2 | 4 = 2, opts: Partial<QuoridorOptions> = {}): QuoridorState {
|
||||
const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder', ...opts });
|
||||
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
|
||||
return quoridor.setup({ players, options, rng: rng(), now: 0 });
|
||||
}
|
||||
|
||||
/** 말 위치·벽·차례를 직접 정한 국면. */
|
||||
function position(s: QuoridorState, pos: Partial<Record<Seat, string | null>>, walls: string[] = [], turn?: Seat): QuoridorState {
|
||||
for (const x of s.seats) if (x.seat in pos) x.pos = pos[x.seat] ? at(pos[x.seat]!) : null;
|
||||
s.walls = walls.map(wall);
|
||||
s.wallIndex = emptyWallIndex();
|
||||
for (const w of s.walls) setWall(s.wallIndex, w, true);
|
||||
if (turn) s.turnIndex = s.seats.findIndex((x) => x.seat === turn);
|
||||
return s;
|
||||
}
|
||||
|
||||
function act(s: QuoridorState, actor: string, a: QuoridorAction, now = 1000, seed = 9): QuoridorState {
|
||||
const v = quoridor.validate(s, actor, a);
|
||||
if (!v.ok) throw new Error(v.reason);
|
||||
return quoridor.apply(s, actor, a, { rng: rng(seed), now }).state;
|
||||
}
|
||||
const turnId = (s: QuoridorState) => s.seats[s.turnIndex]!.playerId;
|
||||
const go = (s: QuoridorState, to: string, now = 1000) => act(s, turnId(s), { type: 'move', to: at(to) }, now);
|
||||
const put = (s: QuoridorState, w: string, now = 1000) => act(s, turnId(s), { type: 'placeWall', ...wall(w) }, now);
|
||||
const tryGo = (s: QuoridorState, to: string) => quoridor.validate(s, turnId(s), { type: 'move', to: at(to) });
|
||||
const tryPut = (s: QuoridorState, w: string, actor = turnId(s)) => quoridor.validate(s, actor, { type: 'placeWall', ...wall(w) });
|
||||
const moves = (s: QuoridorState) => pawnMovesFor(s, s.turnIndex).map(cellLabel).sort();
|
||||
const seatOf = (s: QuoridorState, seat: Seat) => s.seats.find((x) => x.seat === seat)!;
|
||||
const reason = (v: { ok: boolean; reason?: string }) => (v.ok ? 'ok' : v.reason);
|
||||
|
||||
describe('setup', () => {
|
||||
test('2인: 남 e1 → 9행, 북 e9 → 1행, 남 먼저, 벽 10개씩', () => {
|
||||
const s = make(2);
|
||||
expect(s.seats.map((x) => [x.seat, x.playerId, cellLabel(x.pos!), x.wallsLeft])).toEqual([
|
||||
['S', 'A', 'e1', 10],
|
||||
['N', 'B', 'e9', 10],
|
||||
]);
|
||||
expect(turnId(s)).toBe('A');
|
||||
expect(isGoal('S', 0, 8) && isGoal('N', 8, 0) && !isGoal('S', 4, 7)).toBe(true);
|
||||
expect(quoridor.view(s, null).seats.map((x) => x.distance)).toEqual([8, 8]);
|
||||
});
|
||||
|
||||
test('4인: 남 e1, 서 a5, 북 e9, 동 i5, 시계 방향 차례, 벽 5개씩; 하우스 룰 2인 벽 수', () => {
|
||||
let s = make(4);
|
||||
expect(s.seats.map((x) => `${x.seat}${cellLabel(x.pos!)}${x.wallsLeft}`)).toEqual(['Se15', 'Wa55', 'Ne95', 'Ei55']);
|
||||
expect(isGoal('W', 8, 3) && isGoal('E', 0, 3)).toBe(true);
|
||||
s = go(s, 'e2');
|
||||
expect(turnId(s)).toBe('B');
|
||||
s = go(s, 'b5');
|
||||
expect(turnId(s)).toBe('C');
|
||||
s = go(s, 'e8');
|
||||
expect(turnId(s)).toBe('D');
|
||||
s = go(s, 'h5');
|
||||
expect(turnId(s)).toBe('A');
|
||||
expect(make(2, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(6);
|
||||
expect(make(4, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(5);
|
||||
});
|
||||
|
||||
test('3인은 시작할 수 없어요', () => {
|
||||
expect(quoridor.playerCountError!(quoridor.defaultOptions, 3)).toBe('2명 또는 4명이 필요해요.');
|
||||
expect(quoridor.playerCountError!(quoridor.defaultOptions, 2)).toBeNull();
|
||||
expect(quoridor.playerCountError!(quoridor.defaultOptions, 4)).toBeNull();
|
||||
expect(() => quoridor.setup({ players: ['A', 'B', 'C'], options: quoridor.defaultOptions, rng: rng(), now: 0 })).toThrow('2명 또는 4명');
|
||||
});
|
||||
|
||||
test('random 자리 배정은 RNG로, 같은 시드면 같은 결과', () => {
|
||||
const seats = (n: number) => quoridor.setup({ players: ['A', 'B', 'C', 'D'], options: quoridor.defaultOptions, rng: rng(n), now: 0 }).seats.map((x) => x.playerId).join('');
|
||||
expect(seats(3)).toBe(seats(3));
|
||||
expect(new Set([1, 2, 3, 4, 5, 6, 7, 8].map(seats)).size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pawn moves (§5.2)', () => {
|
||||
test('기본 이동 4방향; 벽·판 끝·다른 말 때문에 막힘', () => {
|
||||
let s = position(make(), { S: 'e5', N: 'e9' });
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'e6', 'f5']);
|
||||
s = position(make(), { S: 'e5', N: 'e9' }, ['h:e5']); // e5↔e6, f5↔f6 차단
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
|
||||
expect(reason(tryGo(s, 'e6'))).toBe('벽이 가로막고 있어요.');
|
||||
s = position(make(), { S: 'a1', N: 'e9' });
|
||||
expect(moves(s)).toEqual(['a2', 'b1']);
|
||||
s = position(make(), { S: 'e5', N: 'e9' }, ['v:d4']); // d4↔e4 … 세로벽 d4: (d4|e4), (d5|e5)
|
||||
expect(moves(s)).toEqual(['e4', 'e6', 'f5']);
|
||||
expect(reason(tryGo(s, 'd5'))).toBe('벽이 가로막고 있어요.');
|
||||
expect(reason(tryGo(s, 'e7'))).toBe('한 번에 한 칸씩만 움직일 수 있어요.');
|
||||
expect(reason(tryGo(s, 'f6'))).toBe('대각선으로는 상대 말을 넘을 때만 갈 수 있어요.');
|
||||
expect(reason(tryGo(s, 'e9'))).toBe('다른 말이 있는 칸이에요.');
|
||||
});
|
||||
|
||||
test('직선 점프 성공', () => {
|
||||
const s = position(make(), { S: 'e5', N: 'e6' });
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
|
||||
expect(seatOf(go(s, 'e7'), 'S').pos).toEqual(at('e7'));
|
||||
});
|
||||
|
||||
test('점프할 칸 뒤가 벽 / 판 끝 / 다른 말이면 대각 2곳, 한쪽이 벽이면 1곳', () => {
|
||||
// 뒤가 벽
|
||||
let s = position(make(), { S: 'e5', N: 'e6' }, ['h:d6']); // d6↔d7, e6↔e7 차단
|
||||
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||
// 뒤가 판 끝
|
||||
s = position(make(), { S: 'e8', N: 'e9' });
|
||||
expect(moves(s)).toEqual(['d8', 'd9', 'e7', 'f8', 'f9']);
|
||||
// 뒤가 다른 말(4인)
|
||||
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||
// 대각 한쪽이 벽: 북 말과 d6 사이 세로벽(d6|e6)
|
||||
expect(reason(tryPut(position(make(), { S: 'e5', N: 'e6' }, ['h:d6']), 'v:d6'))).toBe('다른 벽과 겹쳐요.');
|
||||
s = position(make(), { S: 'e5', N: 'e6' }, ['h:e6', 'v:d6']); // e6↔e7, f6↔f7 / (d6|e6),(d7|e7)
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'f5', 'f6']);
|
||||
expect(reason(tryGo(s, 'd6'))).toBe('벽이 가로막고 있어요.');
|
||||
});
|
||||
|
||||
test('wallOnly: 판 끝·다른 말 뒤로는 대각 불가, 벽 뒤로는 가능', () => {
|
||||
let s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e8', N: 'e9' });
|
||||
expect(moves(s)).toEqual(['d8', 'e7', 'f8']);
|
||||
expect(reason(tryGo(s, 'd9'))).toBe('이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.');
|
||||
s = position(make(4, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
|
||||
s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6' }, ['h:d6']);
|
||||
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||
});
|
||||
|
||||
test('직선 점프가 되면 대각은 거부, 말 2개 연속 점프 거부', () => {
|
||||
let s = position(make(), { S: 'e5', N: 'e6' });
|
||||
expect(reason(tryGo(s, 'd6'))).toBe('곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.');
|
||||
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||
expect(reason(tryGo(s, 'e8'))).toBe('말은 한 번에 하나만 뛰어넘을 수 있어요.');
|
||||
});
|
||||
|
||||
test('판에서 빠진 말은 장애물이 아니에요', () => {
|
||||
const s = position(make(4), { S: 'e5', N: 'e6', W: null, E: 'i1' }, [], 'S');
|
||||
seatOf(s, 'W').status = 'resigned';
|
||||
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('walls (§5.3)', () => {
|
||||
test('벽 설치: 개수 감소, 기록, 차례 넘김', () => {
|
||||
const s = put(make(), 'h:e5');
|
||||
expect(s.walls).toEqual([wall('h:e5')]);
|
||||
expect(seatOf(s, 'S').wallsLeft).toBe(9);
|
||||
expect(s.moves.at(-1)).toEqual({ kind: 'wall', seat: 'S', wall: wall('h:e5') });
|
||||
expect(turnId(s)).toBe('B');
|
||||
});
|
||||
|
||||
test('겹침: 같은 방향 반 칸 겹침·같은 앵커 십자 거부, 다른 앵커의 T자·끝 맞닿음은 허용', () => {
|
||||
const s = position(make(), { S: 'e1', N: 'e9' }, ['h:d4']);
|
||||
for (const w of ['h:c4', 'h:d4', 'h:e4', 'v:d4']) expect(reason(tryPut(s, w))).toBe('다른 벽과 겹쳐요.');
|
||||
expect(reason(tryPut(s, 'h:b4'))).toBe('ok'); // 끝이 맞닿는 이웃
|
||||
expect(reason(tryPut(s, 'v:d5'))).toBe('ok'); // T자 (d4 앵커 가로벽의 가운데에 세로벽 끝)
|
||||
expect(reason(tryPut(s, 'v:e4'))).toBe('ok'); // 오른쪽 끝에 세로벽 가운데(ㅓ 모양)
|
||||
const v = position(make(), { S: 'e1', N: 'e9' }, ['v:d4']);
|
||||
for (const w of ['v:d3', 'v:d4', 'v:d5', 'h:d4']) expect(reason(tryPut(v, w))).toBe('다른 벽과 겹쳐요.');
|
||||
expect(reason(tryPut(v, 'v:d6'))).toBe('ok');
|
||||
expect(reason(tryPut(v, 'h:c5'))).toBe('ok');
|
||||
});
|
||||
|
||||
test('판 밖 앵커(0~7 밖) 거부, 남은 벽 0개면 거부', () => {
|
||||
const s = make();
|
||||
for (const [c, r] of [
|
||||
[-1, 0],
|
||||
[8, 0],
|
||||
[0, 8],
|
||||
[0, -1],
|
||||
]) {
|
||||
expect(reason(quoridor.validate(s, 'A', { type: 'placeWall', c: c!, r: r!, o: 'h' }))).toBe('벽은 판 밖으로 나갈 수 없어요.');
|
||||
}
|
||||
expect(quoridor.validate(s, 'A', { type: 'placeWall', c: 7, r: 7, o: 'v' }).ok).toBe(true);
|
||||
seatOf(s, 'S').wallsLeft = 0;
|
||||
expect(reason(tryPut(s, 'h:a1'))).toBe('남은 벽이 없어요.');
|
||||
expect(legalWallsFor(s, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
// 북(e9) 주변 상자: e8·e9를 좌우 세로벽으로 감싸고 e7↔e8 가로벽으로 닫으면 북은 1행에 못 간다.
|
||||
const box = ['v:d8', 'v:e8'];
|
||||
test('상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부', () => {
|
||||
const s = position(make(), { S: 'a1', N: 'e9' }, box, 'S');
|
||||
expect(reason(tryPut(s, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||
const n = position(make(), { S: 'a1', N: 'e9' }, box, 'N');
|
||||
expect(reason(tryPut(n, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||
expect(reason(tryPut(n, 'h:a7'))).toBe('ok');
|
||||
});
|
||||
|
||||
test('4인에서 제3자(서)의 길을 막는 벽 거부', () => {
|
||||
const s = position(make(4), {}, ['h:a5', 'h:a4'], 'S'); // 서 a5·b5를 위아래로 감쌈
|
||||
expect(reason(tryPut(s, 'v:b5'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||
expect(legalWallsFor(s, 0).some((w) => w.o === 'v' && w.c === 1 && w.r === 4)).toBe(false);
|
||||
});
|
||||
|
||||
test('말 때문에만 막히는 경우는 허용(경로 검사는 벽만 봄)', () => {
|
||||
// 북의 유일한 출구 e7을 남의 말이 막고 있어도 벽 설치는 합법.
|
||||
const s = position(make(), { S: 'e7', N: 'e9' }, box, 'S');
|
||||
expect(reason(tryPut(s, 'h:a1'))).toBe('ok');
|
||||
expect(pathLength(s.wallIndex, at('e9'), 'N')).toBe(8);
|
||||
});
|
||||
|
||||
test('경로 검사에서 기권한 사람은 빠져요', () => {
|
||||
let s = position(make(4), {}, ['h:a5', 'h:a4'], 'S');
|
||||
s = act(s, 'B', { type: 'resign' });
|
||||
expect(seatOf(s, 'W').pos).toBeNull();
|
||||
expect(reason(tryPut(s, 'v:b5'))).toBe('ok');
|
||||
});
|
||||
|
||||
test('legalWalls 미리보기: 상대 거리 변화량', () => {
|
||||
const s = make();
|
||||
const lw = quoridor.view(s, 'A').legalWalls!;
|
||||
expect(lw.length).toBe(128);
|
||||
// e8↔e9·f8↔f9 가로벽은 북과 남의 길을 모두 1칸 늘린다(둘 다 e열을 지나므로).
|
||||
expect(lw.find((w) => w.o === 'h' && w.c === 4 && w.r === 7)).toMatchObject({ opp: 1, me: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('goal and game end (§6)', () => {
|
||||
test('목표 줄 도착 즉시 승리', () => {
|
||||
const s = go(position(make(), { S: 'c8', N: 'a5' }), 'c9');
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(s.result).toMatchObject({ reason: 'reachedGoal' });
|
||||
expect(quoridor.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
|
||||
expect(s.result!.summaryKo).toBe('남 승리 — 1수 만에 도착');
|
||||
expect(s.result!.perPlayer.map((p) => p.distanceLeft)).toEqual([0, 4]);
|
||||
});
|
||||
|
||||
test('점프로 도착해도 승리', () => {
|
||||
const s = go(position(make(), { S: 'e7', N: 'e8' }), 'e9');
|
||||
expect(s.result?.ranking[0]).toEqual({ playerId: 'A', rank: 1 });
|
||||
});
|
||||
|
||||
test('북도 1행에 닿으면 승리', () => {
|
||||
let s = position(make(), { S: 'a1', N: 'e2' }, [], 'N');
|
||||
s = go(s, 'e1');
|
||||
expect(quoridor.result(s)!.ranking).toEqual([['B'], ['A']]);
|
||||
});
|
||||
|
||||
test('4인 결과 순위: 도착자 1등, 나머지는 남은 거리 순, 같으면 공동', () => {
|
||||
let s = position(make(4), { S: 'e8', W: 'f5', N: 'c4', E: 'f3' }, [], 'S');
|
||||
s = go(s, 'e9');
|
||||
expect(s.result!.ranking).toEqual([
|
||||
{ playerId: 'A', rank: 1 },
|
||||
{ playerId: 'B', rank: 2 },
|
||||
{ playerId: 'C', rank: 2 },
|
||||
{ playerId: 'D', rank: 4 },
|
||||
]);
|
||||
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B', 'C'], ['D']]);
|
||||
});
|
||||
|
||||
test('4인 기권: 말 제거, 남은 벽 소멸, 설치한 벽 유지, 1명 남으면 그 사람 1등', () => {
|
||||
let s = make(4);
|
||||
s = put(s, 'h:a1'); // 남
|
||||
s = put(s, 'h:c1'); // 서
|
||||
s = act(s, 'B', { type: 'resign' });
|
||||
expect(seatOf(s, 'W')).toMatchObject({ pos: null, wallsLeft: 0, status: 'resigned' });
|
||||
expect(s.walls).toHaveLength(2);
|
||||
expect(turnId(s)).toBe('C');
|
||||
s = act(s, 'C', { type: 'resign' }); // 차례인 사람이 기권 → 다음(동)
|
||||
expect(turnId(s)).toBe('D');
|
||||
s = act(s, 'A', { type: 'resign' });
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(quoridor.result(s)).toMatchObject({ ranking: [['D'], ['A'], ['C'], ['B']], reason: 'resign' });
|
||||
expect(s.result!.summaryKo).toBe('다른 사람이 모두 기권해 동 승리');
|
||||
});
|
||||
|
||||
test('2인 기권은 상대 승', () => {
|
||||
const s = act(make(), 'A', { type: 'resign' });
|
||||
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'resign', summary: '남 기권으로 북 승리' });
|
||||
expect(quoridor.onLeave!(s, 'B')).toBeNull();
|
||||
expect(quoridor.onLeave!(make(), 'A')).toEqual({ type: 'resign' });
|
||||
});
|
||||
|
||||
test('continueForRanking: 도착자를 판에서 빼고 계속, 도착 순서대로 순위', () => {
|
||||
let s = position(make(4, { continueForRanking: true }), { S: 'e8', W: 'h5', N: 'c2', E: 'f3' }, [], 'S');
|
||||
s = go(s, 'e9');
|
||||
expect(s.phase).toBe('playing');
|
||||
expect(seatOf(s, 'S')).toMatchObject({ status: 'finished', finishOrder: 1, pos: null });
|
||||
expect(turnId(s)).toBe('B');
|
||||
s = go(s, 'i5'); // 서 도착
|
||||
expect(turnId(s)).toBe('C');
|
||||
expect(quoridor.validate(s, 'A', { type: 'resign' }).ok).toBe(false);
|
||||
s = go(s, 'c1'); // 북 도착 → 동만 남음
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B'], ['C'], ['D']]);
|
||||
expect(s.result!.summaryKo).toBe('남 → 서 → 북 순서로 도착');
|
||||
});
|
||||
|
||||
test('무승부 합의: 남은 사람 모두 동의해야, 다음 사람이 두면 자동 취소', () => {
|
||||
let s = act(make(4), 'B', { type: 'offerDraw' });
|
||||
expect(quoridor.activePlayers(s)).toEqual(['A', 'C', 'D']);
|
||||
s = act(s, 'A', { type: 'respondDraw', accept: true });
|
||||
s = act(s, 'C', { type: 'respondDraw', accept: true });
|
||||
expect(s.phase).toBe('playing');
|
||||
const moved = go(s, 'e2');
|
||||
expect(moved.draw.pendingBy).toBeNull();
|
||||
expect(reason(quoridor.validate(moved, 'B', { type: 'offerDraw' }))).toBe('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
|
||||
s = act(s, 'D', { type: 'respondDraw', accept: true });
|
||||
expect(quoridor.result(s)).toMatchObject({ ranking: [['A', 'B', 'C', 'D']], reason: 'draw-agreed', summary: '합의 무승부' });
|
||||
const declined = act(act(make(), 'A', { type: 'offerDraw' }), 'B', { type: 'respondDraw', accept: false });
|
||||
expect(declined.draw.pendingBy).toBeNull();
|
||||
expect(quoridor.validate(make(2, { drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
test('2인: 상대가 이미 뒀으면 2개 되돌림(벽 개수 포함), 거절하면 그대로, 횟수 제한', () => {
|
||||
let s = put(make(2, { undo: '1' }), 'h:e5'); // 남 벽
|
||||
s = go(s, 'e8'); // 북 이동
|
||||
s = act(s, 'A', { type: 'requestUndo' });
|
||||
expect(s.undo.pending).toEqual({ by: 'S', accepted: [], plies: 2 });
|
||||
expect(reason(quoridor.validate(s, 'A', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
|
||||
const no = act(s, 'B', { type: 'respondUndo', accept: false });
|
||||
expect(no.walls).toHaveLength(1);
|
||||
expect(no.undo.pending).toBeNull();
|
||||
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(s.walls).toEqual([]);
|
||||
expect(seatOf(s, 'S').wallsLeft).toBe(10);
|
||||
expect(seatOf(s, 'N').pos).toEqual(at('e9'));
|
||||
expect(turnId(s)).toBe('A');
|
||||
expect(s.moves).toEqual([]);
|
||||
s = go(s, 'e2');
|
||||
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('무르기 횟수를 다 썼어요.');
|
||||
expect(quoridor.validate(make(2, { undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false);
|
||||
expect(reason(quoridor.validate(make(), 'A', { type: 'requestUndo' }))).toBe('아직 무를 수가 없어요.');
|
||||
});
|
||||
|
||||
test('4인: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가', () => {
|
||||
let s = go(make(4), 'e2');
|
||||
s = act(s, 'A', { type: 'requestUndo' });
|
||||
expect(quoridor.activePlayers(s)).toEqual(['B', 'C', 'D']);
|
||||
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||
s = act(s, 'C', { type: 'respondUndo', accept: true });
|
||||
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
|
||||
expect(reason(quoridor.validate(s, 'C', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
|
||||
const declined = act(s, 'D', { type: 'respondUndo', accept: false });
|
||||
expect(seatOf(declined, 'S').pos).toEqual(at('e2'));
|
||||
expect(declined.undo.pending).toBeNull();
|
||||
s = act(s, 'D', { type: 'respondUndo', accept: true });
|
||||
expect(seatOf(s, 'S').pos).toEqual(at('e1'));
|
||||
expect(turnId(s)).toBe('A');
|
||||
expect(s.undo.usedBy.S).toBe(1);
|
||||
// 다음 사람(서)이 행동한 뒤에는 남이 무를 수 없다.
|
||||
s = go(go(s, 'e2'), 'b5');
|
||||
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.');
|
||||
expect(quoridor.validate(s, 'B', { type: 'requestUndo' }).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forced pass (§5.4)', () => {
|
||||
// 4인: 남 a1, 서 a2(위), 북 a3(서 뒤), 동 b2. 세로벽 a1|b1·a2|b2. 남은 벽이 없으면 말·벽 모두 불가.
|
||||
const stuck = (wallsLeft: number) => {
|
||||
const s = position(make(4), { S: 'a1', W: 'a2', N: 'a3', E: 'b2' }, ['v:a1'], 'E');
|
||||
seatOf(s, 'S').wallsLeft = wallsLeft;
|
||||
return s;
|
||||
};
|
||||
test('말 이동도 벽도 불가하면 자동 패스', () => {
|
||||
const s0 = stuck(0);
|
||||
expect(pawnMovesFor(s0, 0)).toEqual([]);
|
||||
const s = go(s0, 'c2'); // 동이 움직여도 남은 여전히 갇힘
|
||||
expect(s.moves.slice(-2)).toEqual([
|
||||
{ kind: 'move', seat: 'E', from: at('b2'), to: at('c2'), auto: false },
|
||||
{ kind: 'pass', seat: 'S' },
|
||||
]);
|
||||
expect(turnId(s)).toBe('B');
|
||||
});
|
||||
test('벽이 남아 있으면 패스하지 않음', () => {
|
||||
const s = go(stuck(1), 'c2');
|
||||
expect(turnId(s)).toBe('A');
|
||||
expect(quoridor.view(s, 'A').legalPawnMoves).toEqual([]);
|
||||
expect(quoridor.view(s, 'A').legalWalls!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time (§8.5)', () => {
|
||||
test('autoMove: 최단 거리가 줄어드는 칸으로 이동', () => {
|
||||
const s = act(make(), 'A', { type: 'timeout' });
|
||||
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
|
||||
expect(s.moves[0]).toMatchObject({ kind: 'move', auto: true });
|
||||
expect(s.clock.autoMoveStreak.S).toBe(1);
|
||||
});
|
||||
|
||||
test('autoMove 동률은 RNG: 같은 시드 같은 결과, 시드에 따라 둘 다 나옴', () => {
|
||||
// 남 e5 앞에 북 e6, 그 뒤에 서 e7 → 대각 d6·f6 모두 거리 3.
|
||||
const base = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||
const pick = (seed: number) => cellLabel(seatOf(act(base, 'A', { type: 'timeout' }, 1000, seed), 'S').pos!);
|
||||
const seen = new Set<string>();
|
||||
for (let seed = 1; seed <= 20; seed++) {
|
||||
expect(pick(seed)).toBe(pick(seed));
|
||||
seen.add(pick(seed));
|
||||
}
|
||||
expect([...seen].sort()).toEqual(['d6', 'f6']);
|
||||
});
|
||||
|
||||
test('3번 연속 시간 초과면 시간패, 직접 두면 연속 횟수 초기화', () => {
|
||||
let s = act(make(), 'A', { type: 'timeout' });
|
||||
s = go(s, 'e8');
|
||||
s = act(s, 'A', { type: 'timeout' });
|
||||
s = go(s, 'e7');
|
||||
const reset = go(s, 'd3');
|
||||
expect(reset.clock.autoMoveStreak.S).toBe(0);
|
||||
s = act(s, 'A', { type: 'timeout' });
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout', summary: '남 시간패로 북 승리' });
|
||||
expect(seatOf(s, 'S').status).toBe('timedOut');
|
||||
});
|
||||
|
||||
test('4인 시간패는 말 제거 후 다음 사람 차례', () => {
|
||||
const s = act(make(4, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' });
|
||||
expect(seatOf(s, 'S')).toMatchObject({ status: 'timedOut', pos: null, wallsLeft: 0 });
|
||||
expect(turnId(s)).toBe('B');
|
||||
expect(s.phase).toBe('playing');
|
||||
});
|
||||
|
||||
test('lose 정책·idle 한도는 첫 시간 초과에 시간패; 차례인 사람만', () => {
|
||||
expect(act(make(2, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).phase).toBe('finished');
|
||||
expect(act(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }), 'A', { type: 'timeout' }).phase).toBe('finished');
|
||||
expect(quoridor.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
|
||||
let s = act(go(make(), 'e2'), 'A', { type: 'requestUndo' });
|
||||
expect(quoridor.timeoutPlayers!(s)).toEqual(['B']);
|
||||
s = act(s, 'B', { type: 'timeout' }); // 자동 이동이 요청을 지운다
|
||||
expect(s.undo.pending).toBeNull();
|
||||
});
|
||||
|
||||
test('deadline: perMove / none / fischer', () => {
|
||||
expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000);
|
||||
expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBe(300_000);
|
||||
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
|
||||
let s = make(2, { timeControl: tc });
|
||||
expect(quoridor.deadline!(s)).toBe(60_000);
|
||||
s = go(s, 'e2', 10_000);
|
||||
expect(s.clock.remainingMs.S).toBe(55_000);
|
||||
expect(quoridor.deadline!(s)).toBe(70_000);
|
||||
const late = go(s, 'e8', 80_000);
|
||||
expect(late.result).toMatchObject({ reason: 'timeLoss' });
|
||||
expect(late.clock.remainingMs.N).toBe(0);
|
||||
const t = act(s, 'B', { type: 'timeout' });
|
||||
expect(quoridor.result(t)).toMatchObject({ ranking: [['A'], ['B']], reason: 'timeout' });
|
||||
expect(quoridor.deadline!(t)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('views (§8.3)', () => {
|
||||
test('legalPawnMoves/legalWalls/myShortestPath는 차례인 사람에게만, rng 없음', () => {
|
||||
const s = go(make(4), 'e2');
|
||||
const v = quoridor.view(s, 'B');
|
||||
expect(v.legalPawnMoves!.map(cellLabel).sort()).toEqual(['a4', 'a6', 'b5']);
|
||||
expect(v.legalWalls!.length).toBe(128);
|
||||
expect(v.myShortestPath!.length).toBe(8);
|
||||
expect(v.mySeat).toBe('W');
|
||||
for (const viewer of ['A', 'C', 'D', null]) {
|
||||
const o = quoridor.view(s, viewer);
|
||||
expect(o.legalPawnMoves).toBeNull();
|
||||
expect(o.legalWalls).toBeNull();
|
||||
expect(o.myShortestPath).toBeNull();
|
||||
expect(o.seats.map((x) => x.distance)).toEqual([7, 8, 8, 8]);
|
||||
}
|
||||
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(quoridor.view(s, viewer))).not.toMatch(/rng/i);
|
||||
expect(JSON.stringify(s)).not.toMatch(/rng/i);
|
||||
expect(quoridor.view(make(2, { showPath: false }), 'A').myShortestPath).toBeNull();
|
||||
expect(quoridor.view(s, null).mySeat).toBeNull();
|
||||
});
|
||||
|
||||
test('성능: 합법 벽 목록(128 후보 × BFS 4회)이 1ms 수준', () => {
|
||||
// 벽 몇 개가 있는 4인 중반 국면.
|
||||
let s = make(4);
|
||||
for (const w of ['h:a3', 'h:c3', 'v:e2', 'h:f6', 'v:b6', 'h:d7', 'v:g3', 'h:g1']) s = put(s, w);
|
||||
const t0 = performance.now();
|
||||
const N = 200;
|
||||
let n = 0;
|
||||
for (let k = 0; k < N; k++) n += legalWallsFor(s, s.turnIndex, true).length;
|
||||
const ms = (performance.now() - t0) / N;
|
||||
console.log(` legalWalls (4인, 변화량 포함): ${ms.toFixed(3)}ms / 회, 후보 ${n / N}개`);
|
||||
expect(n / N).toBeGreaterThan(80);
|
||||
expect(ms).toBeLessThan(5); // 부하가 있는 CI에서도 여유 있게
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
const TOTAL_SEATS = (s: QuoridorState) => s.seats.length;
|
||||
|
||||
/** 매 단계 불변식: 벽 보존, 인덱스 일치, 겹침 없음, 남은 사람 모두 길 있음, 말 겹침 없음, 정보 누출 없음. */
|
||||
function invariant(s: QuoridorState): void {
|
||||
const per = s.seats.length === 4 ? 5 : s.options.wallsPerPlayer;
|
||||
let placed = 0;
|
||||
for (const x of s.seats) {
|
||||
const mine = s.moves.filter((m) => m.kind === 'wall' && m.seat === x.seat).length;
|
||||
placed += mine;
|
||||
if (x.status === 'resigned' || x.status === 'timedOut') {
|
||||
if (s.seats.length === 4 && x.wallsLeft !== 0) throw new Error('removed seat kept walls');
|
||||
} else if (mine + x.wallsLeft !== per) throw new Error(`wall conservation broken for ${x.seat}`);
|
||||
}
|
||||
if (placed !== s.walls.length) throw new Error('walls list != wall moves');
|
||||
const idx = emptyWallIndex();
|
||||
for (const w of s.walls) {
|
||||
if (w.c < 0 || w.c > 7 || w.r < 0 || w.r > 7) throw new Error('wall anchor out of range');
|
||||
setWall(idx, w, true);
|
||||
}
|
||||
if (JSON.stringify(idx) !== JSON.stringify(s.wallIndex)) throw new Error('wallIndex out of sync');
|
||||
const keys = new Set<string>();
|
||||
for (const w of s.walls) {
|
||||
// 같은 방향 반 칸 겹침, 같은 앵커 십자.
|
||||
const k = w.o === 'h' ? [`h${w.c}${w.r}`, `h${w.c + 1}${w.r}`, `x${w.c}${w.r}`] : [`v${w.c}${w.r}`, `v${w.c}${w.r + 1}`, `x${w.c}${w.r}`];
|
||||
for (const key of k) {
|
||||
if (keys.has(key)) throw new Error('overlapping walls');
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
const cells = new Set<string>();
|
||||
for (const x of s.seats) {
|
||||
if (!x.pos) continue;
|
||||
const k = cellLabel(x.pos);
|
||||
if (cells.has(k)) throw new Error('two pawns on one cell');
|
||||
cells.add(k);
|
||||
if (x.status === 'playing' && pathLength(s.wallIndex, x.pos, x.seat) < 0) throw new Error(`${x.seat} has no path`);
|
||||
}
|
||||
if (s.phase === 'playing') {
|
||||
const cur = s.seats[s.turnIndex]!;
|
||||
if (cur.status !== 'playing') throw new Error('turn on a removed seat');
|
||||
if (s.seats.filter((x) => x.status === 'playing').length < 2) throw new Error('game should have ended');
|
||||
for (const p of quoridor.timeoutPlayers!(s)) {
|
||||
const v = quoridor.validate(s, p, quoridor.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
|
||||
}
|
||||
} else if (!s.result || !quoridor.result(s)) throw new Error('finished without result');
|
||||
}
|
||||
|
||||
function checkViews(s: QuoridorState): void {
|
||||
for (const viewer of [...s.seats.map((x) => x.playerId), null]) {
|
||||
const v = quoridor.view(s, viewer);
|
||||
const mine = s.phase === 'playing' && viewer === s.seats[s.turnIndex]!.playerId;
|
||||
if (!mine && (v.legalWalls !== null || v.legalPawnMoves !== null || v.myShortestPath !== null)) throw new Error('turn-only hint leaked');
|
||||
if (/rng/i.test(JSON.stringify(v))) throw new Error('rng in view');
|
||||
}
|
||||
}
|
||||
|
||||
/** 빠른 무작위 대국: 최단 길 이동 / 무작위 이동 / 무작위 벽(거부 샘플링) / 가끔 시간 초과·무르기·무승부·기권. */
|
||||
function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEvery: number) {
|
||||
const r = SeededRng.fromSeed(seedN(seed));
|
||||
const pick = SeededRng.fromSeed(seedN(seed + 77_777));
|
||||
let now = 1_700_000_000_000;
|
||||
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
|
||||
let s = quoridor.setup({ players, options, rng: r, now });
|
||||
const trace: { actor: string; action: QuoridorAction }[] = [];
|
||||
const step = (actor: string, action: QuoridorAction) => {
|
||||
const v = quoridor.validate(s, actor, action);
|
||||
if (!v.ok) throw new Error(`seed ${seed}: rejected ${JSON.stringify(action)}: ${v.reason}`);
|
||||
now += 1000;
|
||||
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
|
||||
trace.push({ actor, action });
|
||||
};
|
||||
for (let k = 0; k < 3000 && s.phase === 'playing'; k++) {
|
||||
if (k % checkEvery === 0) {
|
||||
invariant(s);
|
||||
checkViews(s);
|
||||
if (JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
|
||||
}
|
||||
// 대기 중인 요청이 있으면 응답부터.
|
||||
const pend = s.undo.pending ?? (s.draw.pendingBy ? { by: s.draw.pendingBy, accepted: s.draw.accepted } : null);
|
||||
if (pend) {
|
||||
const responder = s.seats.find((x) => x.status === 'playing' && x.seat !== pend.by && !pend.accepted.includes(x.seat));
|
||||
if (responder) {
|
||||
const accept = pick.next() < 0.7;
|
||||
step(responder.playerId, s.undo.pending ? { type: 'respondUndo', accept } : { type: 'respondDraw', accept: accept && pick.next() < 0.3 });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const i = s.turnIndex;
|
||||
const me = s.seats[i]!;
|
||||
const roll = pick.next();
|
||||
if (roll < 0.03) {
|
||||
step(me.playerId, { type: 'timeout' });
|
||||
continue;
|
||||
}
|
||||
if (roll < 0.04) {
|
||||
const any = s.seats.filter((x) => x.status === 'playing')[pick.int(s.seats.filter((x) => x.status === 'playing').length)]!;
|
||||
const a: QuoridorAction = pick.next() < 0.5 ? { type: 'requestUndo' } : { type: 'offerDraw' };
|
||||
if (quoridor.validate(s, any.playerId, a).ok) {
|
||||
step(any.playerId, a);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (roll < 0.042 && n === 4) {
|
||||
step(s.seats.filter((x) => x.status === 'playing')[0]!.playerId, { type: 'resign' });
|
||||
continue;
|
||||
}
|
||||
if (roll < 0.4 && me.wallsLeft > 0) {
|
||||
let placed = false;
|
||||
for (let t = 0; t < 12 && !placed; t++) {
|
||||
const a: QuoridorAction = { type: 'placeWall', c: pick.int(8), r: pick.int(8), o: pick.int(2) ? 'h' : 'v' };
|
||||
if (quoridor.validate(s, me.playerId, a).ok) {
|
||||
step(me.playerId, a);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
if (placed) continue;
|
||||
}
|
||||
const cands = pawnMovesFor(s, i);
|
||||
if (cands.length === 0) {
|
||||
const lw = legalWallsFor(s, i);
|
||||
step(me.playerId, lw.length ? { type: 'placeWall', c: lw[0]!.c, r: lw[0]!.r, o: lw[0]!.o } : { type: 'timeout' });
|
||||
continue;
|
||||
}
|
||||
let to = cands[pick.int(cands.length)]!;
|
||||
if (pick.next() < 0.6) {
|
||||
let best = Infinity;
|
||||
for (const c of cands) {
|
||||
const d = pathLength(s.wallIndex, c, me.seat);
|
||||
if (d >= 0 && d < best) {
|
||||
best = d;
|
||||
to = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
step(me.playerId, { type: 'move', to });
|
||||
}
|
||||
invariant(s);
|
||||
return { state: s, trace, players };
|
||||
}
|
||||
|
||||
describe('random playouts (docs/09 §7)', () => {
|
||||
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
|
||||
const t0 = performance.now();
|
||||
let finished = 0;
|
||||
let steps = 0;
|
||||
const reasons: Record<string, number> = {};
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const n = i % 3 === 0 ? 4 : 2;
|
||||
const options = quoridor.optionsSchema.parse({
|
||||
seatOrder: i % 2 ? 'random' : 'joinOrder',
|
||||
diagonalJumpTrigger: i % 5 === 0 ? 'wallOnly' : 'wallEdgePawn',
|
||||
continueForRanking: i % 6 === 0,
|
||||
wallsPerPlayer: 6 + (i % 5),
|
||||
undo: i % 7 === 0 ? 'unlimited' : '3',
|
||||
timeControl: i % 11 === 0 ? { kind: 'fischer', perMoveSec: 30, baseSec: 300, incSec: 3 } : { kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 },
|
||||
});
|
||||
const { state, trace } = fastPlayout(i, n, options, i < 100 ? 1 : 7);
|
||||
if (state.phase === 'finished') finished++;
|
||||
steps += trace.length;
|
||||
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
|
||||
}
|
||||
console.log(` 1000 playouts: ${finished} finished, avg ${(steps / 1000).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
|
||||
expect(finished).toBe(1000);
|
||||
expect(reasons.reachedGoal).toBeGreaterThan(500);
|
||||
}, 60_000);
|
||||
|
||||
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const n = i % 2 ? 4 : 2;
|
||||
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 4 === 1 });
|
||||
const a = fastPlayout(5000 + i, n, options, 1000);
|
||||
const b = fastPlayout(5000 + i, n, options, 1000);
|
||||
expect(JSON.stringify(a.state)).toBe(JSON.stringify(b.state));
|
||||
const r = SeededRng.fromSeed(seedN(5000 + i));
|
||||
let now = 1_700_000_000_000;
|
||||
let s = quoridor.setup({ players: a.players, options, rng: r, now });
|
||||
for (const { actor, action } of a.trace) {
|
||||
now += 1000;
|
||||
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
|
||||
}
|
||||
expect(JSON.stringify(s)).toBe(JSON.stringify(a.state));
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
|
||||
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
|
||||
const inv = (st: unknown) => {
|
||||
const s = st as QuoridorState;
|
||||
if (TOTAL_SEATS(s) !== players.length) throw new Error('seat count changed');
|
||||
invariant(s);
|
||||
};
|
||||
const rep = randomPlayout(quoridor, { players, options, seed: seedN(900 + i), invariant: inv, maxSteps: 20_000, maxApplyMs: 50 });
|
||||
expect(rep.finished).toBe(true);
|
||||
expect(quoridor.result(rep.finalState as QuoridorState)).not.toBeNull();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
76
packages/games/src/splendor/cards.ts
Normal file
76
packages/games/src/splendor/cards.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 보석 상인 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card
|
||||
* list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`.
|
||||
*/
|
||||
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;
|
||||
export type Color = (typeof COLORS)[number];
|
||||
export type TokenColor = Color | 'gold';
|
||||
export const TOKEN_COLORS: readonly TokenColor[] = [...COLORS, 'gold'];
|
||||
export type Gems = Record<Color, number>;
|
||||
export type Tokens = Record<TokenColor, number>;
|
||||
export type Tier = 1 | 2 | 3;
|
||||
export const TIERS: readonly Tier[] = [1, 2, 3];
|
||||
|
||||
export interface DevCard {
|
||||
id: string;
|
||||
tier: Tier;
|
||||
bonus: Color;
|
||||
points: number;
|
||||
cost: Gems;
|
||||
}
|
||||
export interface Noble {
|
||||
id: string;
|
||||
points: 3;
|
||||
req: Gems;
|
||||
}
|
||||
|
||||
const RAW_CARDS = [
|
||||
// tier 1
|
||||
'1W01:0:01111', '1W02:0:01211', '1W03:0:02201', '1W04:0:31001', '1W05:0:00021', '1W06:0:02002', '1W07:0:03000', '1W08:1:00400',
|
||||
'1U01:0:10111', '1U02:0:10121', '1U03:0:10220', '1U04:0:01310', '1U05:0:10002', '1U06:0:00202', '1U07:0:00003', '1U08:1:00040',
|
||||
'1G01:0:11011', '1G02:0:11012', '1G03:0:01022', '1G04:0:13100', '1G05:0:21000', '1G06:0:02020', '1G07:0:00030', '1G08:1:00004',
|
||||
'1R01:0:11101', '1R02:0:21101', '1R03:0:20102', '1R04:0:10013', '1R05:0:02100', '1R06:0:20020', '1R07:0:30000', '1R08:1:40000',
|
||||
'1K01:0:11110', '1K02:0:12110', '1K03:0:22010', '1K04:0:00131', '1K05:0:00210', '1K06:0:20200', '1K07:0:00300', '1K08:1:04000',
|
||||
// tier 2
|
||||
'2W01:1:00322', '2W02:1:23030', '2W03:2:00142', '2W04:2:00053', '2W05:2:00050', '2W06:3:60000', '2U01:1:02230', '2U02:1:02303',
|
||||
'2U03:2:53000', '2U04:2:20014', '2U05:2:05000', '2U06:3:06000', '2G01:1:30230', '2G02:1:23002', '2G03:2:42001', '2G04:2:05300',
|
||||
'2G05:2:00500', '2G06:3:00600', '2R01:1:20023', '2R02:1:03023', '2R03:2:14200', '2R04:2:30005', '2R05:2:00005', '2R06:3:00060',
|
||||
'2K01:1:32200', '2K02:1:30302', '2K03:2:01420', '2K04:2:00530', '2K05:2:50000', '2K06:3:00006',
|
||||
// tier 3
|
||||
'3W01:3:03353', '3W02:4:00007', '3W03:4:30036', '3W04:5:30007', '3U01:3:30335', '3U02:4:70000', '3U03:4:63003', '3U04:5:73000',
|
||||
'3G01:3:53033', '3G02:4:07000', '3G03:4:36300', '3G04:5:07300', '3R01:3:35303', '3R02:4:00700', '3R03:4:03630', '3R04:5:00730',
|
||||
'3K01:3:33530', '3K02:4:00070', '3K03:4:00363', '3K04:5:00073',
|
||||
];
|
||||
|
||||
const RAW_NOBLES = [
|
||||
'N01:00440', 'N02:04400', 'N03:44000', 'N04:40004', 'N05:00044', 'N06:03330', 'N07:33300', 'N08:33003', 'N09:30033', 'N10:00333',
|
||||
];
|
||||
|
||||
const gems = (digits: string): Gems => ({ W: +digits[0]!, U: +digits[1]!, G: +digits[2]!, R: +digits[3]!, K: +digits[4]! });
|
||||
|
||||
export const CARDS: readonly DevCard[] = RAW_CARDS.map((raw) => {
|
||||
const [id, points, cost] = raw.split(':') as [string, string, string];
|
||||
return { id, tier: Number(id[0]) as Tier, bonus: id[1] as Color, points: Number(points), cost: gems(cost) };
|
||||
});
|
||||
export const NOBLES: readonly Noble[] = RAW_NOBLES.map((raw) => {
|
||||
const [id, req] = raw.split(':') as [string, string];
|
||||
return { id, points: 3, req: gems(req) };
|
||||
});
|
||||
|
||||
const CARD_BY_ID = new Map(CARDS.map((c) => [c.id, c]));
|
||||
const NOBLE_BY_ID = new Map(NOBLES.map((n) => [n.id, n]));
|
||||
export function cardOf(id: string): DevCard {
|
||||
const c = CARD_BY_ID.get(id);
|
||||
if (!c) throw new Error(`unknown card ${id}`);
|
||||
return c;
|
||||
}
|
||||
export function nobleOf(id: string): Noble {
|
||||
const n = NOBLE_BY_ID.get(id);
|
||||
if (!n) throw new Error(`unknown noble ${id}`);
|
||||
return n;
|
||||
}
|
||||
export const isCardId = (id: string): boolean => CARD_BY_ID.has(id);
|
||||
|
||||
export const emptyGems = (): Gems => ({ W: 0, U: 0, G: 0, R: 0, K: 0 });
|
||||
export const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 });
|
||||
export const sumTokens = (t: Partial<Record<TokenColor, number>>): number => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0);
|
||||
968
packages/games/src/splendor/index.ts
Normal file
968
packages/games/src/splendor/index.ts
Normal file
@@ -0,0 +1,968 @@
|
||||
/**
|
||||
* 보석 상인 (스플렌더 방식, docs/games/splendor.md). Pure rules module: all randomness is in setup
|
||||
* (turn order, nobles, decks), state is plain JSON, the view hides deck contents and blind reservations.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
|
||||
import {
|
||||
CARDS,
|
||||
COLORS,
|
||||
NOBLES,
|
||||
TIERS,
|
||||
TOKEN_COLORS,
|
||||
cardOf,
|
||||
emptyGems,
|
||||
emptyTokens,
|
||||
nobleOf,
|
||||
sumTokens,
|
||||
type Color,
|
||||
type DevCard,
|
||||
type Gems,
|
||||
type Noble,
|
||||
type Tier,
|
||||
type TokenColor,
|
||||
type Tokens,
|
||||
} from './cards';
|
||||
|
||||
export * from './cards';
|
||||
|
||||
const lit = <T extends number | null>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
export const splendorOptionsSchema = z.object({
|
||||
targetPoints: lit(15, 10)
|
||||
.default(15)
|
||||
.meta({ title: '끝나는 점수', labels: { 15: '15점(원작)', 10: '10점(짧은 판)' } }),
|
||||
allowFewerTokens: z.boolean().default(true).meta({ title: '남은 색이 3가지보다 적으면 서로 다른 색 2개나 1개만 가져오기 허용' }),
|
||||
turnSeconds: lit(30, 60, 90, null)
|
||||
.default(60)
|
||||
.meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', null: '제한 없음' } }),
|
||||
showHints: z.boolean().default(true).meta({ title: '살 수 있는 카드와 귀족까지 남은 보너스 보여 주기' }),
|
||||
});
|
||||
export type SplendorOptions = z.infer<typeof splendorOptionsSchema>;
|
||||
|
||||
const colorSchema = z.enum(COLORS);
|
||||
const tierSchema = lit(1, 2, 3);
|
||||
const count = z.number().int().min(0).max(10);
|
||||
const tokensSchema = z.object({ W: count, U: count, G: count, R: count, K: count, gold: count });
|
||||
|
||||
export const splendorActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('TAKE_DIFFERENT'), colors: z.array(colorSchema).min(1).max(3) }),
|
||||
z.object({ type: z.literal('TAKE_SAME'), color: colorSchema }),
|
||||
z.object({
|
||||
type: z.literal('RESERVE'),
|
||||
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ tier: tierSchema, deck: z.literal(true) })]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('BUY'),
|
||||
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ reservedIndex: z.number().int().min(0).max(2) })]),
|
||||
payment: tokensSchema.optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('PASS') }),
|
||||
z.object({ type: z.literal('DISCARD'), tokens: tokensSchema }),
|
||||
z.object({ type: z.literal('CHOOSE_NOBLE'), nobleId: z.string().max(8) }),
|
||||
z.object({ type: z.literal('CONCEDE') }),
|
||||
]);
|
||||
/** Client actions plus the system-only timeout action (not in the schema, docs/09 §2). */
|
||||
export type SplendorAction = z.infer<typeof splendorActionSchema> | { type: 'TIMEOUT' };
|
||||
type PlayAction = Extract<SplendorAction, { type: 'TAKE_DIFFERENT' | 'TAKE_SAME' | 'RESERVE' | 'BUY' | 'PASS' }>;
|
||||
export type BuyFrom = Extract<SplendorAction, { type: 'BUY' }>['from'];
|
||||
export type ReserveFrom = Extract<SplendorAction, { type: 'RESERVE' }>['from'];
|
||||
|
||||
export const TOKEN_LIMIT = 10;
|
||||
export const MAX_RESERVED = 3;
|
||||
export const BOARD_SLOTS = 4;
|
||||
/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */
|
||||
export const AWAY_AFTER = 3;
|
||||
/** Turn length for away players. */
|
||||
export const AWAY_MS = 5000;
|
||||
/** Extra time when the turn goes on to returning tokens or choosing a noble (§8.5). */
|
||||
export const EXTRA_PHASE_MS = 15_000;
|
||||
const LOG_MAX = 20;
|
||||
|
||||
export interface PlayerState {
|
||||
tokens: Tokens;
|
||||
/** Bought development cards. */
|
||||
cards: string[];
|
||||
/** `blind` = taken face down from a deck (only the owner sees it). */
|
||||
reserved: { cardId: string; blind: boolean }[];
|
||||
nobles: string[];
|
||||
}
|
||||
|
||||
export type LogEntry =
|
||||
| { n: number; k: 'take'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
|
||||
| { n: number; k: 'reserve'; p: PlayerId; tier: Tier; cardId: string | null; gold: boolean; auto: boolean }
|
||||
| { n: number; k: 'buy'; p: PlayerId; cardId: string; paid: Partial<Tokens>; fromReserve: boolean; auto: boolean }
|
||||
| { n: number; k: 'pass'; p: PlayerId; auto: boolean }
|
||||
| { n: number; k: 'discard'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
|
||||
| { n: number; k: 'noble'; p: PlayerId; nobleId: string }
|
||||
| { n: number; k: 'concede'; p: PlayerId }
|
||||
| { n: number; k: 'away'; p: PlayerId }
|
||||
| { n: number; k: 'end'; p: PlayerId; points: number };
|
||||
|
||||
export interface SplendorState {
|
||||
options: SplendorOptions;
|
||||
/** Turn order, order[0] = first player (shuffled at setup). */
|
||||
order: PlayerId[];
|
||||
current: number;
|
||||
phase: 'action' | 'discard' | 'noble' | 'finished';
|
||||
/** Somebody reached the target: the round is played to its end (§6). */
|
||||
endTriggered: boolean;
|
||||
endReason: 'target' | 'stalemate' | 'resign' | null;
|
||||
supply: Tokens;
|
||||
/** Hidden, index 0 = top. */
|
||||
decks: Record<Tier, string[]>;
|
||||
/** Four open slots per tier, null = empty (deck ran out). */
|
||||
board: Record<Tier, (string | null)[]>;
|
||||
/** Open nobles still waiting for a visit. */
|
||||
nobles: string[];
|
||||
players: Record<PlayerId, PlayerState>;
|
||||
pendingNobleChoices: string[] | null;
|
||||
turnsTaken: Record<PlayerId, number>;
|
||||
turnSeq: number;
|
||||
deadlineAt: number | null;
|
||||
conceded: PlayerId[];
|
||||
/** Reserved cards of players who conceded (out of the game). */
|
||||
removed: { cardId: string; blind: boolean }[];
|
||||
timeoutsInRow: Record<PlayerId, number>;
|
||||
/** Consecutive PASS turns; a full round of passes ends the game (nothing can change any more). */
|
||||
passStreak: number;
|
||||
logSeq: number;
|
||||
log: LogEntry[];
|
||||
}
|
||||
|
||||
export type ReservedView = { hidden: true; tier: Tier } | (DevCard & { hidden: false; blind: boolean });
|
||||
|
||||
export interface BuyOption {
|
||||
/** Automatic payment (colour tokens first, gold only for the rest). */
|
||||
payment: Tokens;
|
||||
/** Tokens per colour still needed after bonuses. */
|
||||
need: Gems;
|
||||
gold: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SplendorPlayerView {
|
||||
id: PlayerId;
|
||||
tokens: Tokens;
|
||||
tokenCount: number;
|
||||
bonuses: Gems;
|
||||
cards: string[];
|
||||
points: number;
|
||||
nobles: string[];
|
||||
reservedCount: number;
|
||||
reserved: ReservedView[];
|
||||
conceded: boolean;
|
||||
away: boolean;
|
||||
turnsTaken: number;
|
||||
}
|
||||
|
||||
export interface SplendorMeView {
|
||||
id: PlayerId;
|
||||
myTurn: boolean;
|
||||
/** Action phase only: the colours that can be picked for "different colours" and how many to pick. */
|
||||
takeDifferent: { available: Color[]; count: number } | null;
|
||||
takeSame: Color[];
|
||||
canReserve: boolean;
|
||||
reserveDecks: Tier[];
|
||||
goldOnReserve: boolean;
|
||||
/** Affordable cards for me, keyed `b<tier>-<slot>` (board) or `r<index>` (my reservations). */
|
||||
buy: Record<string, BuyOption>;
|
||||
canPass: boolean;
|
||||
/** Discard phase: how many tokens must go back. */
|
||||
discardCount: number;
|
||||
nobleChoices: string[] | null;
|
||||
/** Hints: bonuses still missing per open noble (empty when hints are off). */
|
||||
nobleNeed: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SplendorRankRow {
|
||||
rank: number;
|
||||
player: PlayerId;
|
||||
points: number;
|
||||
cards: number;
|
||||
nobles: number;
|
||||
bonuses: Gems;
|
||||
conceded: boolean;
|
||||
}
|
||||
|
||||
export interface SplendorResult {
|
||||
ranking: SplendorRankRow[];
|
||||
winners: PlayerId[];
|
||||
revealedReserved: Record<PlayerId, string[]>;
|
||||
summary: string;
|
||||
reason: GameResult['reason'];
|
||||
endReason: SplendorState['endReason'];
|
||||
}
|
||||
|
||||
export interface SplendorView {
|
||||
options: SplendorOptions;
|
||||
phase: SplendorState['phase'];
|
||||
order: PlayerId[];
|
||||
current: PlayerId | null;
|
||||
turnSeq: number;
|
||||
deadlineAt: number | null;
|
||||
/** Full length of the running timer (for the ring). */
|
||||
turnMs: number;
|
||||
endTriggered: boolean;
|
||||
supply: Tokens;
|
||||
board: Record<Tier, (DevCard | null)[]>;
|
||||
deckCounts: Record<Tier, number>;
|
||||
nobles: Noble[];
|
||||
players: Record<PlayerId, SplendorPlayerView>;
|
||||
me: SplendorMeView | null;
|
||||
log: LogEntry[];
|
||||
result: SplendorResult | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
export const tokensPerColor = (players: number): number => (players <= 2 ? 4 : players === 3 ? 5 : 7);
|
||||
|
||||
export function bonusesOf(ps: PlayerState): Gems {
|
||||
const b = emptyGems();
|
||||
for (const id of ps.cards) b[cardOf(id).bonus]++;
|
||||
return b;
|
||||
}
|
||||
|
||||
export function pointsOf(ps: PlayerState): number {
|
||||
return ps.cards.reduce((a, id) => a + cardOf(id).points, 0) + ps.nobles.length * 3;
|
||||
}
|
||||
|
||||
/** Tokens per colour still needed for `card` after my bonuses. */
|
||||
export function needFor(card: DevCard, bonuses: Gems): Gems {
|
||||
const n = emptyGems();
|
||||
for (const c of COLORS) n[c] = Math.max(0, card.cost[c] - bonuses[c]);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** §5 "색 토큰 먼저, 부족분만 황금"; null when I can't afford the card. */
|
||||
export function autoPayment(card: DevCard, ps: PlayerState, bonuses = bonusesOf(ps)): Tokens | null {
|
||||
const need = needFor(card, bonuses);
|
||||
const pay = emptyTokens();
|
||||
let short = 0;
|
||||
for (const c of COLORS) {
|
||||
pay[c] = Math.min(need[c], ps.tokens[c]);
|
||||
short += need[c] - pay[c];
|
||||
}
|
||||
if (short > ps.tokens.gold) return null;
|
||||
pay.gold = short;
|
||||
return pay;
|
||||
}
|
||||
|
||||
/** A manual payment: never more than I hold, no colour overpaid, gold covers exactly the rest. */
|
||||
export function paymentProblem(card: DevCard, ps: PlayerState, payment: Tokens, bonuses = bonusesOf(ps)): string | null {
|
||||
const need = needFor(card, bonuses);
|
||||
let short = 0;
|
||||
for (const c of TOKEN_COLORS) {
|
||||
const v = payment[c];
|
||||
if (!Number.isInteger(v) || v < 0) return '지불 내역이 올바르지 않아요.';
|
||||
if (v > ps.tokens[c]) return '보석이 부족해요.';
|
||||
}
|
||||
for (const c of COLORS) {
|
||||
if (payment[c] > need[c]) return '필요한 것보다 많이 낼 수는 없어요.';
|
||||
short += need[c] - payment[c];
|
||||
}
|
||||
if (payment.gold > short) return '필요한 것보다 많이 낼 수는 없어요.';
|
||||
if (payment.gold < short) return '보석이 부족해요.';
|
||||
return null;
|
||||
}
|
||||
|
||||
const satisfies = (bonuses: Gems, req: Gems) => COLORS.every((c) => bonuses[c] >= req[c]);
|
||||
const tokenCount = (ps: PlayerState) => sumTokens(ps.tokens);
|
||||
const isActive = (s: SplendorState, p: PlayerId) => !s.conceded.includes(p);
|
||||
const activePlayersOf = (s: SplendorState) => s.order.filter((p) => isActive(s, p));
|
||||
const currentPlayer = (s: SplendorState): PlayerId | null => (s.phase === 'finished' ? null : s.order[s.current]!);
|
||||
const compact = (t: Tokens): Partial<Tokens> => {
|
||||
const out: Partial<Tokens> = {};
|
||||
for (const c of TOKEN_COLORS) if (t[c]) out[c] = t[c];
|
||||
return out;
|
||||
};
|
||||
const buyKey = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`);
|
||||
|
||||
/** Colours that still have tokens, and how many "different colours" must be taken (0 = not possible). */
|
||||
function differentRule(s: SplendorState): { available: Color[]; count: number } {
|
||||
const available = COLORS.filter((c) => s.supply[c] > 0);
|
||||
if (available.length >= 3) return { available, count: 3 };
|
||||
return { available, count: s.options.allowFewerTokens ? available.length : 0 };
|
||||
}
|
||||
|
||||
function cardAt(s: SplendorState, p: PlayerId, from: BuyFrom): { id: string; reserved: boolean } | null {
|
||||
if ('reservedIndex' in from) {
|
||||
const r = s.players[p]!.reserved[from.reservedIndex];
|
||||
return r ? { id: r.cardId, reserved: true } : null;
|
||||
}
|
||||
const id = s.board[from.tier]?.[from.slot];
|
||||
return id ? { id, reserved: false } : null;
|
||||
}
|
||||
|
||||
/** Every card I could buy right now (board tier 1→3, left to right, then my reservations). */
|
||||
function buyables(s: SplendorState, p: PlayerId): { from: BuyFrom; card: DevCard; payment: Tokens }[] {
|
||||
const ps = s.players[p]!;
|
||||
const bonuses = bonusesOf(ps);
|
||||
const out: { from: BuyFrom; card: DevCard; payment: Tokens }[] = [];
|
||||
for (const tier of TIERS) {
|
||||
s.board[tier].forEach((id, slot) => {
|
||||
if (!id) return;
|
||||
const card = cardOf(id);
|
||||
const payment = autoPayment(card, ps, bonuses);
|
||||
if (payment) out.push({ from: { tier, slot }, card, payment });
|
||||
});
|
||||
}
|
||||
ps.reserved.forEach((r, i) => {
|
||||
const card = cardOf(r.cardId);
|
||||
const payment = autoPayment(card, ps, bonuses);
|
||||
if (payment) out.push({ from: { reservedIndex: i }, card, payment });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function reserveTargets(s: SplendorState, p: PlayerId): ReserveFrom[] {
|
||||
if (s.players[p]!.reserved.length >= MAX_RESERVED) return [];
|
||||
const out: ReserveFrom[] = [];
|
||||
for (const tier of TIERS) s.board[tier].forEach((id, slot) => id && out.push({ tier, slot }));
|
||||
for (const tier of TIERS) if (s.decks[tier].length) out.push({ tier, deck: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Can the player do any of the four actions (§5 "행동 불가")? */
|
||||
function canAct(s: SplendorState, p: PlayerId): boolean {
|
||||
if (differentRule(s).count > 0) return true;
|
||||
if (COLORS.some((c) => s.supply[c] >= 4)) return true;
|
||||
if (reserveTargets(s, p).length) return true;
|
||||
return buyables(s, p).length > 0;
|
||||
}
|
||||
|
||||
function deadlineFor(s: SplendorState, p: PlayerId, now: number): number | null {
|
||||
if (s.options.turnSeconds === null) return null;
|
||||
if ((s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER) return now + AWAY_MS;
|
||||
return now + s.options.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
type DistOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
||||
function pushLog(s: SplendorState, e: DistOmit<LogEntry, 'n'>): void {
|
||||
s.log.push({ ...e, n: ++s.logSeq } as LogEntry);
|
||||
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
|
||||
}
|
||||
|
||||
function refill(s: SplendorState, tier: Tier, slot: number, events: GameEvent[]): void {
|
||||
const next = s.decks[tier].shift() ?? null;
|
||||
s.board[tier][slot] = next;
|
||||
events.push({ type: 'refilled', tier, slot, cardId: next });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- turn steps (mutate a cloned state)
|
||||
|
||||
function doPlay(s: SplendorState, p: PlayerId, a: PlayAction, auto: boolean, events: GameEvent[]): void {
|
||||
const ps = s.players[p]!;
|
||||
s.passStreak = a.type === 'PASS' ? s.passStreak + 1 : 0;
|
||||
switch (a.type) {
|
||||
case 'TAKE_DIFFERENT':
|
||||
case 'TAKE_SAME': {
|
||||
const got = emptyTokens();
|
||||
const colors = a.type === 'TAKE_SAME' ? [a.color, a.color] : a.colors;
|
||||
for (const c of colors) {
|
||||
if (s.supply[c] <= 0) throw new Error('supply empty');
|
||||
s.supply[c]--;
|
||||
ps.tokens[c]++;
|
||||
got[c]++;
|
||||
}
|
||||
events.push({ type: 'tookTokens', player: p, tokens: compact(got) });
|
||||
pushLog(s, { k: 'take', p, tokens: compact(got), auto });
|
||||
break;
|
||||
}
|
||||
case 'RESERVE': {
|
||||
if (ps.reserved.length >= MAX_RESERVED) throw new Error('reserve limit');
|
||||
let cardId: string;
|
||||
const blind = 'deck' in a.from;
|
||||
if ('deck' in a.from) {
|
||||
const top = s.decks[a.from.tier].shift();
|
||||
if (!top) throw new Error('deck empty');
|
||||
cardId = top;
|
||||
} else {
|
||||
const id = s.board[a.from.tier][a.from.slot];
|
||||
if (!id) throw new Error('empty slot');
|
||||
cardId = id;
|
||||
}
|
||||
ps.reserved.push({ cardId, blind });
|
||||
const gold = s.supply.gold > 0;
|
||||
if (gold) {
|
||||
s.supply.gold--;
|
||||
ps.tokens.gold++;
|
||||
}
|
||||
events.push({ type: 'reserved', player: p, tier: a.from.tier, cardId: blind ? null : cardId, gold });
|
||||
pushLog(s, { k: 'reserve', p, tier: a.from.tier, cardId: blind ? null : cardId, gold, auto });
|
||||
if (!blind && 'slot' in a.from) refill(s, a.from.tier, a.from.slot, events);
|
||||
break;
|
||||
}
|
||||
case 'BUY': {
|
||||
const at = cardAt(s, p, a.from);
|
||||
if (!at) throw new Error('no card');
|
||||
const card = cardOf(at.id);
|
||||
const payment = a.payment ?? autoPayment(card, ps);
|
||||
if (!payment || paymentProblem(card, ps, payment)) throw new Error('cannot pay');
|
||||
for (const c of TOKEN_COLORS) {
|
||||
ps.tokens[c] -= payment[c];
|
||||
s.supply[c] += payment[c];
|
||||
}
|
||||
ps.cards.push(at.id);
|
||||
if ('reservedIndex' in a.from) ps.reserved.splice(a.from.reservedIndex, 1);
|
||||
events.push({ type: 'bought', player: p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved });
|
||||
pushLog(s, { k: 'buy', p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved, auto });
|
||||
if (!('reservedIndex' in a.from)) refill(s, a.from.tier, a.from.slot, events);
|
||||
break;
|
||||
}
|
||||
case 'PASS':
|
||||
events.push({ type: 'passed', player: p });
|
||||
pushLog(s, { k: 'pass', p, auto });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function doDiscard(s: SplendorState, p: PlayerId, tokens: Tokens, auto: boolean, events: GameEvent[]): void {
|
||||
const ps = s.players[p]!;
|
||||
for (const c of TOKEN_COLORS) {
|
||||
if (tokens[c] > ps.tokens[c]) throw new Error('discard more than held');
|
||||
ps.tokens[c] -= tokens[c];
|
||||
s.supply[c] += tokens[c];
|
||||
}
|
||||
if (tokenCount(ps) !== TOKEN_LIMIT) throw new Error('discard must leave exactly 10');
|
||||
events.push({ type: 'discarded', player: p, tokens: compact(tokens) });
|
||||
pushLog(s, { k: 'discard', p, tokens: compact(tokens), auto });
|
||||
}
|
||||
|
||||
function visitNoble(s: SplendorState, p: PlayerId, nobleId: string, events: GameEvent[]): void {
|
||||
const i = s.nobles.indexOf(nobleId);
|
||||
if (i < 0) throw new Error('noble not open');
|
||||
s.nobles.splice(i, 1);
|
||||
s.players[p]!.nobles.push(nobleId);
|
||||
events.push({ type: 'nobleVisited', player: p, nobleId });
|
||||
pushLog(s, { k: 'noble', p, nobleId });
|
||||
}
|
||||
|
||||
/** After an action (or a discard): token limit → noble → end of turn (§8.2 "차례 마무리 순서"). */
|
||||
function continueTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[], afterDiscard = false): void {
|
||||
const ps = s.players[p]!;
|
||||
if (!afterDiscard && tokenCount(ps) > TOKEN_LIMIT) {
|
||||
s.phase = 'discard';
|
||||
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
|
||||
return;
|
||||
}
|
||||
const bonuses = bonusesOf(ps);
|
||||
const eligible = s.nobles.filter((id) => satisfies(bonuses, nobleOf(id).req)).sort();
|
||||
if (eligible.length === 1) visitNoble(s, p, eligible[0]!, events);
|
||||
else if (eligible.length > 1) {
|
||||
s.phase = 'noble';
|
||||
s.pendingNobleChoices = eligible;
|
||||
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
|
||||
return;
|
||||
}
|
||||
endTurn(s, p, now, events);
|
||||
}
|
||||
|
||||
function endTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
|
||||
s.turnsTaken[p] = (s.turnsTaken[p] ?? 0) + 1;
|
||||
s.pendingNobleChoices = null;
|
||||
const pts = pointsOf(s.players[p]!);
|
||||
if (!s.endTriggered && pts >= s.options.targetPoints) {
|
||||
s.endTriggered = true;
|
||||
events.push({ type: 'endTriggered', player: p, points: pts });
|
||||
pushLog(s, { k: 'end', p, points: pts });
|
||||
}
|
||||
advance(s, now, events);
|
||||
}
|
||||
|
||||
function finish(s: SplendorState, reason: NonNullable<SplendorState['endReason']>, events: GameEvent[]): void {
|
||||
s.phase = 'finished';
|
||||
s.endReason = reason;
|
||||
s.deadlineAt = null;
|
||||
s.pendingNobleChoices = null;
|
||||
events.push({ type: 'gameOver', reason });
|
||||
}
|
||||
|
||||
/** Next active seat; the game ends after the last seat of a triggered round, on a full round of passes, or with one player left. */
|
||||
function advance(s: SplendorState, now: number, events: GameEvent[]): void {
|
||||
const active = activePlayersOf(s);
|
||||
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
|
||||
const n = s.order.length;
|
||||
const next = nextSeat(s.current, n, (i) => !isActive(s, s.order[i]!));
|
||||
if (s.endTriggered && next <= s.current) return finish(s, 'target', events);
|
||||
if (s.passStreak >= active.length) return finish(s, 'stalemate', events);
|
||||
s.current = next;
|
||||
s.phase = 'action';
|
||||
s.pendingNobleChoices = null;
|
||||
s.turnSeq++;
|
||||
s.deadlineAt = deadlineFor(s, s.order[next]!, now);
|
||||
events.push({ type: 'turnChanged', player: s.order[next]! });
|
||||
}
|
||||
|
||||
function concede(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
|
||||
const ps = s.players[p]!;
|
||||
for (const c of TOKEN_COLORS) {
|
||||
s.supply[c] += ps.tokens[c];
|
||||
ps.tokens[c] = 0;
|
||||
}
|
||||
s.removed.push(...ps.reserved);
|
||||
ps.reserved = [];
|
||||
s.conceded.push(p);
|
||||
events.push({ type: 'conceded', player: p });
|
||||
pushLog(s, { k: 'concede', p });
|
||||
const wasCurrent = s.order[s.current] === p;
|
||||
const active = activePlayersOf(s);
|
||||
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
|
||||
if (wasCurrent) {
|
||||
// Their turn ends without an action; passes so far still count only for players who are in.
|
||||
s.passStreak = 0;
|
||||
advance(s, now, events);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- automatic play (§8.5)
|
||||
|
||||
/** Deterministic default move: best buy → 3 different (most supply) → 2 same → reserve → pass. */
|
||||
export function autoPlay(s: SplendorState, p: PlayerId): PlayAction {
|
||||
const buys = buyables(s, p);
|
||||
if (buys.length) {
|
||||
let best = buys[0]!;
|
||||
for (const b of buys) {
|
||||
const better = b.card.points > best.card.points || (b.card.points === best.card.points && sumTokens(b.payment) < sumTokens(best.payment));
|
||||
if (better) best = b;
|
||||
}
|
||||
return { type: 'BUY', from: best.from };
|
||||
}
|
||||
const rule = differentRule(s);
|
||||
if (rule.count > 0) {
|
||||
// Stable sort keeps W,U,G,R,K order among equal supplies.
|
||||
const colors = [...rule.available].sort((a, b) => s.supply[b] - s.supply[a]).slice(0, rule.count);
|
||||
return { type: 'TAKE_DIFFERENT', colors };
|
||||
}
|
||||
const same = [...COLORS].sort((a, b) => s.supply[b] - s.supply[a]).find((c) => s.supply[c] >= 4);
|
||||
if (same) return { type: 'TAKE_SAME', color: same };
|
||||
const res = reserveTargets(s, p)[0];
|
||||
if (res) return { type: 'RESERVE', from: res };
|
||||
return { type: 'PASS' };
|
||||
}
|
||||
|
||||
const DISCARD_ORDER: Color[] = ['K', 'R', 'G', 'U', 'W'];
|
||||
/** One token at a time from the colour I hold most of (ties K,R,G,U,W), gold last. */
|
||||
export function autoDiscard(ps: PlayerState): Tokens {
|
||||
const left = { ...ps.tokens };
|
||||
const out = emptyTokens();
|
||||
let extra = sumTokens(left) - TOKEN_LIMIT;
|
||||
while (extra > 0) {
|
||||
let pick: TokenColor = 'gold';
|
||||
let most = 0;
|
||||
for (const c of DISCARD_ORDER) {
|
||||
if (left[c] > most) {
|
||||
most = left[c];
|
||||
pick = c;
|
||||
}
|
||||
}
|
||||
left[pick]--;
|
||||
out[pick]++;
|
||||
extra--;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function autoTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
|
||||
if (s.phase === 'action') {
|
||||
doPlay(s, p, autoPlay(s, p), true, events);
|
||||
continueTurn(s, p, now, events);
|
||||
}
|
||||
// Discard and noble choices of an automatic (or abandoned) turn are resolved right away.
|
||||
for (let guard = 0; guard < 3 && s.phase !== 'finished' && s.order[s.current] === p; guard++) {
|
||||
if (s.phase === 'discard') {
|
||||
doDiscard(s, p, autoDiscard(s.players[p]!), true, events);
|
||||
continueTurn(s, p, now, events, true);
|
||||
} else if (s.phase === 'noble') {
|
||||
visitNoble(s, p, [...s.pendingNobleChoices!].sort()[0]!, events);
|
||||
endTurn(s, p, now, events);
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- result
|
||||
|
||||
function rankRows(s: SplendorState): SplendorRankRow[] {
|
||||
const rows = s.order.map((p) => {
|
||||
const ps = s.players[p]!;
|
||||
return { player: p, points: pointsOf(ps), cards: ps.cards.length, nobles: ps.nobles.length, bonuses: bonusesOf(ps), conceded: s.conceded.includes(p) };
|
||||
});
|
||||
// Conceded players rank last; then points high → fewer cards (§6).
|
||||
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.points - a.points || a.cards - b.cards);
|
||||
const out: SplendorRankRow[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
const prev = out[i - 1];
|
||||
const tied = prev && prev.conceded === r.conceded && prev.points === r.points && prev.cards === r.cards;
|
||||
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildResult(s: SplendorState): SplendorResult {
|
||||
const ranking = rankRows(s);
|
||||
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
|
||||
const top = ranking[0]!;
|
||||
const detail = (r: SplendorRankRow) => `${r.points}점 (카드 ${r.cards}장${r.nobles ? `, 귀족 ${r.nobles}` : ''})`;
|
||||
let summary: string;
|
||||
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${detail(top)}`;
|
||||
else if (ranking.length > 1) summary = `1위 ${detail(top)} · 2위 ${ranking[1]!.points}점`;
|
||||
else summary = `${detail(top)}`;
|
||||
if (s.endReason === 'resign') summary = `다른 사람이 모두 기권했어요. ${summary}`;
|
||||
if (s.endReason === 'stalemate') summary = `더 할 수 있는 행동이 없어 끝났어요. ${summary}`;
|
||||
const revealedReserved: Record<PlayerId, string[]> = {};
|
||||
for (const p of s.order) revealedReserved[p] = s.players[p]!.reserved.map((r) => r.cardId);
|
||||
const reason: GameResult['reason'] = s.endReason === 'resign' ? (activePlayersOf(s).length === 0 ? 'abandoned' : 'resign') : 'normal';
|
||||
return { ranking, winners, revealedReserved, summary, reason, endReason: s.endReason };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view helpers
|
||||
|
||||
function meView(s: SplendorState, me: PlayerId): SplendorMeView {
|
||||
const ps = s.players[me]!;
|
||||
const myTurn = s.phase !== 'finished' && s.order[s.current] === me && isActive(s, me);
|
||||
const acting = myTurn && s.phase === 'action';
|
||||
const buy: Record<string, BuyOption> = {};
|
||||
if (isActive(s, me) && s.phase !== 'finished') {
|
||||
const bonuses = bonusesOf(ps);
|
||||
for (const b of buyables(s, me)) {
|
||||
buy[buyKey(b.from)] = { payment: b.payment, need: needFor(b.card, bonuses), gold: b.payment.gold, total: sumTokens(b.payment) };
|
||||
}
|
||||
}
|
||||
const rule = differentRule(s);
|
||||
const nobleNeed: Record<string, number> = {};
|
||||
if (s.options.showHints) {
|
||||
const bonuses = bonusesOf(ps);
|
||||
for (const id of s.nobles) nobleNeed[id] = COLORS.reduce((a, c) => a + Math.max(0, nobleOf(id).req[c] - bonuses[c]), 0);
|
||||
}
|
||||
return {
|
||||
id: me,
|
||||
myTurn,
|
||||
takeDifferent: acting && rule.count > 0 ? rule : null,
|
||||
takeSame: acting ? COLORS.filter((c) => s.supply[c] >= 4) : [],
|
||||
canReserve: acting && ps.reserved.length < MAX_RESERVED && reserveTargets(s, me).length > 0,
|
||||
reserveDecks: acting && ps.reserved.length < MAX_RESERVED ? TIERS.filter((t) => s.decks[t].length > 0) : [],
|
||||
goldOnReserve: s.supply.gold > 0,
|
||||
buy,
|
||||
canPass: acting && !canAct(s, me),
|
||||
discardCount: myTurn && s.phase === 'discard' ? tokenCount(ps) - TOKEN_LIMIT : 0,
|
||||
nobleChoices: myTurn && s.phase === 'noble' ? s.pendingNobleChoices : null,
|
||||
nobleNeed,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const splendor: GameDefinition<SplendorState, SplendorAction, SplendorView, SplendorOptions> = {
|
||||
id: 'splendor',
|
||||
nameKo: '보석 상인',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
defaultOptions: splendorOptionsSchema.parse({}),
|
||||
optionsSchema: splendorOptionsSchema,
|
||||
actionSchema: splendorActionSchema as z.ZodType<SplendorAction>,
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 2 || players.length > 4) throw new Error('splendor: 2~4 players');
|
||||
const order = rng.shuffle(players);
|
||||
const nobles = rng.shuffle(NOBLES.map((n) => n.id)).slice(0, players.length + 1);
|
||||
const decks = {} as Record<Tier, string[]>;
|
||||
const board = {} as Record<Tier, (string | null)[]>;
|
||||
for (const tier of TIERS) {
|
||||
const deck = rng.shuffle(CARDS.filter((c) => c.tier === tier).map((c) => c.id));
|
||||
board[tier] = deck.splice(0, BOARD_SLOTS);
|
||||
decks[tier] = deck;
|
||||
}
|
||||
const per = tokensPerColor(players.length);
|
||||
const supply: Tokens = { W: per, U: per, G: per, R: per, K: per, gold: 5 };
|
||||
const ps: Record<PlayerId, PlayerState> = {};
|
||||
for (const p of players) ps[p] = { tokens: emptyTokens(), cards: [], reserved: [], nobles: [] };
|
||||
const s: SplendorState = {
|
||||
options,
|
||||
order,
|
||||
current: 0,
|
||||
phase: 'action',
|
||||
endTriggered: false,
|
||||
endReason: null,
|
||||
supply,
|
||||
decks,
|
||||
board,
|
||||
nobles,
|
||||
players: ps,
|
||||
pendingNobleChoices: null,
|
||||
turnsTaken: Object.fromEntries(players.map((p) => [p, 0])),
|
||||
turnSeq: 1,
|
||||
deadlineAt: null,
|
||||
conceded: [],
|
||||
removed: [],
|
||||
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
|
||||
passStreak: 0,
|
||||
logSeq: 0,
|
||||
log: [],
|
||||
};
|
||||
s.deadlineAt = deadlineFor(s, order[0]!, now);
|
||||
return s;
|
||||
},
|
||||
|
||||
validate(s, actor, a): Validation {
|
||||
const ps = s.players[actor];
|
||||
if (!ps) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
|
||||
if (s.conceded.includes(actor)) return fail('이미 기권했어요.');
|
||||
if (a.type === 'CONCEDE') return ok;
|
||||
if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||
if (a.type === 'TIMEOUT') return ok;
|
||||
if (a.type === 'DISCARD') {
|
||||
if (s.phase !== 'discard') return fail('지금은 보석을 돌려놓을 때가 아니에요.');
|
||||
for (const c of TOKEN_COLORS) {
|
||||
const v = a.tokens[c];
|
||||
if (!Number.isInteger(v) || v < 0) return fail('잘못된 요청이에요.');
|
||||
if (v > ps.tokens[c]) return fail('가진 것보다 많이 돌려놓을 수 없어요.');
|
||||
}
|
||||
const extra = tokenCount(ps) - TOKEN_LIMIT;
|
||||
if (sumTokens(a.tokens) !== extra) return fail(`보석이 10개가 되도록 ${extra}개를 골라 주세요.`);
|
||||
return ok;
|
||||
}
|
||||
if (a.type === 'CHOOSE_NOBLE') {
|
||||
if (s.phase !== 'noble') return fail('지금은 귀족을 고를 때가 아니에요.');
|
||||
return s.pendingNobleChoices?.includes(a.nobleId) ? ok : fail('고를 수 없는 귀족이에요.');
|
||||
}
|
||||
if (s.phase === 'discard') return fail('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
|
||||
if (s.phase === 'noble') return fail('먼저 방문할 귀족을 골라 주세요.');
|
||||
switch (a.type) {
|
||||
case 'TAKE_DIFFERENT': {
|
||||
const colors = a.colors;
|
||||
const rule = differentRule(s);
|
||||
if (!Array.isArray(colors) || new Set(colors).size !== colors.length) return fail('서로 다른 색 3개를 골라 주세요.');
|
||||
if (colors.some((c) => !COLORS.includes(c))) return fail('서로 다른 색 3개를 골라 주세요.');
|
||||
if (colors.some((c) => s.supply[c] < 1)) return fail('남아 있지 않은 색이 있어요.');
|
||||
if (rule.count === 0) return fail('서로 다른 색 3개를 가져올 수 없어요.');
|
||||
if (colors.length !== rule.count) return fail(rule.count === 3 ? '서로 다른 색 3개를 골라 주세요.' : `남은 색이 ${rule.count}가지라 ${rule.count}개를 골라 주세요.`);
|
||||
return ok;
|
||||
}
|
||||
case 'TAKE_SAME':
|
||||
if (!COLORS.includes(a.color)) return fail('잘못된 요청이에요.');
|
||||
return s.supply[a.color] >= 4 ? ok : fail('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
|
||||
case 'RESERVE': {
|
||||
if (ps.reserved.length >= MAX_RESERVED) return fail('예약은 3장까지만 할 수 있어요.');
|
||||
if ('deck' in a.from) return s.decks[a.from.tier]?.length ? ok : fail('그 단계 더미에 카드가 없어요.');
|
||||
return s.board[a.from.tier]?.[a.from.slot] ? ok : fail('그 자리에 카드가 없어요.');
|
||||
}
|
||||
case 'BUY': {
|
||||
const at = cardAt(s, actor, a.from);
|
||||
if (!at) return fail('그 자리에 카드가 없어요.');
|
||||
const card = cardOf(at.id);
|
||||
if (a.payment) {
|
||||
const err = paymentProblem(card, ps, a.payment);
|
||||
return err ? fail(err) : ok;
|
||||
}
|
||||
return autoPayment(card, ps) ? ok : fail('보석이 부족해요.');
|
||||
}
|
||||
case 'PASS':
|
||||
return canAct(s, actor) ? fail('할 수 있는 행동이 있어요.') : ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
// The state is plain JSON by contract; this is much faster than structuredClone.
|
||||
const s = JSON.parse(JSON.stringify(prev)) as SplendorState;
|
||||
const events: GameEvent[] = [];
|
||||
if (a.type !== 'TIMEOUT' && a.type !== 'CONCEDE') s.timeoutsInRow[actor] = 0;
|
||||
switch (a.type) {
|
||||
case 'CONCEDE':
|
||||
concede(s, actor, now, events);
|
||||
break;
|
||||
case 'TIMEOUT': {
|
||||
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
|
||||
events.push({ type: 'timeoutAuto', player: actor });
|
||||
if (s.timeoutsInRow[actor] === AWAY_AFTER) pushLog(s, { k: 'away', p: actor });
|
||||
autoTurn(s, actor, now, events);
|
||||
break;
|
||||
}
|
||||
case 'DISCARD':
|
||||
if (s.phase !== 'discard') throw new Error('not in discard phase');
|
||||
doDiscard(s, actor, a.tokens, false, events);
|
||||
continueTurn(s, actor, now, events, true);
|
||||
break;
|
||||
case 'CHOOSE_NOBLE':
|
||||
if (s.phase !== 'noble' || !s.pendingNobleChoices?.includes(a.nobleId)) throw new Error('illegal noble');
|
||||
visitNoble(s, actor, a.nobleId, events);
|
||||
endTurn(s, actor, now, events);
|
||||
break;
|
||||
default:
|
||||
if (s.phase !== 'action') throw new Error('not in action phase');
|
||||
doPlay(s, actor, a, false, events);
|
||||
continueTurn(s, actor, now, events);
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const finished = s.phase === 'finished';
|
||||
const me = viewer && s.players[viewer] ? viewer : null;
|
||||
const board = {} as Record<Tier, (DevCard | null)[]>;
|
||||
const deckCounts = {} as Record<Tier, number>;
|
||||
for (const t of TIERS) {
|
||||
board[t] = s.board[t].map((id) => (id ? cardOf(id) : null));
|
||||
deckCounts[t] = s.decks[t].length;
|
||||
}
|
||||
const players: Record<PlayerId, SplendorPlayerView> = {};
|
||||
for (const p of s.order) {
|
||||
const ps = s.players[p]!;
|
||||
players[p] = {
|
||||
id: p,
|
||||
tokens: ps.tokens,
|
||||
tokenCount: tokenCount(ps),
|
||||
bonuses: bonusesOf(ps),
|
||||
cards: ps.cards,
|
||||
points: pointsOf(ps),
|
||||
nobles: ps.nobles,
|
||||
reservedCount: ps.reserved.length,
|
||||
reserved: ps.reserved.map((r): ReservedView => {
|
||||
const card = cardOf(r.cardId);
|
||||
return r.blind && p !== me && !finished ? { hidden: true, tier: card.tier } : { ...card, hidden: false, blind: r.blind };
|
||||
}),
|
||||
conceded: s.conceded.includes(p),
|
||||
away: (s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER,
|
||||
turnsTaken: s.turnsTaken[p] ?? 0,
|
||||
};
|
||||
}
|
||||
const cur = currentPlayer(s);
|
||||
const away = cur ? (s.timeoutsInRow[cur] ?? 0) >= AWAY_AFTER : false;
|
||||
const base = s.options.turnSeconds === null ? 0 : away ? AWAY_MS : s.options.turnSeconds * 1000;
|
||||
return {
|
||||
options: s.options,
|
||||
phase: s.phase,
|
||||
order: s.order,
|
||||
current: cur,
|
||||
turnSeq: s.turnSeq,
|
||||
deadlineAt: s.deadlineAt,
|
||||
turnMs: base + (s.phase === 'discard' || s.phase === 'noble' ? EXTRA_PHASE_MS : 0),
|
||||
endTriggered: s.endTriggered,
|
||||
supply: s.supply,
|
||||
board,
|
||||
deckCounts,
|
||||
nobles: s.nobles.map(nobleOf),
|
||||
players,
|
||||
me: me ? meView(s, me) : null,
|
||||
log: s.log,
|
||||
result: finished ? buildResult(s) : null,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
const cur = currentPlayer(s);
|
||||
return cur ? [cur] : [];
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
return s.phase === 'finished' ? null : s.deadlineAt;
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'TIMEOUT' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.phase === 'finished' || !s.players[player] || s.conceded.includes(player)) return null;
|
||||
return { type: 'CONCEDE' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (s.phase !== 'finished') return null;
|
||||
const r = buildResult(s);
|
||||
const groups = new Map<number, PlayerId[]>();
|
||||
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
|
||||
const scores: Record<PlayerId, number> = {};
|
||||
for (const row of r.ranking) scores[row.player] = row.points;
|
||||
return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason };
|
||||
},
|
||||
|
||||
/**
|
||||
* Test helper. Buying is preferred whenever possible (with a manual all-gold variant), so random games
|
||||
* make progress; otherwise every take / reserve, or PASS when nothing is possible. Concede is left out.
|
||||
*/
|
||||
legalActions(s, p) {
|
||||
if (s.phase === 'finished' || s.order[s.current] !== p || s.conceded.includes(p)) return [];
|
||||
const ps = s.players[p]!;
|
||||
if (s.phase === 'noble') return (s.pendingNobleChoices ?? []).map((nobleId) => ({ type: 'CHOOSE_NOBLE', nobleId }));
|
||||
if (s.phase === 'discard') return discardOptions(ps.tokens, tokenCount(ps) - TOKEN_LIMIT).map((tokens) => ({ type: 'DISCARD', tokens }));
|
||||
const out: SplendorAction[] = [];
|
||||
const buys = buyables(s, p);
|
||||
const bonuses = bonusesOf(ps);
|
||||
for (const b of buys) {
|
||||
out.push({ type: 'BUY', from: b.from });
|
||||
// Same card paid with as much gold as possible (manual payment path).
|
||||
const need = needFor(b.card, bonuses);
|
||||
const manual = { ...b.payment };
|
||||
for (const c of COLORS) {
|
||||
const swap = Math.min(manual[c], ps.tokens.gold - manual.gold);
|
||||
manual[c] -= swap;
|
||||
manual.gold += swap;
|
||||
}
|
||||
if (manual.gold !== b.payment.gold && COLORS.every((c) => manual[c] <= need[c])) out.push({ type: 'BUY', from: b.from, payment: manual });
|
||||
}
|
||||
if (out.length && s.turnSeq % 6 !== 0) return out;
|
||||
const rule = differentRule(s);
|
||||
if (rule.count > 0) for (const colors of combos(rule.available, rule.count)) out.push({ type: 'TAKE_DIFFERENT', colors });
|
||||
for (const c of COLORS) if (s.supply[c] >= 4) out.push({ type: 'TAKE_SAME', color: c });
|
||||
for (const from of reserveTargets(s, p)) out.push({ type: 'RESERVE', from });
|
||||
if (!out.length) out.push({ type: 'PASS' });
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Hidden from others: my blind reservations; hidden from everyone: deck contents, unused nobles. */
|
||||
secretsOf(s, player) {
|
||||
const out: string[] = [];
|
||||
if (s.phase !== 'finished') for (const r of s.players[player]?.reserved ?? []) if (r.blind) out.push(r.cardId);
|
||||
for (const r of s.removed) if (r.blind) out.push(r.cardId);
|
||||
for (const t of TIERS) out.push(...s.decks[t]);
|
||||
const seen = new Set([...s.nobles, ...Object.values(s.players).flatMap((x) => x.nobles)]);
|
||||
for (const n of NOBLES) if (!seen.has(n.id)) out.push(n.id);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
function combos<T>(arr: readonly T[], k: number): T[][] {
|
||||
if (k === 0) return [[]];
|
||||
const out: T[][] = [];
|
||||
arr.forEach((x, i) => {
|
||||
for (const rest of combos(arr.slice(i + 1), k - 1)) out.push([x, ...rest]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every way to give back `k` tokens from `held`. */
|
||||
function discardOptions(held: Tokens, k: number): Tokens[] {
|
||||
const out: Tokens[] = [];
|
||||
const cur = emptyTokens();
|
||||
const go = (i: number, left: number) => {
|
||||
if (left === 0) return void out.push({ ...cur });
|
||||
if (i >= TOKEN_COLORS.length) return;
|
||||
const c = TOKEN_COLORS[i]!;
|
||||
for (let v = Math.min(left, held[c]); v >= 0; v--) {
|
||||
cur[c] = v;
|
||||
go(i + 1, left - v);
|
||||
}
|
||||
cur[c] = 0;
|
||||
};
|
||||
go(0, k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Component totals for invariants: tokens per colour and cards / nobles accounted for. */
|
||||
export function componentCounts(s: SplendorState): { tokens: Tokens; cards: number; nobles: number } {
|
||||
const tokens = { ...s.supply };
|
||||
let cards = s.removed.length;
|
||||
for (const t of TIERS) cards += s.decks[t].length + s.board[t].filter(Boolean).length;
|
||||
let nobles = s.nobles.length;
|
||||
for (const p of s.order) {
|
||||
const ps = s.players[p]!;
|
||||
for (const c of TOKEN_COLORS) tokens[c] += ps.tokens[c];
|
||||
cards += ps.cards.length + ps.reserved.length;
|
||||
nobles += ps.nobles.length;
|
||||
}
|
||||
return { tokens, cards, nobles };
|
||||
}
|
||||
135
packages/games/src/splendor/splendor.playout.test.ts
Normal file
135
packages/games/src/splendor/splendor.playout.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/** Random playouts for 보석 상인 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine';
|
||||
import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index';
|
||||
|
||||
const CONFIGS: Partial<SplendorOptions>[] = [
|
||||
{},
|
||||
{ targetPoints: 10 },
|
||||
{ allowFewerTokens: false },
|
||||
{ turnSeconds: null, showHints: false },
|
||||
{ turnSeconds: 30, targetPoints: 10, allowFewerTokens: false },
|
||||
];
|
||||
|
||||
function conserved(state: unknown): void {
|
||||
const s = state as SplendorState;
|
||||
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
|
||||
const c = componentCounts(s);
|
||||
const want: Tokens = { W: n, U: n, G: n, R: n, K: n, gold: 5 };
|
||||
for (const k of [...COLORS, 'gold'] as const) if (c.tokens[k] !== want[k]) throw new Error(`token ${k}: ${c.tokens[k]} != ${want[k]}`);
|
||||
if (c.cards !== 90) throw new Error(`cards: ${c.cards}`);
|
||||
if (c.nobles !== s.order.length + 1) throw new Error(`nobles: ${c.nobles}`);
|
||||
for (const p of s.order) {
|
||||
const ps = s.players[p]!;
|
||||
if (ps.reserved.length > 3) throw new Error('more than 3 reservations');
|
||||
for (const k of [...COLORS, 'gold'] as const) if (ps.tokens[k] < 0) throw new Error('negative tokens');
|
||||
const total = Object.values(ps.tokens).reduce((a, b) => a + b, 0);
|
||||
if (total > 10 && !(s.phase === 'discard' && s.order[s.current] === p)) throw new Error(`${p} holds ${total} tokens outside the discard phase`);
|
||||
}
|
||||
for (const t of TIERS) if (s.board[t].length !== 4) throw new Error('board must keep 4 slots');
|
||||
}
|
||||
|
||||
const ID_RE = /"(?:[123][WUGRK]\d\d|N\d\d)"/g;
|
||||
|
||||
/** Who may see each hidden id: '*' = nobody, otherwise the owner of a blind reservation. */
|
||||
function hiddenOwners(s: SplendorState): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const t of TIERS) for (const id of s.decks[t]) m.set(id, '*');
|
||||
for (const r of s.removed) if (r.blind) m.set(r.cardId, '*');
|
||||
const seen = new Set([...s.nobles, ...s.order.flatMap((p) => s.players[p]!.nobles)]);
|
||||
for (const n of NOBLES) if (!seen.has(n.id)) m.set(n.id, '*');
|
||||
if (s.phase !== 'finished') for (const p of s.order) for (const r of s.players[p]!.reserved) if (r.blind) m.set(r.cardId, p);
|
||||
return m;
|
||||
}
|
||||
|
||||
function run(seed: number): { state: SplendorState; steps: number } {
|
||||
const options = splendor.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]);
|
||||
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3));
|
||||
const rng = SeededRng.fromSeed(seedN(seed));
|
||||
const chooser = SeededRng.fromSeed(seedN(seed + 700_000));
|
||||
let now = 1_700_000_000_000;
|
||||
let s = splendor.setup({ players, options, rng, now });
|
||||
const concedeAt = seed % 13 === 0 ? 30 + (seed % 40) : -1;
|
||||
for (let step = 0; step < 2000; step++) {
|
||||
if (step % 10 === 0) {
|
||||
const json = JSON.stringify(s);
|
||||
if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable');
|
||||
}
|
||||
conserved(s);
|
||||
const owners = hiddenOwners(s);
|
||||
for (const viewer of [...players, null]) {
|
||||
const ids = JSON.stringify(splendor.view(s, viewer)).match(ID_RE) ?? [];
|
||||
for (const q of ids) {
|
||||
const o = owners.get(q.slice(1, -1));
|
||||
if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} (${o})`);
|
||||
}
|
||||
}
|
||||
if (splendor.result(s)) return { state: s, steps: step };
|
||||
const waiting = splendor.activePlayers(s);
|
||||
if (waiting.length !== 1) throw new Error('exactly one player should be waited for');
|
||||
for (const p of waiting) {
|
||||
const v = splendor.validate(s, p, splendor.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`);
|
||||
}
|
||||
let actor = waiting[0]!;
|
||||
let a: SplendorAction;
|
||||
if (step === concedeAt) {
|
||||
const inGame = players.filter((p) => !s.conceded.includes(p));
|
||||
actor = inGame[chooser.int(inGame.length)]!;
|
||||
a = splendor.onLeave!(s, actor)!;
|
||||
} else {
|
||||
const legal = splendor.legalActions!(s, actor);
|
||||
a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : splendor.onTimeout(s, actor);
|
||||
}
|
||||
const v = splendor.validate(s, actor, a);
|
||||
if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`);
|
||||
now += 1000;
|
||||
const t0 = performance.now();
|
||||
const out = splendor.apply(s, actor, a, { rng, now });
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 20) throw new Error(`apply took ${ms.toFixed(1)}ms`);
|
||||
const after = hiddenOwners(out.state);
|
||||
for (const q of JSON.stringify(out.events).match(ID_RE) ?? []) if (after.has(q.slice(1, -1))) throw new Error(`seed ${seed}: event leaks ${q}`);
|
||||
s = out.state;
|
||||
}
|
||||
throw new Error(`seed ${seed}: not finished within 2000 steps`);
|
||||
}
|
||||
|
||||
describe('random playouts (docs/09 §7)', () => {
|
||||
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, invariants, determinism)', () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const options = splendor.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
|
||||
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
|
||||
const a = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, invariant: conserved, maxApplyMs: 20 });
|
||||
expect(a.finished).toBe(true);
|
||||
if (i < 2) {
|
||||
const b = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, maxApplyMs: 20 });
|
||||
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => {
|
||||
const ends: Record<string, number> = {};
|
||||
let maxSteps = 0;
|
||||
for (let seed = 1; seed <= 1000; seed++) {
|
||||
const { state, steps } = run(seed);
|
||||
maxSteps = Math.max(maxSteps, steps);
|
||||
const res = splendor.result(state)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...state.order].sort());
|
||||
expect(res.summary.length).toBeGreaterThan(0);
|
||||
const k = state.endReason ?? 'none';
|
||||
ends[k] = (ends[k] ?? 0) + 1;
|
||||
if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state));
|
||||
if (state.endReason === 'target') {
|
||||
// Everyone in the game had the same number of turns (the round was played to its end).
|
||||
const inGame = state.order.filter((p: PlayerId) => !state.conceded.includes(p));
|
||||
const turns = new Set(inGame.map((p) => state.turnsTaken[p]));
|
||||
if (!state.conceded.length) expect(turns.size).toBe(1);
|
||||
}
|
||||
}
|
||||
expect(ends.target ?? 0).toBeGreaterThan(900);
|
||||
expect(ends.resign ?? 0).toBeGreaterThan(0);
|
||||
expect(maxSteps).toBeLessThan(2000);
|
||||
}, 60_000);
|
||||
});
|
||||
753
packages/games/src/splendor/splendor.test.ts
Normal file
753
packages/games/src/splendor/splendor.test.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SeededRng, seedN, type GameEvent, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
CARDS,
|
||||
COLORS,
|
||||
NOBLES,
|
||||
TIERS,
|
||||
cardOf,
|
||||
componentCounts,
|
||||
emptyTokens,
|
||||
pointsOf,
|
||||
splendor,
|
||||
splendorActionSchema,
|
||||
type Color,
|
||||
type SplendorAction,
|
||||
type SplendorOptions,
|
||||
type SplendorState,
|
||||
type Tier,
|
||||
type Tokens,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
let clock = 1_000_000;
|
||||
const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||
|
||||
function mk(players = ['A', 'B'], opts: Partial<SplendorOptions> = {}, seed = 1): SplendorState {
|
||||
const options = splendor.optionsSchema.parse(opts);
|
||||
return splendor.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
|
||||
}
|
||||
|
||||
/** Fixes the turn order (setup shuffles it) and gives the turn to the first player. */
|
||||
function inOrder(s: SplendorState, order: PlayerId[]): SplendorState {
|
||||
s.order = [...order];
|
||||
s.current = 0;
|
||||
return s;
|
||||
}
|
||||
|
||||
function act(s: SplendorState, who: PlayerId, a: SplendorAction): SplendorState {
|
||||
return actE(s, who, a).state;
|
||||
}
|
||||
function actE(s: SplendorState, who: PlayerId, a: SplendorAction): { state: SplendorState; events: GameEvent[] } {
|
||||
const v = splendor.validate(s, who, a);
|
||||
if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`);
|
||||
clock += 1000;
|
||||
const out = splendor.apply(s, who, a, { rng: RNG(), now: clock });
|
||||
conserved(out.state);
|
||||
return out;
|
||||
}
|
||||
function reason(s: SplendorState, who: PlayerId, a: SplendorAction): string {
|
||||
const v = splendor.validate(s, who, a);
|
||||
if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`);
|
||||
return v.reason;
|
||||
}
|
||||
const cur = (s: SplendorState) => s.order[s.current]!;
|
||||
const tk = (t: Partial<Tokens>): Tokens => ({ ...emptyTokens(), ...t });
|
||||
|
||||
/** Removes a card from wherever it is (deck or board, refilling the board slot from the deck). */
|
||||
function pull(s: SplendorState, id: string): void {
|
||||
const tier = cardOf(id).tier;
|
||||
const di = s.decks[tier].indexOf(id);
|
||||
if (di >= 0) return void s.decks[tier].splice(di, 1);
|
||||
const bi = s.board[tier].indexOf(id);
|
||||
if (bi < 0) throw new Error(`card ${id} not available`);
|
||||
s.board[tier][bi] = s.decks[tier].shift() ?? null;
|
||||
}
|
||||
function giveCards(s: SplendorState, p: PlayerId, ids: string[]): void {
|
||||
for (const id of ids) {
|
||||
pull(s, id);
|
||||
s.players[p]!.cards.push(id);
|
||||
}
|
||||
}
|
||||
/** Moves tokens supply → player (keeps the totals). */
|
||||
function giveTokens(s: SplendorState, p: PlayerId, t: Partial<Tokens>): void {
|
||||
for (const [c, n] of Object.entries(t) as [keyof Tokens, number][]) {
|
||||
if (s.supply[c] < n) throw new Error(`supply short of ${c}`);
|
||||
s.supply[c] -= n;
|
||||
s.players[p]!.tokens[c] += n;
|
||||
}
|
||||
}
|
||||
/** Puts card `id` face up at board slot (tier, slot); the card that was there goes back to the deck top. */
|
||||
function place(s: SplendorState, id: string, slot = 0): void {
|
||||
const tier = cardOf(id).tier;
|
||||
pull(s, id);
|
||||
const old = s.board[tier][slot];
|
||||
if (old) s.decks[tier].unshift(old);
|
||||
s.board[tier][slot] = id;
|
||||
}
|
||||
/** Moves supply tokens of one colour away to a bystander so the supply shows `n`. */
|
||||
function setSupply(s: SplendorState, c: Color | 'gold', n: number, holder: PlayerId): void {
|
||||
const d = s.supply[c] - n;
|
||||
if (d < 0) throw new Error('can only lower the supply');
|
||||
s.supply[c] = n;
|
||||
s.players[holder]!.tokens[c] += d;
|
||||
}
|
||||
|
||||
function initialTokens(s: SplendorState): Tokens {
|
||||
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
|
||||
return { W: n, U: n, G: n, R: n, K: n, gold: 5 };
|
||||
}
|
||||
function conserved(s: SplendorState): void {
|
||||
const c = componentCounts(s);
|
||||
expect(c.tokens).toEqual(initialTokens(s));
|
||||
expect(c.cards).toBe(90);
|
||||
expect(c.nobles).toBe(s.order.length + 1);
|
||||
for (const p of s.order) for (const k of [...COLORS, 'gold'] as const) expect(s.players[p]!.tokens[k]).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup & data
|
||||
|
||||
describe('셋업 (§4)', () => {
|
||||
test.each([
|
||||
[2, 4, 3, 36, 26, 16],
|
||||
[3, 5, 4, 36, 26, 16],
|
||||
[4, 7, 5, 36, 26, 16],
|
||||
])('%i players: %i tokens per colour, gold 5, %i nobles, 12 open cards, decks %i/%i/%i', (n, per, nobles, d1, d2, d3) => {
|
||||
const s = mk(['A', 'B', 'C', 'D'].slice(0, n));
|
||||
expect(s.supply).toEqual({ W: per, U: per, G: per, R: per, K: per, gold: 5 });
|
||||
expect(s.nobles).toHaveLength(nobles);
|
||||
expect(TIERS.map((t) => s.board[t].filter(Boolean).length)).toEqual([4, 4, 4]);
|
||||
expect([s.decks[1].length, s.decks[2].length, s.decks[3].length]).toEqual([d1, d2, d3]);
|
||||
expect([...s.order].sort()).toEqual(['A', 'B', 'C', 'D'].slice(0, n));
|
||||
expect(s.phase).toBe('action');
|
||||
expect(s.deadlineAt).toBe(clock + 60_000);
|
||||
conserved(s);
|
||||
});
|
||||
|
||||
test('turn order, nobles and decks come from the seeded RNG', () => {
|
||||
const a = mk(['A', 'B', 'C', 'D'], {}, 3);
|
||||
const b = mk(['A', 'B', 'C', 'D'], {}, 3);
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
const differs = [4, 5, 6, 7].some((seed) => JSON.stringify(mk(['A', 'B', 'C', 'D'], {}, seed)) !== JSON.stringify(a));
|
||||
expect(differs).toBe(true);
|
||||
});
|
||||
|
||||
test('player count is limited to 2~4', () => {
|
||||
expect(() => mk(['A'])).toThrow();
|
||||
expect(() => mk(['A', 'B', 'C', 'D', 'E'])).toThrow();
|
||||
expect([splendor.minPlayers, splendor.maxPlayers]).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('카드 데이터 (§3.1, §3.2)', () => {
|
||||
test('90 cards: 40/30/20, 8/6/4 per colour per tier, points as in §3.1', () => {
|
||||
expect(CARDS).toHaveLength(90);
|
||||
expect(new Set(CARDS.map((c) => c.id)).size).toBe(90);
|
||||
const per = (t: Tier) => CARDS.filter((c) => c.tier === t);
|
||||
expect(TIERS.map((t) => per(t).length)).toEqual([40, 30, 20]);
|
||||
for (const t of TIERS) for (const c of COLORS) expect(per(t).filter((x) => x.bonus === c)).toHaveLength([8, 6, 4][t - 1]!);
|
||||
const dist = (t: Tier) => {
|
||||
const d: Record<number, number> = {};
|
||||
for (const c of per(t)) d[c.points] = (d[c.points] ?? 0) + 1;
|
||||
return d;
|
||||
};
|
||||
expect(dist(1)).toEqual({ 0: 35, 1: 5 });
|
||||
expect(dist(2)).toEqual({ 1: 10, 2: 15, 3: 5 });
|
||||
expect(dist(3)).toEqual({ 3: 5, 4: 10, 5: 5 });
|
||||
});
|
||||
|
||||
test('the card and noble tables match the spec document row by row (snapshot of §3.1 / §3.2)', () => {
|
||||
const doc = readFileSync(join(import.meta.dir, '../../../../docs/games/splendor.md'), 'utf8');
|
||||
const num = (x: string) => (x === '·' ? 0 : Number(x));
|
||||
const rows = [...doc.matchAll(/^\| ([123][WUGRK]\d\d) \| . \| (\d) \| (.) \| (.) \| (.) \| (.) \| (.) \| (\d+) \|$/gm)];
|
||||
expect(rows).toHaveLength(90);
|
||||
for (const m of rows) {
|
||||
const card = cardOf(m[1]!);
|
||||
expect(card.points).toBe(Number(m[2]));
|
||||
expect(card.cost).toEqual({ W: num(m[3]!), U: num(m[4]!), G: num(m[5]!), R: num(m[6]!), K: num(m[7]!) });
|
||||
expect(COLORS.reduce((a, c) => a + card.cost[c], 0)).toBe(Number(m[8]));
|
||||
}
|
||||
const nobles = [...doc.matchAll(/^\| (N\d\d) \| (.) \| (.) \| (.) \| (.) \| (.) \|$/gm)];
|
||||
expect(nobles).toHaveLength(10);
|
||||
expect(NOBLES).toHaveLength(10);
|
||||
for (const m of nobles) {
|
||||
const n = NOBLES.find((x) => x.id === m[1])!;
|
||||
expect(n.points).toBe(3);
|
||||
expect(n.req).toEqual({ W: num(m[2]!), U: num(m[3]!), G: num(m[4]!), R: num(m[5]!), K: num(m[6]!) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- taking tokens
|
||||
|
||||
describe('보석 가져오기 (§5)', () => {
|
||||
test('TAKE_SAME: allowed with 4 in the supply, refused with 3', () => {
|
||||
const s = inOrder(mk(), ['A', 'B']);
|
||||
const s2 = act(s, 'A', { type: 'TAKE_SAME', color: 'R' });
|
||||
expect(s2.players.A!.tokens.R).toBe(2);
|
||||
expect(s2.supply.R).toBe(2);
|
||||
const t = inOrder(mk(), ['A', 'B']);
|
||||
setSupply(t, 'R', 3, 'B');
|
||||
expect(reason(t, 'A', { type: 'TAKE_SAME', color: 'R' })).toBe('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
|
||||
});
|
||||
|
||||
test('TAKE_DIFFERENT: duplicates and gold are refused', () => {
|
||||
const s = inOrder(mk(), ['A', 'B']);
|
||||
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
||||
expect(splendorActionSchema.safeParse({ type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold'] }).success).toBe(false);
|
||||
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold' as Color] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
||||
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
||||
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
|
||||
expect(s2.players.A!.tokens).toEqual(tk({ W: 1, U: 1, K: 1 }));
|
||||
});
|
||||
|
||||
test('only 2 colours left: 2 allowed with allowFewerTokens, refused without; 3 left but only 2 taken → refused', () => {
|
||||
const s = inOrder(mk(), ['A', 'B']);
|
||||
setSupply(s, 'W', 0, 'B');
|
||||
setSupply(s, 'U', 0, 'B');
|
||||
setSupply(s, 'G', 0, 'B');
|
||||
expect(act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] }).players.A!.tokens).toEqual(tk({ R: 1, K: 1 }));
|
||||
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R'] })).toBe('남은 색이 2가지라 2개를 골라 주세요.');
|
||||
expect(splendor.view(s, 'A').me!.takeDifferent).toEqual({ available: ['R', 'K'], count: 2 });
|
||||
const strict = inOrder(mk(['A', 'B'], { allowFewerTokens: false }), ['A', 'B']);
|
||||
for (const c of ['W', 'U', 'G'] as const) setSupply(strict, c, 0, 'B');
|
||||
expect(reason(strict, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 가져올 수 없어요.');
|
||||
expect(splendor.view(strict, 'A').me!.takeDifferent).toBeNull();
|
||||
const three = inOrder(mk(), ['A', 'B']);
|
||||
setSupply(three, 'W', 0, 'B');
|
||||
setSupply(three, 'U', 0, 'B');
|
||||
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
||||
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'R', 'K'] })).toBe('남아 있지 않은 색이 있어요.');
|
||||
});
|
||||
|
||||
test('only the current player may act', () => {
|
||||
const s = inOrder(mk(), ['A', 'B']);
|
||||
expect(reason(s, 'B', { type: 'TAKE_SAME', color: 'W' })).toBe('지금은 내 차례가 아니에요.');
|
||||
expect(reason(s, 'Z', { type: 'TAKE_SAME', color: 'W' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- reserving
|
||||
|
||||
describe('예약 (§5)', () => {
|
||||
test('reserving an open card gives a gold token and refills the slot from the deck', () => {
|
||||
const s = inOrder(mk(), ['A', 'B']);
|
||||
const id = s.board[2][1]!;
|
||||
const top = s.decks[2][0]!;
|
||||
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 1 } });
|
||||
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: false }]);
|
||||
expect(state.players.A!.tokens.gold).toBe(1);
|
||||
expect(state.board[2][1]).toBe(top);
|
||||
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ cardId: id, gold: true });
|
||||
expect(events.find((e) => e.type === 'refilled')).toMatchObject({ tier: 2, slot: 1, cardId: top });
|
||||
});
|
||||
|
||||
test('a fourth reservation is refused; with no gold left reserving still works (without gold)', () => {
|
||||
let s = inOrder(mk(), ['A', 'B']);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
|
||||
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
if (s.phase === 'discard') s = act(s, 'B', { type: 'TIMEOUT' });
|
||||
}
|
||||
expect(s.players.A!.reserved).toHaveLength(3);
|
||||
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } })).toBe('예약은 3장까지만 할 수 있어요.');
|
||||
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('예약은 3장까지만 할 수 있어요.');
|
||||
const t = inOrder(mk(), ['A', 'B']);
|
||||
setSupply(t, 'gold', 0, 'B');
|
||||
const t2 = act(t, 'A', { type: 'RESERVE', from: { tier: 3, slot: 0 } });
|
||||
expect(t2.players.A!.reserved).toHaveLength(1);
|
||||
expect(t2.players.A!.tokens.gold).toBe(0);
|
||||
expect(splendor.view(t, 'A').me!.goldOnReserve).toBe(false);
|
||||
});
|
||||
|
||||
test('a blind deck reservation: only the owner sees the card; others and spectators get the tier only; events carry no id', () => {
|
||||
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
const id = s.decks[3][0]!;
|
||||
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } });
|
||||
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: true }]);
|
||||
expect(state.board[3]).toEqual(s.board[3]);
|
||||
expect(splendor.view(state, 'A').players.A!.reserved[0]).toMatchObject({ id, hidden: false, blind: true });
|
||||
for (const viewer of ['B', 'C', null]) {
|
||||
const v = splendor.view(state, viewer);
|
||||
expect(v.players.A!.reserved).toEqual([{ hidden: true, tier: 3 }]);
|
||||
expect(v.players.A!.reservedCount).toBe(1);
|
||||
expect(JSON.stringify(v)).not.toContain(`"${id}"`);
|
||||
}
|
||||
expect(JSON.stringify(events)).not.toContain(`"${id}"`);
|
||||
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ tier: 3, cardId: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- buying
|
||||
|
||||
/** §5.1 position: bonuses W2 U0 G1 R0 K1, tokens W0 U2 G1 R0 K0 gold 1. */
|
||||
function example(): SplendorState {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveCards(s, 'A', ['1W07', '1W06', '1G07', '1K07']); // bonuses W2 G1 K1, 0 points
|
||||
giveTokens(s, 'A', { U: 2, G: 1, gold: 1 });
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('구매 (§5, §5.1)', () => {
|
||||
test('example 1: 2W04 (R5 K3) needs 7 tokens → cannot buy', () => {
|
||||
const s = example();
|
||||
place(s, '2W04', 0);
|
||||
expect(reason(s, 'A', { type: 'BUY', from: { tier: 2, slot: 0 } })).toBe('보석이 부족해요.');
|
||||
expect(splendor.view(s, 'A').me!.buy['b2-0']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('example 2: 1U05 (W1 K2) → W covered by bonus, K 1 paid with gold; gold 0 afterwards', () => {
|
||||
const s = example();
|
||||
place(s, '1U05', 2);
|
||||
const v = splendor.view(s, 'A').me!;
|
||||
expect(v.buy['b1-2']).toMatchObject({ gold: 1, total: 1, need: { W: 0, U: 0, G: 0, R: 0, K: 1 } });
|
||||
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 2 } });
|
||||
expect(state.players.A!.tokens.gold).toBe(0);
|
||||
expect(state.players.A!.cards).toContain('1U05');
|
||||
expect(state.supply.gold).toBe(5);
|
||||
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: '1U05', paid: { gold: 1 } });
|
||||
});
|
||||
|
||||
test('bonuses that cover the whole cost: the card is free', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveCards(s, 'A', ['1U07', '1U06', '1U01']); // three blue bonuses
|
||||
place(s, '1W07', 0); // cost U3
|
||||
const s2 = act(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
|
||||
expect(s2.players.A!.tokens).toEqual(emptyTokens());
|
||||
expect(s2.players.A!.cards).toContain('1W07');
|
||||
});
|
||||
|
||||
test('manual payment: gold instead of colour tokens is allowed; paying more than needed is refused', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveTokens(s, 'A', { R: 3, gold: 2 });
|
||||
place(s, '1G07', 1); // cost R3
|
||||
const slot = { tier: 1 as const, slot: 1 };
|
||||
const s2 = act(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, gold: 2 }) });
|
||||
expect(s2.players.A!.tokens).toEqual(tk({ R: 2 }));
|
||||
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 3, gold: 1 }) })).toBe('필요한 것보다 많이 낼 수는 없어요.');
|
||||
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 2 }) })).toBe('보석이 부족해요.');
|
||||
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, U: 1, gold: 1 }) })).toBe('보석이 부족해요.');
|
||||
});
|
||||
|
||||
test('buying a reserved card reveals it; the board is not refilled', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
pull(s, '1R07'); // cost W3
|
||||
s.decks[1].unshift('1R07');
|
||||
const id = '1R07';
|
||||
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
||||
s = act(s, 'B', { type: 'TAKE_SAME', color: 'G' });
|
||||
giveTokens(s, 'A', { W: 3 });
|
||||
const board = JSON.stringify(s.board);
|
||||
const { state, events } = actE(s, 'A', { type: 'BUY', from: { reservedIndex: 0 } });
|
||||
expect(state.players.A!.cards).toEqual([id]);
|
||||
expect(state.players.A!.reserved).toEqual([]);
|
||||
expect(JSON.stringify(state.board)).toBe(board);
|
||||
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: id, fromReserve: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- token limit
|
||||
|
||||
describe('토큰 한도 (§5)', () => {
|
||||
test('9 tokens + 3 → discard phase; anything but exactly 10 left is refused', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
||||
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
||||
expect(s2.phase).toBe('discard');
|
||||
expect(cur(s2)).toBe('A');
|
||||
expect(splendor.view(s2, 'A').me!.discardCount).toBe(2);
|
||||
expect(reason(s2, 'A', { type: 'TAKE_SAME', color: 'U' })).toBe('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
|
||||
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 1 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
|
||||
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 3 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
|
||||
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ gold: 2 }) })).toBe('가진 것보다 많이 돌려놓을 수 없어요.');
|
||||
// A token taken just now may go back.
|
||||
const s3 = act(s2, 'A', { type: 'DISCARD', tokens: tk({ R: 1, K: 1 }) });
|
||||
expect(s3.players.A!.tokens).toEqual(tk({ W: 4, U: 3, G: 3 }));
|
||||
expect(cur(s3)).toBe('B');
|
||||
expect(s3.phase).toBe('action');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- nobles
|
||||
|
||||
describe('귀족 (§5)', () => {
|
||||
test('two nobles satisfied at once → choose one; the other comes automatically at the end of the next turn', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
s.nobles = ['N06', 'N07', 'N10']; // U3 G3 R3 / W3 U3 G3 / G3 R3 K3
|
||||
// Keep the noble total (3 open for 2 players): swap in from whatever setup chose.
|
||||
giveCards(s, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06']); // W3 U3 G2
|
||||
place(s, '1G05', 0); // cost W2 U1 → free with bonuses; makes G3 → N07 (W3 U3 G3)
|
||||
giveCards(s, 'A', ['1R07', '1R06', '1R05']); // R3 → with U3 G3 also N06
|
||||
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
|
||||
expect(events.some((e) => e.type === 'nobleVisited')).toBe(false);
|
||||
expect(state.phase).toBe('noble');
|
||||
expect(state.pendingNobleChoices).toEqual(['N06', 'N07']);
|
||||
expect(splendor.view(state, 'A').me!.nobleChoices).toEqual(['N06', 'N07']);
|
||||
expect(reason(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N10' })).toBe('고를 수 없는 귀족이에요.');
|
||||
s = act(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N07' });
|
||||
expect(s.players.A!.nobles).toEqual(['N07']);
|
||||
expect(pointsOf(s.players.A!)).toBe(3);
|
||||
expect(cur(s)).toBe('B');
|
||||
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
const out = actE(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
|
||||
expect(out.state.players.A!.nobles).toEqual(['N07', 'N06']);
|
||||
expect(out.events.find((e) => e.type === 'nobleVisited')).toMatchObject({ player: 'A', nobleId: 'N06' });
|
||||
expect(out.state.nobles).toEqual(['N10']);
|
||||
});
|
||||
|
||||
test('hints: bonuses still missing per noble (only with showHints)', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
s.nobles = ['N01', 'N06', 'N10'];
|
||||
giveCards(s, 'A', ['1G07', '1R07']);
|
||||
expect(splendor.view(s, 'A').me!.nobleNeed).toEqual({ N01: 6, N06: 7, N10: 7 });
|
||||
const off = inOrder(mk(['A', 'B'], { showHints: false }), ['A', 'B']);
|
||||
expect(splendor.view(off, 'A').me!.nobleNeed).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- end & ranking
|
||||
|
||||
describe('종료와 순위 (§6)', () => {
|
||||
test('the 2nd player reaches 15 → the 3rd and 4th still play, then the game ends', () => {
|
||||
let s = inOrder(mk(['A', 'B', 'C', 'D']), ['A', 'B', 'C', 'D']);
|
||||
giveCards(s, 'B', ['3W04', '3U04', '3G04']); // 15 points, bonuses W1 U1 G1
|
||||
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
const out = actE(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
expect(out.events.find((e) => e.type === 'endTriggered')).toMatchObject({ player: 'B', points: 15 });
|
||||
s = out.state;
|
||||
expect(s.endTriggered).toBe(true);
|
||||
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
expect(s.phase).toBe('action');
|
||||
expect(cur(s)).toBe('D');
|
||||
s = act(s, 'D', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
|
||||
expect(s.phase).toBe('finished');
|
||||
const r = splendor.result(s)!;
|
||||
expect(r.ranking[0]).toEqual(['B']);
|
||||
expect(r.reason).toBe('normal');
|
||||
expect(r.summary).toBe('1위 15점 (카드 3장) · 2위 0점');
|
||||
});
|
||||
|
||||
test('the player just before the first player reaches 15 → the game ends at once', () => {
|
||||
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
giveCards(s, 'C', ['3W04', '3U04', '3G04']);
|
||||
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(s.turnsTaken).toEqual({ A: 1, B: 1, C: 1 });
|
||||
});
|
||||
|
||||
test('targetPoints=10 ends at 10 points', () => {
|
||||
let s = inOrder(mk(['A', 'B'], { targetPoints: 10 }), ['A', 'B']);
|
||||
giveCards(s, 'B', ['3W04', '3U04']);
|
||||
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
expect(s.phase).toBe('finished');
|
||||
});
|
||||
|
||||
test('ties: same points → fewer cards wins; same points and cards → shared rank (A 16/14, B 16/12, C 15)', () => {
|
||||
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
const zeros = CARDS.filter((c) => c.tier === 1 && c.points === 0).map((c) => c.id);
|
||||
giveCards(s, 'A', ['3W04', '3U04', '3G04', '1W08', ...zeros.slice(0, 10)]);
|
||||
giveCards(s, 'B', ['3R04', '3K04', '3W03', '1U08', '1G08', ...zeros.slice(10, 17)]);
|
||||
giveCards(s, 'C', ['3U03', '3G03', '3R03', '2W06']);
|
||||
expect(['A', 'B', 'C'].map((p) => [pointsOf(s.players[p]!), s.players[p]!.cards.length])).toEqual([
|
||||
[16, 14],
|
||||
[16, 12],
|
||||
[15, 4],
|
||||
]);
|
||||
s.phase = 'finished';
|
||||
s.endReason = 'target';
|
||||
expect(splendor.result(s)!.ranking).toEqual([['B'], ['A'], ['C']]);
|
||||
expect(splendor.view(s, null).result!.ranking.map((x) => [x.player, x.rank])).toEqual([
|
||||
['B', 1],
|
||||
['A', 2],
|
||||
['C', 3],
|
||||
]);
|
||||
giveCards(s, 'B', zeros.slice(17, 19)); // B: 16 points, 14 cards → same as A
|
||||
const r = splendor.result(s)!;
|
||||
expect(r.ranking).toEqual([['A', 'B'], ['C']]);
|
||||
expect(r.summary).toBe('공동 1위 2명, 16점 (카드 14장)');
|
||||
expect(splendor.view(s, 'A').result!.winners).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- pass, empty decks
|
||||
|
||||
describe('넘기기와 더미 소진 (§5)', () => {
|
||||
/**
|
||||
* Nobody can do anything: all colour tokens are held (10 each), both have 3 reservations they can't
|
||||
* afford and the board is empty (its cards went back under the decks).
|
||||
*/
|
||||
function stuck(): SplendorState {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
const reserve = (p: PlayerId, ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
pull(s, id);
|
||||
s.players[p]!.reserved.push({ cardId: id, blind: false });
|
||||
}
|
||||
};
|
||||
reserve('A', ['3W02', '3U02', '3G02']); // K7, W7, U7
|
||||
reserve('B', ['3R02', '3K02', '3U04']); // G7, R7, W7 U3
|
||||
giveTokens(s, 'A', { W: 4, U: 4, G: 2 });
|
||||
giveTokens(s, 'B', { G: 2, R: 4, K: 4 });
|
||||
for (const t of TIERS) {
|
||||
for (const id of s.board[t]) if (id) s.decks[t].push(id);
|
||||
s.board[t] = [null, null, null, null];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
test('PASS is refused while any action is possible and allowed only when none is', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
expect(reason(s, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
|
||||
expect(splendor.view(s, 'A').me!.canPass).toBe(false);
|
||||
const t = stuck();
|
||||
expect(splendor.view(t, 'A').me!.canPass).toBe(true);
|
||||
expect(splendor.legalActions!(t, 'A')).toEqual([{ type: 'PASS' }]);
|
||||
const t2 = act(t, 'A', { type: 'PASS' });
|
||||
expect(cur(t2)).toBe('B');
|
||||
expect(t2.passStreak).toBe(1);
|
||||
expect(t2.phase).toBe('action');
|
||||
// With one token back in the supply A could take it, so passing is refused again.
|
||||
const u = stuck();
|
||||
u.players.B!.tokens.K--;
|
||||
u.supply.K++;
|
||||
expect(reason(u, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
|
||||
});
|
||||
|
||||
test('a full round of passes ends the game (nothing can change any more)', () => {
|
||||
let s = act(stuck(), 'A', { type: 'PASS' });
|
||||
expect(splendor.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' });
|
||||
s = act(s, 'B', { type: 'TIMEOUT' }); // automatic move is a pass as well
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(s.endReason).toBe('stalemate');
|
||||
expect(splendor.result(s)!.summary).toMatch(/^더 할 수 있는 행동이 없어 끝났어요\./);
|
||||
});
|
||||
|
||||
test('empty deck: the board slot stays empty after a buy, and reserving from the empty deck is refused', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
// Move the rest of the tier-3 deck out of reach: B "owns" them as bought cards (keeps the 90 total).
|
||||
s.players.B!.cards.push(...s.decks[3].splice(0));
|
||||
const id = s.board[3][2]!;
|
||||
giveTokens(s, 'A', { gold: 5 });
|
||||
const need = COLORS.reduce((a, c) => a + cardOf(id).cost[c], 0);
|
||||
giveTokens(s, 'A', Object.fromEntries(COLORS.map((c) => [c, Math.min(cardOf(id).cost[c], s.supply[c])])) as Partial<Tokens>);
|
||||
expect(need).toBeGreaterThan(0);
|
||||
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('그 단계 더미에 카드가 없어요.');
|
||||
expect(splendor.view(s, 'A').me!.reserveDecks).toEqual([1, 2]);
|
||||
s = act(s, 'A', { type: 'BUY', from: { tier: 3, slot: 2 } });
|
||||
if (s.phase === 'discard') s = act(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(s.board[3][2]).toBeNull();
|
||||
expect(splendor.view(s, null).board[3][2]).toBeNull();
|
||||
expect(reason(s, 'B', { type: 'BUY', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
|
||||
expect(reason(s, 'B', { type: 'RESERVE', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- timeouts
|
||||
|
||||
describe('시간 초과와 자리 비움 (§8.5)', () => {
|
||||
test('TIMEOUT is system-only (not in the action schema) and legal for the current player', () => {
|
||||
const s = mk();
|
||||
expect(splendorActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
|
||||
expect(splendor.onTimeout(s, cur(s))).toEqual({ type: 'TIMEOUT' });
|
||||
expect(splendor.validate(s, cur(s), { type: 'TIMEOUT' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('affordable card → automatic buy (most points, then fewest tokens)', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
place(s, '1W08', 0); // G4, 1 point
|
||||
place(s, '1U07', 1); // K3, 0 points
|
||||
giveTokens(s, 'A', { G: 4, K: 3 });
|
||||
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(state.players.A!.cards).toEqual(['1W08']);
|
||||
expect(events.some((e) => e.type === 'timeoutAuto')).toBe(true);
|
||||
expect(state.log.at(-1)).toMatchObject({ k: 'buy', cardId: '1W08', auto: true });
|
||||
});
|
||||
|
||||
test('nothing affordable → 3 different colours with the most supply (ties W,U,G,R,K)', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
setSupply(s, 'W', 2, 'B');
|
||||
const s2 = act(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(s2.players.A!.tokens).toEqual(tk({ U: 1, G: 1, R: 1 }));
|
||||
});
|
||||
|
||||
test('timeouts in discard and noble phases finish the turn so the game never stalls', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
||||
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
||||
expect(s.phase).toBe('discard');
|
||||
s = act(s, 'A', { type: 'TIMEOUT' });
|
||||
// Most held first (W 4), then ties K,R,G,U,W: G (3) before U (3).
|
||||
expect(s.players.A!.tokens).toEqual(tk({ W: 3, U: 3, G: 2, R: 1, K: 1 }));
|
||||
expect(cur(s)).toBe('B');
|
||||
|
||||
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
t.nobles = ['N06', 'N07', 'N10'];
|
||||
giveCards(t, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06', '1G05', '1R07', '1R06', '1R05']);
|
||||
t = act(t, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
expect(t.phase).toBe('noble');
|
||||
t = act(t, 'A', { type: 'TIMEOUT' });
|
||||
expect(t.players.A!.nobles).toEqual(['N06']); // smallest id
|
||||
expect(cur(t)).toBe('B');
|
||||
});
|
||||
|
||||
test('an automatic action that goes over 10 tokens also returns them in the same timeout', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveTokens(s, 'A', { W: 2, U: 2, G: 2, R: 2, K: 2 });
|
||||
for (const t of [1, 2] as const) {
|
||||
for (const id of s.board[t]) if (id) s.decks[t].push(id);
|
||||
s.board[t] = [null, null, null, null]; // only tier-3 cards left: none affordable with 2 of each
|
||||
}
|
||||
const s2 = act(s, 'A', { type: 'TIMEOUT' });
|
||||
expect(s2.log.find((e) => e.k === 'take')).toMatchObject({ auto: true });
|
||||
expect(s2.log.find((e) => e.k === 'discard')).toMatchObject({ auto: true });
|
||||
expect(cur(s2)).toBe('B');
|
||||
expect(componentCounts(s2).tokens).toEqual(initialTokens(s2));
|
||||
expect(Object.values(s2.players.A!.tokens).reduce((a, b) => a + b, 0)).toBe(10);
|
||||
});
|
||||
|
||||
test('3 timeouts in a row → away (short deadline); acting again clears it', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
s = act(s, 'A', { type: 'TIMEOUT' });
|
||||
s = act(s, 'B', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
|
||||
}
|
||||
expect(splendor.view(s, 'B').players.A!.away).toBe(true);
|
||||
expect(s.deadlineAt).toBe(clock + 5000);
|
||||
expect(s.log.some((e) => e.k === 'away' && e.p === 'A')).toBe(true);
|
||||
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
|
||||
if (s.phase === 'discard') s = act(s, 'A', { type: 'DISCARD', tokens: tk({ gold: 1 }) });
|
||||
expect(splendor.view(s, 'B').players.A!.away).toBe(false);
|
||||
expect(s.deadlineAt).toBe(clock + 60_000);
|
||||
});
|
||||
|
||||
test('discard / noble phases get 15 extra seconds; no time limit → no deadline', () => {
|
||||
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
||||
const before = s.deadlineAt!;
|
||||
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
||||
expect(s2.deadlineAt).toBe(before + 15_000);
|
||||
const free = mk(['A', 'B'], { turnSeconds: null });
|
||||
expect(free.deadlineAt).toBeNull();
|
||||
expect(splendor.deadline!(free)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- concede / leave
|
||||
|
||||
describe('기권 (§8.2)', () => {
|
||||
test('3 players: tokens go back, reservations leave the game, cards stay for the result (ranked last)', () => {
|
||||
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
giveCards(s, 'B', ['3W04']);
|
||||
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
||||
giveTokens(s, 'B', { R: 2 });
|
||||
s = act(s, 'B', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
|
||||
expect(splendor.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
|
||||
s = act(s, 'B', { type: 'CONCEDE' });
|
||||
expect(s.players.B!.tokens).toEqual(emptyTokens());
|
||||
expect(s.players.B!.reserved).toEqual([]);
|
||||
expect(s.players.B!.cards).toEqual(['3W04']);
|
||||
expect(s.removed).toHaveLength(1);
|
||||
expect(cur(s)).toBe('C');
|
||||
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
||||
expect(cur(s)).toBe('A'); // B is skipped
|
||||
expect(reason(s, 'B', { type: 'CONCEDE' })).toBe('이미 기권했어요.');
|
||||
expect(splendor.onLeave!(s, 'B')).toBeNull();
|
||||
});
|
||||
|
||||
test('the current player concedes → next player; 2 players → the game ends, the other one wins', () => {
|
||||
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
s = act(s, 'A', { type: 'CONCEDE' });
|
||||
expect(cur(s)).toBe('B');
|
||||
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
giveCards(t, 'A', ['3W04']);
|
||||
t = act(t, 'A', { type: 'CONCEDE' });
|
||||
expect(t.phase).toBe('finished');
|
||||
const r = splendor.result(t)!;
|
||||
expect(r.ranking).toEqual([['B'], ['A']]);
|
||||
expect(r.reason).toBe('resign');
|
||||
expect(r.summary).toMatch(/^다른 사람이 모두 기권했어요\./);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- info hiding, determinism, options
|
||||
|
||||
describe('정보 공개 (§8.3)', () => {
|
||||
test('views and events never contain deck contents, unused nobles, blind reservations of others or an rng', () => {
|
||||
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
||||
const allEvents: GameEvent[] = [];
|
||||
const step = (who: PlayerId, a: SplendorAction) => {
|
||||
const out = actE(s, who, a);
|
||||
allEvents.push(...out.events);
|
||||
s = out.state;
|
||||
};
|
||||
step('A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
||||
step('B', { type: 'RESERVE', from: { tier: 2, deck: true } });
|
||||
step('C', { type: 'RESERVE', from: { tier: 3, deck: true } });
|
||||
const blind = { A: s.players.A!.reserved[0]!.cardId, B: s.players.B!.reserved[0]!.cardId, C: s.players.C!.reserved[0]!.cardId };
|
||||
for (const viewer of ['A', 'B', 'C', null] as const) {
|
||||
const json = JSON.stringify(splendor.view(s, viewer));
|
||||
for (const [owner, id] of Object.entries(blind)) {
|
||||
if (owner === viewer) expect(json).toContain(`"${id}"`);
|
||||
else expect(json).not.toContain(`"${id}"`);
|
||||
}
|
||||
for (const t of TIERS) for (const id of s.decks[t]) expect(json).not.toContain(`"${id}"`);
|
||||
const used = new Set(s.nobles);
|
||||
for (const n of NOBLES) if (!used.has(n.id)) expect(json).not.toContain(`"${n.id}"`);
|
||||
expect(json).not.toMatch(/"rng"|"decks"/);
|
||||
}
|
||||
const ev = JSON.stringify(allEvents);
|
||||
for (const id of Object.values(blind)) expect(ev).not.toContain(`"${id}"`);
|
||||
// After the game all reservations are shown.
|
||||
s.phase = 'finished';
|
||||
s.endReason = 'target';
|
||||
const res = splendor.view(s, null);
|
||||
expect(res.result!.revealedReserved).toEqual({ A: [blind.A], B: [blind.B], C: [blind.C] });
|
||||
expect(res.players.A!.reserved[0]).toMatchObject({ id: blind.A, hidden: false });
|
||||
});
|
||||
|
||||
test('secretsOf: own blind reservations + every deck card + the unused nobles', () => {
|
||||
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
||||
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, deck: true } });
|
||||
const sec = splendor.secretsOf!(s, 'A');
|
||||
expect(sec).toContain(s.players.A!.reserved[0]!.cardId);
|
||||
expect(sec.filter((x) => x.startsWith('N'))).toHaveLength(7);
|
||||
expect(sec.length).toBe(1 + s.decks[1].length + s.decks[2].length + s.decks[3].length + 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('결정성과 옵션', () => {
|
||||
test('replay: same seed + same actions → same states and events', () => {
|
||||
const play = () => {
|
||||
let s = mk(['A', 'B', 'C'], {}, 42);
|
||||
const log: string[] = [];
|
||||
for (let i = 0; i < 60 && s.phase !== 'finished'; i++) {
|
||||
const p = cur(s);
|
||||
const legal = splendor.legalActions!(s, p);
|
||||
const a = legal[(i * 7) % legal.length]!;
|
||||
const out = splendor.apply(s, p, a, { rng: RNG(), now: 5000 + i });
|
||||
log.push(JSON.stringify(out.events));
|
||||
s = out.state;
|
||||
}
|
||||
return JSON.stringify(s) + log.join('|');
|
||||
};
|
||||
expect(play()).toBe(play());
|
||||
});
|
||||
|
||||
test('every option has a Korean title (and labels for choices); defaults follow §2', () => {
|
||||
expect(splendor.defaultOptions).toEqual({ targetPoints: 15, allowFewerTokens: true, turnSeconds: 60, showHints: true });
|
||||
const shape = splendor.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } }> };
|
||||
for (const [key, field] of Object.entries(shape.shape)) {
|
||||
const m = field.meta();
|
||||
expect(m?.title, key).toMatch(/[가-힣]/);
|
||||
if (key === 'targetPoints' || key === 'turnSeconds') expect(Object.keys(m.labels ?? {}).length).toBeGreaterThan(1);
|
||||
}
|
||||
expect(splendor.optionsSchema.safeParse({ targetPoints: 12 }).success).toBe(false);
|
||||
expect(splendor.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,12 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
|
||||
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
|
||||
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
|
||||
{ id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true },
|
||||
{ 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