Merge branch 'm4/davinci-code' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
956
apps/web/src/games/davinci-code/index.tsx
Normal file
956
apps/web/src/games/davinci-code/index.tsx
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
/** 다빈치 코드 화면 (docs/games/davinci-code.md §9). */
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DavinciGuess, DavinciOptions, DavinciTileView, DavinciView, PhaseKind, TColor, TileValue } from '@bg/games/davinci-code';
|
||||||
|
import type { PublicUser } from '@bg/shared/lite';
|
||||||
|
import { Avatar } from '../../components/ui';
|
||||||
|
import { TurnTimer } from '../../components/TurnTimer';
|
||||||
|
import { haptic, sounds } from '../../lib/feedback';
|
||||||
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||||
|
|
||||||
|
const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
|
||||||
|
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
|
||||||
|
const NUMBERS = Array.from({ length: 12 }, (_, i) => i);
|
||||||
|
|
||||||
|
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 valueKo = (v: TileValue) => (v === 'joker' ? '조커' : String(v));
|
||||||
|
const faceOf = (v: TileValue) => (v === 'joker' ? '−' : String(v));
|
||||||
|
const hiddenLeft = (row: DavinciTileView[]) => row.filter((t) => !t.revealed).length;
|
||||||
|
|
||||||
|
function totalMsOf(v: DavinciView): number {
|
||||||
|
if (v.phase === 'chooseStart') return 15_000;
|
||||||
|
if ((v.idle[v.current] ?? 0) >= 3) return 5_000;
|
||||||
|
return v.phase === 'guess' ? v.options.turnSeconds * 1000 : 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats starting with the one after `me` (clockwise); spectators see everyone. */
|
||||||
|
function opponentsOf(v: DavinciView, me: string | null): string[] {
|
||||||
|
const i = me ? v.seats.indexOf(me) : -1;
|
||||||
|
if (i < 0) return v.seats;
|
||||||
|
return v.seats.map((_, k) => v.seats[(i + 1 + k) % v.seats.length]!).filter((p) => p !== me);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour+number combinations the viewer can already see (for the "impossible guess" warning). */
|
||||||
|
function visibleCombos(v: DavinciView): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const row of Object.values(v.rows)) for (const t of row) if (t.n !== undefined) out.add(`${t.color}:${t.n}`);
|
||||||
|
if (v.pending?.n !== undefined) out.add(`${v.pending.color}:${v.pending.n}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessLine(g: DavinciGuess, players: Record<string, PublicUser>): string {
|
||||||
|
return `${nick(players, g.by)} → ${nick(players, g.target)} ${g.index + 1}번째 ${COLOR_SHORT[g.color]} = ${valueKo(g.said)} (${g.correct ? '맞힘' : '틀림'}${g.auto ? ', 시간 초과' : ''})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.dv-board { width: 100%; max-width: 980px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
|
||||||
|
.dv-opps { display: grid; gap: 10px; }
|
||||||
|
.dv-player { background: var(--surface); border-radius: var(--radius-lg); padding: 10px 12px; box-shadow: var(--shadow); border: 3px solid transparent; }
|
||||||
|
.dv-player.turn { border-color: var(--focus); }
|
||||||
|
.dv-player.out { opacity: .7; }
|
||||||
|
.dv-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
|
||||||
|
.dv-name { font-weight: 800; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dv-badge { display: inline-block; border-radius: 999px; padding: 1px 9px; font-weight: 800; font-size: .8rem; background: var(--surface-2); color: var(--text); }
|
||||||
|
.dv-badge.turn { background: var(--focus); color: #1c1e21; }
|
||||||
|
.dv-badge.out { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.dv-badge.ok { background: var(--success-soft); color: var(--success); }
|
||||||
|
.dv-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; padding-top: 8px; }
|
||||||
|
.dv-slot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||||
|
.dv-pos { font-size: .75rem; color: var(--text-sub); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||||
|
.dv-tile { --w: 46px; width: var(--w); height: calc(var(--w) * 1.42); border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 900; font-size: calc(var(--w) * .56); line-height: 1; position: relative; user-select: none; font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: 0 3px 0 rgba(0,0,0,.35); transition: transform .15s ease; }
|
||||||
|
.dv-tile.black { background: linear-gradient(160deg, #42454d, #141519); color: #fff; border: 2px solid color-mix(in srgb, var(--text) 30%, #050505); }
|
||||||
|
.dv-tile.white { background: linear-gradient(160deg, #ffffff, #e2ded3); color: #15161a; border: 2px solid #9d978a; }
|
||||||
|
.dv-tile.back.black { background: repeating-linear-gradient(45deg, #2a2c33 0 5px, #1b1c21 5px 10px); }
|
||||||
|
.dv-tile.back.white { background: repeating-linear-gradient(45deg, #fbf9f4 0 5px, #e6e1d6 5px 10px); }
|
||||||
|
.dv-tile .q { opacity: .45; font-size: .62em; }
|
||||||
|
.dv-tile.open::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: 4px; height: 4px; border-radius: 2px; background: #f59e0b; }
|
||||||
|
.dv-tile.secret { outline: 3px dashed var(--primary); outline-offset: 2px; }
|
||||||
|
.dv-tile.sm { --w: 30px; }
|
||||||
|
.dv-tile.lg { --w: 54px; }
|
||||||
|
.dv-tile.hit { animation: dv-flip .5s ease-out, dv-hit 1.2s ease-out; }
|
||||||
|
.dv-tile.miss { animation: dv-flip .5s ease-out, dv-miss 1.2s ease-out; }
|
||||||
|
.dv-tile.new { animation: dv-drop .35s ease-out; }
|
||||||
|
@keyframes dv-flip { 0% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
|
||||||
|
@keyframes dv-hit { 0%, 60% { box-shadow: 0 0 0 4px #22c55e, 0 0 18px 6px rgba(34,197,94,.7); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||||
|
@keyframes dv-miss { 0%, 60% { box-shadow: 0 0 0 4px #ef4444, 0 0 18px 6px rgba(239,68,68,.6); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||||
|
@keyframes dv-drop { 0% { transform: translateY(-14px); opacity: .3; } 100% { transform: translateY(0); opacity: 1; } }
|
||||||
|
.dv-pick { background: none; border: 0; padding: 3px; border-radius: 11px; cursor: pointer; color: inherit; }
|
||||||
|
.dv-pick:disabled { cursor: default; }
|
||||||
|
.dv-pick.can { box-shadow: 0 0 0 3px var(--primary); }
|
||||||
|
.dv-pick.can:hover .dv-tile { transform: translateY(-3px); }
|
||||||
|
.dv-pick.sel { box-shadow: 0 0 0 4px var(--focus); }
|
||||||
|
.dv-pick.sel .dv-tile { transform: translateY(-6px); }
|
||||||
|
.dv-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.dv-gap { align-self: stretch; min-width: 28px; min-height: 48px; border: 2px dashed var(--primary); background: var(--primary-soft); color: var(--text);
|
||||||
|
border-radius: 8px; font-weight: 800; cursor: pointer; padding: 0 2px; font-size: .8rem; }
|
||||||
|
.dv-gap:focus-visible { outline: 3px solid var(--focus); }
|
||||||
|
.dv-mid { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||||
|
.dv-center { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
||||||
|
.dv-piles { display: flex; gap: 12px; justify-content: center; }
|
||||||
|
.dv-pile { display: flex; flex-direction: column; align-items: center; gap: 4px; background: rgba(0,0,0,.18); border: 2px solid transparent; border-radius: 12px;
|
||||||
|
padding: 8px 10px; color: #fff; font-weight: 800; min-width: 92px; min-height: 48px; cursor: pointer; font-size: .9rem; }
|
||||||
|
.dv-pile:disabled { cursor: default; opacity: .8; }
|
||||||
|
.dv-pile.can { border-color: #fde047; background: rgba(253,224,71,.15); }
|
||||||
|
.dv-pile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.dv-stack { position: relative; width: 44px; height: 50px; }
|
||||||
|
.dv-stack .dv-tile { position: absolute; left: 0; }
|
||||||
|
.dv-pending { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
|
||||||
|
.dv-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.dv-pad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
|
.dv-pad button { min-height: 56px; font-size: 1.35rem; font-weight: 900; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-variant-numeric: tabular-nums; }
|
||||||
|
.dv-pad button.unlikely { opacity: .4; }
|
||||||
|
.dv-pad button[aria-pressed='true'] { background: var(--primary); color: var(--primary-text); border-color: var(--primary); opacity: 1; }
|
||||||
|
.dv-pad button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.dv-pad .joker { grid-column: 1 / -1; font-size: 1.1rem; }
|
||||||
|
.dv-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.dv-big .btn { min-height: 64px; font-size: 1.15rem; }
|
||||||
|
.dv-split { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||||
|
.dv-split button { min-height: 56px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 8px; }
|
||||||
|
.dv-split button[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||||
|
.dv-split button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.dv-split button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.dv-mini { display: inline-block; width: 12px; height: 17px; border-radius: 3px; vertical-align: middle; }
|
||||||
|
.dv-mini.black { background: #1b1c21; border: 1px solid #000; }
|
||||||
|
.dv-mini.white { background: #fff; border: 1px solid #8f8a7e; }
|
||||||
|
.dv-me { border-color: var(--primary); }
|
||||||
|
.dv-legend { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px 16px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.dv-legend > span { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.dv-me .dv-slot { gap: 6px; }
|
||||||
|
.dv-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; font-size: .9rem; }
|
||||||
|
.dv-log li.hit::before { content: '✓ '; color: var(--success); font-weight: 900; }
|
||||||
|
.dv-log li.miss::before { content: '✗ '; color: var(--danger); font-weight: 900; }
|
||||||
|
@media (max-width: 719px) {
|
||||||
|
.dv-tile { --w: 34px; border-radius: 6px; }
|
||||||
|
.dv-tile.lg { --w: 37px; }
|
||||||
|
.dv-row { gap: 4px; }
|
||||||
|
.dv-mid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.dv-board { max-height: none; overflow: visible; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dv-tile.hit, .dv-tile.miss, .dv-tile.new { animation: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tiles
|
||||||
|
|
||||||
|
type Fx = 'hit' | 'miss' | 'new';
|
||||||
|
|
||||||
|
function Tile({ color, n, revealed, mine, size, fx }: { color: TColor; n?: TileValue; revealed: boolean; mine?: boolean; size?: 'sm' | 'lg'; fx?: Fx }) {
|
||||||
|
const known = n !== undefined;
|
||||||
|
const cls = ['dv-tile', color, known ? '' : 'back', revealed ? 'open' : '', mine && !revealed ? 'secret' : '', size ?? '', fx ?? ''].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<span className={cls} aria-hidden>
|
||||||
|
{known ? faceOf(n) : <span className="q">?</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileLabel(t: DavinciTileView, pos: number, mine: boolean): string {
|
||||||
|
const what = t.n === undefined ? '숨김' : t.n === 'joker' ? '조커' : `${t.n}`;
|
||||||
|
return `${pos + 1}번째, ${COLOR_KO[t.color]} ${what}${t.revealed ? ', 공개됨' : mine ? ', 나만 보임' : ''}${t.wrong.length ? `, 틀린 추리 ${t.wrong.map(valueKo).join('·')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- board
|
||||||
|
|
||||||
|
interface Sel {
|
||||||
|
target: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DavinciBoard({ view: v, me, players, act, pending, deadline }: BoardProps<DavinciView>) {
|
||||||
|
const amPlayer = !!me && v.seats.includes(me);
|
||||||
|
const myTurn = amPlayer && v.current === me && !!v.alive[me!] && v.phase !== 'chooseStart' && v.phase !== 'finished';
|
||||||
|
const [sel, setSel] = useState<Sel | null>(null);
|
||||||
|
const [val, setVal] = useState<TileValue | null>(null);
|
||||||
|
const [fx, setFx] = useState<Map<string, Fx>>(new Map());
|
||||||
|
const [announce, setAnnounce] = useState('');
|
||||||
|
const prevHist = useRef(v.history.length);
|
||||||
|
const prevPlaced = useRef(JSON.stringify(v.lastPlaced));
|
||||||
|
const prevTurn = useRef(`${v.current}|${v.phase === 'chooseStart'}`);
|
||||||
|
|
||||||
|
const resetKey = `${v.phase}|${v.current}|${v.history.length}`;
|
||||||
|
useEffect(() => {
|
||||||
|
setSel(null);
|
||||||
|
setVal(null);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
// Effects + screen-reader text for new guesses and newly placed tiles.
|
||||||
|
useEffect(() => {
|
||||||
|
const placedNow = JSON.stringify(v.lastPlaced);
|
||||||
|
const next = new Map<string, Fx>();
|
||||||
|
if (v.history.length > prevHist.current) {
|
||||||
|
const g = v.history[v.history.length - 1]!;
|
||||||
|
if (g.correct) next.set(`${g.target}:${g.index}`, 'hit');
|
||||||
|
else if (v.lastPlaced && v.lastPlaced.player === g.by && v.lastPlaced.revealed) next.set(`${g.by}:${v.lastPlaced.index}`, 'miss');
|
||||||
|
setAnnounce(
|
||||||
|
`${nick(players, g.by)}님이 ${nick(players, g.target)}님 ${g.index + 1}번째 타일을 ${valueKo(g.said)}(으)로 추리해서 ${g.correct ? '맞혔어요' : '틀렸어요'}.`,
|
||||||
|
);
|
||||||
|
if (g.correct && g.by === me) haptic(30);
|
||||||
|
} else if (placedNow !== prevPlaced.current && v.lastPlaced) {
|
||||||
|
next.set(`${v.lastPlaced.player}:${v.lastPlaced.index}`, v.lastPlaced.revealed ? 'miss' : 'new');
|
||||||
|
}
|
||||||
|
prevHist.current = v.history.length;
|
||||||
|
prevPlaced.current = placedNow;
|
||||||
|
if (next.size) {
|
||||||
|
setFx(next);
|
||||||
|
const t = setTimeout(() => setFx(new Map()), 1300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [v.history.length, JSON.stringify(v.lastPlaced)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const key = `${v.current}|${v.phase === 'chooseStart'}`;
|
||||||
|
if (key !== prevTurn.current && v.phase !== 'chooseStart' && v.phase !== 'finished') {
|
||||||
|
if (v.current === me) {
|
||||||
|
haptic(40);
|
||||||
|
sounds.myTurn();
|
||||||
|
setAnnounce('내 차례예요.');
|
||||||
|
} else setAnnounce(`${nick(players, v.current)}님 차례예요.`);
|
||||||
|
}
|
||||||
|
prevTurn.current = key;
|
||||||
|
}, [v.current, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const canGuess = myTurn && v.phase === 'guess' && !pending;
|
||||||
|
const pick = (target: string, index: number) => {
|
||||||
|
if (sel && sel.target === target && sel.index === index) {
|
||||||
|
setSel(null);
|
||||||
|
setVal(null);
|
||||||
|
} else {
|
||||||
|
setSel({ target, index });
|
||||||
|
setVal(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dv-board" role="region" aria-label="다빈치 코드 판">
|
||||||
|
<style>{CSS}</style>
|
||||||
|
<div className="dv-opps">
|
||||||
|
{opponentsOf(v, me).map((p) => (
|
||||||
|
<PlayerRow key={p} id={p} view={v} me={me} players={players} deadline={deadline} fx={fx} canPick={canGuess && !!v.alive[p]} sel={sel} onPick={pick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dv-mid">
|
||||||
|
<Center view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} />
|
||||||
|
<ActionPanel view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} sel={sel} val={val} setVal={setVal} onCancel={() => (setSel(null), setVal(null))} />
|
||||||
|
</div>
|
||||||
|
{amPlayer && <MyRow view={v} me={me!} players={players} act={act} pending={pending} myTurn={myTurn} deadline={deadline} fx={fx} />}
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{announce}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||||
|
const u = userOf(players, id);
|
||||||
|
const row = v.rows[id] ?? [];
|
||||||
|
const st = v.stats[id] ?? { guesses: 0, correct: 0 };
|
||||||
|
const outRank = v.eliminationOrder.indexOf(id);
|
||||||
|
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||||
|
const choosing = v.phase === 'chooseStart' && v.alive[id] && v.picked && !v.picked[id];
|
||||||
|
return (
|
||||||
|
<div className="dv-head">
|
||||||
|
<Avatar user={u} size={26} />
|
||||||
|
<span className="dv-name">
|
||||||
|
{u.nickname}
|
||||||
|
{id === me && <span className="muted"> (나)</span>}
|
||||||
|
</span>
|
||||||
|
{isTurn && <span className="dv-badge turn">차례</span>}
|
||||||
|
{v.final?.winner === id && <span className="dv-badge ok">승리</span>}
|
||||||
|
{!v.alive[id] && <span className="dv-badge out">{v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}</span>}
|
||||||
|
{v.alive[id] && (v.idle[id] ?? 0) >= 3 && <span className="dv-badge">자리 비움</span>}
|
||||||
|
{v.phase === 'chooseStart' && v.alive[id] && <span className={`dv-badge${choosing ? '' : ' ok'}`}>{choosing ? '고르는 중' : '다 골랐어요'}</span>}
|
||||||
|
<span className="small muted" style={{ marginLeft: 'auto' }}>
|
||||||
|
{v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses}
|
||||||
|
</span>
|
||||||
|
{(isTurn || choosing) && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={id === me} size={30} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerRow({
|
||||||
|
id,
|
||||||
|
view: v,
|
||||||
|
me,
|
||||||
|
players,
|
||||||
|
deadline,
|
||||||
|
fx,
|
||||||
|
canPick,
|
||||||
|
sel,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
view: DavinciView;
|
||||||
|
me: string | null;
|
||||||
|
players: Record<string, PublicUser>;
|
||||||
|
deadline: number | null;
|
||||||
|
fx: Map<string, Fx>;
|
||||||
|
canPick: boolean;
|
||||||
|
sel: Sel | null;
|
||||||
|
onPick: (target: string, index: number) => void;
|
||||||
|
}) {
|
||||||
|
const row = v.rows[id] ?? [];
|
||||||
|
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||||
|
const name = nick(players, id);
|
||||||
|
return (
|
||||||
|
<section className={`dv-player${isTurn ? ' turn' : ''}${v.alive[id] || v.phase === 'finished' ? '' : ' out'}`} aria-label={`${name}님 타일`}>
|
||||||
|
<PlayerHead id={id} view={v} me={me} players={players} deadline={deadline} />
|
||||||
|
<div className="dv-row" role="group" aria-label={`${name}님 타일 줄`}>
|
||||||
|
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}</span>}
|
||||||
|
{row.map((t, i) => {
|
||||||
|
const can = canPick && !t.revealed;
|
||||||
|
const selected = sel?.target === id && sel.index === i;
|
||||||
|
return (
|
||||||
|
<div className="dv-slot" key={i}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dv-pick${can ? ' can' : ''}${selected ? ' sel' : ''}`}
|
||||||
|
disabled={!can}
|
||||||
|
aria-pressed={can ? selected : undefined}
|
||||||
|
aria-label={`${name}님 ${tileLabel(t, i, false)}${can ? ', 눌러서 추리' : ''}`}
|
||||||
|
onClick={() => onPick(id, i)}
|
||||||
|
>
|
||||||
|
<Tile color={t.color} n={t.n} revealed={t.revealed} fx={fx.get(`${id}:${i}`)} />
|
||||||
|
</button>
|
||||||
|
<span className="dv-pos" aria-hidden>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MyRow({
|
||||||
|
view: v,
|
||||||
|
me,
|
||||||
|
players,
|
||||||
|
act,
|
||||||
|
pending,
|
||||||
|
myTurn,
|
||||||
|
deadline,
|
||||||
|
fx,
|
||||||
|
}: {
|
||||||
|
view: DavinciView;
|
||||||
|
me: string;
|
||||||
|
players: Record<string, PublicUser>;
|
||||||
|
act: (a: unknown) => void;
|
||||||
|
pending: boolean;
|
||||||
|
myTurn: boolean;
|
||||||
|
deadline: number | null;
|
||||||
|
fx: Map<string, Fx>;
|
||||||
|
}) {
|
||||||
|
const row = v.rows[me] ?? [];
|
||||||
|
const placing = myTurn && v.phase === 'placeJoker' && !pending;
|
||||||
|
const revealing = myTurn && v.phase === 'revealOwn' && !pending;
|
||||||
|
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === me;
|
||||||
|
const gap = (index: number) =>
|
||||||
|
placing && (
|
||||||
|
<button key={`gap${index}`} type="button" className="dv-gap" aria-label={`${index + 1}번째 자리에 조커 넣기`} onClick={() => act({ type: 'placeJoker', index })}>
|
||||||
|
여기
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className={`dv-player dv-me${isTurn ? ' turn' : ''}${v.alive[me] || v.phase === 'finished' ? '' : ' out'}`} aria-label="내 타일">
|
||||||
|
<PlayerHead id={me} view={v} me={me} players={players} deadline={deadline} />
|
||||||
|
<div className="dv-row" role="group" aria-label="내 타일 줄">
|
||||||
|
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}</span>}
|
||||||
|
{row.map((t, i) => {
|
||||||
|
const can = revealing && !t.revealed;
|
||||||
|
return [
|
||||||
|
gap(i),
|
||||||
|
<div className="dv-slot" key={i}>
|
||||||
|
{can ? (
|
||||||
|
<button type="button" className="dv-pick can" aria-label={`내 ${tileLabel(t, i, true)}, 눌러서 공개`} onClick={() => act({ type: 'revealOwn', index: i })}>
|
||||||
|
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="dv-pick" role="img" aria-label={`내 ${tileLabel(t, i, true)}`}>
|
||||||
|
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="dv-pos" aria-hidden>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
{gap(row.length)}
|
||||||
|
</div>
|
||||||
|
{row.length > 0 && (
|
||||||
|
<div className="dv-legend" style={{ marginTop: 6 }}>
|
||||||
|
<span>
|
||||||
|
<span className="dv-mini white" style={{ outline: '2px dashed var(--primary)', outlineOffset: 1 }} /> 점선: 상대에게 숨김
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="dv-mini white" style={{ boxShadow: 'inset 0 -4px 0 #f59e0b' }} /> 아래 노란 줄: 모두에게 공개됨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; myTurn: boolean }) {
|
||||||
|
const drawing = myTurn && v.phase === 'draw' && !pending;
|
||||||
|
const total = v.pool.black + v.pool.white;
|
||||||
|
const last = v.history[v.history.length - 1];
|
||||||
|
return (
|
||||||
|
<div className="dv-center" role="group" aria-label="더미">
|
||||||
|
{v.options.chooseColors ? (
|
||||||
|
<div className="dv-piles">
|
||||||
|
{(['black', 'white'] as const).map((c) => {
|
||||||
|
const can = drawing && v.pool[c] > 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`dv-pile${can ? ' can' : ''}`}
|
||||||
|
disabled={!can}
|
||||||
|
aria-label={`${COLOR_KO[c]} 타일 가져오기, ${v.pool[c]}개 남음`}
|
||||||
|
onClick={() => act({ type: 'draw', color: c })}
|
||||||
|
>
|
||||||
|
<span className="dv-stack" aria-hidden>
|
||||||
|
{v.pool[c] > 0 ? (
|
||||||
|
[0, 1, 2].slice(0, Math.min(3, v.pool[c])).map((k) => (
|
||||||
|
<span key={k} style={{ position: 'absolute', left: 7 + k * 3, top: 6 - k * 3 }}>
|
||||||
|
<Tile color={c} revealed={false} size="sm" />
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>없음</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{COLOR_SHORT[c]} {v.pool[c]}개
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={`dv-pile${drawing ? ' can' : ''}`} disabled={!drawing} aria-label={`타일 가져오기, ${total}개 남음`} onClick={() => act({ type: 'draw' })}>
|
||||||
|
<span>타일 가져오기</span>
|
||||||
|
<span className="small">
|
||||||
|
남은 타일 {total}개 (검정 {v.pool.black} · 흰색 {v.pool.white})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{v.pending ? (
|
||||||
|
<div className="dv-pending">
|
||||||
|
<Tile color={v.pending.color} n={v.pending.n} revealed={false} mine={v.current === me} />
|
||||||
|
<span>
|
||||||
|
{v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`}
|
||||||
|
<br />
|
||||||
|
<span className="small" style={{ opacity: 0.85 }}>
|
||||||
|
{v.current === me ? '나만 보여요' : '색만 보여요'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
v.phase !== 'chooseStart' &&
|
||||||
|
v.phase !== 'finished' &&
|
||||||
|
total === 0 && <span className="small">더미가 비었어요. 가져오지 않고 바로 추리해요.</span>
|
||||||
|
)}
|
||||||
|
{last && (
|
||||||
|
<span className="small" style={{ textAlign: 'center' }} aria-hidden>
|
||||||
|
최근: {guessLine(last, players)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionPanel({
|
||||||
|
view: v,
|
||||||
|
me,
|
||||||
|
players,
|
||||||
|
act,
|
||||||
|
pending,
|
||||||
|
myTurn,
|
||||||
|
sel,
|
||||||
|
val,
|
||||||
|
setVal,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
view: DavinciView;
|
||||||
|
me: string | null;
|
||||||
|
players: Record<string, PublicUser>;
|
||||||
|
act: (a: unknown) => void;
|
||||||
|
pending: boolean;
|
||||||
|
myTurn: boolean;
|
||||||
|
sel: Sel | null;
|
||||||
|
val: TileValue | null;
|
||||||
|
setVal: (v: TileValue | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
if (v.phase === 'finished') {
|
||||||
|
return (
|
||||||
|
<div className="dv-panel" aria-live="polite">
|
||||||
|
<strong>게임이 끝났어요</strong>
|
||||||
|
<span>{v.final?.summary}</span>
|
||||||
|
<span className="small muted">모든 타일을 공개했어요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (v.phase === 'chooseStart') return <StartChooser view={v} me={me} act={act} pending={pending} />;
|
||||||
|
if (!myTurn) {
|
||||||
|
const who = nick(players, v.current);
|
||||||
|
const doing: Record<PhaseKind, string> = {
|
||||||
|
chooseStart: '',
|
||||||
|
draw: '타일을 가져오는 중이에요',
|
||||||
|
guess: '추리하는 중이에요',
|
||||||
|
afterCorrect: '맞혔어요! 계속할지 고르는 중이에요',
|
||||||
|
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
|
||||||
|
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
|
||||||
|
finished: '',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>
|
||||||
|
{who}님이 {doing[v.phase]}
|
||||||
|
</strong>
|
||||||
|
{me && v.seats.includes(me) && !v.alive[me] && <span className="small muted">나는 탈락했어요. 끝까지 구경할 수 있어요.</span>}
|
||||||
|
<span className="small muted">공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (v.phase) {
|
||||||
|
case 'draw':
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>더미에서 타일을 하나 가져와 주세요.</strong>
|
||||||
|
<span className="small muted">
|
||||||
|
{v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'guess':
|
||||||
|
return sel ? <NumberPad view={v} players={players} act={act} pending={pending} sel={sel} val={val} setVal={setVal} onCancel={onCancel} /> : (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>맞힐 상대 타일을 눌러 주세요.</strong>
|
||||||
|
<span className="small muted">파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'afterCorrect':
|
||||||
|
return (
|
||||||
|
<div className="dv-panel" aria-live="polite">
|
||||||
|
<strong style={{ color: 'var(--success)', fontSize: '1.15rem' }}>맞혔어요!</strong>
|
||||||
|
<div className="dv-big">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'continue' })}>
|
||||||
|
한 번 더 맞히기
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'stop' })}>
|
||||||
|
여기서 멈추기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="small muted">
|
||||||
|
{v.pending
|
||||||
|
? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.'
|
||||||
|
: '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'placeJoker':
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>조커를 넣을 자리를 골라 주세요.</strong>
|
||||||
|
<span className="small muted">내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'revealOwn':
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>틀렸어요. 공개할 내 타일을 하나 골라 주세요.</strong>
|
||||||
|
<span className="small muted">더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberPad({
|
||||||
|
view: v,
|
||||||
|
players,
|
||||||
|
act,
|
||||||
|
pending,
|
||||||
|
sel,
|
||||||
|
val,
|
||||||
|
setVal,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
view: DavinciView;
|
||||||
|
players: Record<string, PublicUser>;
|
||||||
|
act: (a: unknown) => void;
|
||||||
|
pending: boolean;
|
||||||
|
sel: Sel;
|
||||||
|
val: TileValue | null;
|
||||||
|
setVal: (v: TileValue | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const tile = v.rows[sel.target]?.[sel.index];
|
||||||
|
const padRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
padRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
||||||
|
}, [sel.target, sel.index]);
|
||||||
|
if (!tile) return null;
|
||||||
|
const seen = visibleCombos(v);
|
||||||
|
const cands = tile.candidates ? new Set(tile.candidates.map(String)) : null;
|
||||||
|
const values: TileValue[] = [...NUMBERS, ...(v.options.jokers ? ['joker' as const] : [])];
|
||||||
|
const impossible = (x: TileValue) => seen.has(`${tile.color}:${x}`) || tile.wrong.includes(x) || (!!cands && !cands.has(String(x)));
|
||||||
|
const warn = val === null ? null : seen.has(`${tile.color}:${val}`) ? `${COLOR_SHORT[tile.color]} ${valueKo(val)}은(는) 이미 보이는 곳에 있어요.` : tile.wrong.includes(val) ? '이 타일에 이미 틀린 숫자예요.' : cands && !cands.has(String(val)) ? '공개된 정보로 보면 어려운 숫자예요.' : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dv-panel"
|
||||||
|
role="group"
|
||||||
|
aria-label="숫자 고르기"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요?
|
||||||
|
</strong>
|
||||||
|
{tile.wrong.length > 0 && <span className="small muted">이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}</span>}
|
||||||
|
<div className="dv-pad" ref={padRef}>
|
||||||
|
{values.map((x) => (
|
||||||
|
<button
|
||||||
|
key={String(x)}
|
||||||
|
type="button"
|
||||||
|
className={`${x === 'joker' ? 'joker' : ''}${(v.options.deductionHelper || seen.has(`${tile.color}:${x}`)) && impossible(x) ? ' unlikely' : ''}`}
|
||||||
|
aria-pressed={val === x}
|
||||||
|
aria-label={x === 'joker' ? '조커' : String(x)}
|
||||||
|
onClick={() => setVal(x)}
|
||||||
|
>
|
||||||
|
{x === 'joker' ? '조커 (−)' : x}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="small" style={{ minHeight: '1.4em', color: 'var(--warning)' }}>
|
||||||
|
{warn ? `${warn} 그래도 말할 수 있어요.` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
disabled={val === null || pending}
|
||||||
|
onClick={() => act({ type: 'guess', target: sel.target, index: sel.index, value: val })}
|
||||||
|
>
|
||||||
|
{val === null ? '숫자를 고르세요' : `${valueKo(val)}, 이 숫자!`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartChooser({ view: v, me, act, pending }: { view: DavinciView; me: string | null; act: (a: unknown) => void; pending: boolean }) {
|
||||||
|
const n = v.startCount;
|
||||||
|
const lim = v.pickLimits ?? { black: n, white: n };
|
||||||
|
const fallbackBlack = Math.min(Math.ceil(n / 2), lim.black);
|
||||||
|
const [black, setBlack] = useState<number>(fallbackBlack);
|
||||||
|
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
|
||||||
|
const total = v.picked ? v.seats.filter((p) => v.alive[p]).length : 0;
|
||||||
|
if (!me || !v.seats.includes(me) || !v.alive[me]) {
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>모두 시작 타일 색을 고르는 중이에요 ({done}/{total})</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (v.myPick) {
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>
|
||||||
|
검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요.
|
||||||
|
</strong>
|
||||||
|
<span className="small muted">
|
||||||
|
다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const okPick = (b: number) => b <= lim.black && n - b <= lim.white;
|
||||||
|
const chosen = okPick(black) ? black : null;
|
||||||
|
return (
|
||||||
|
<div className="dv-panel">
|
||||||
|
<strong>시작 타일 {n}개의 색을 골라 주세요.</strong>
|
||||||
|
<span className="small muted">숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요.</span>
|
||||||
|
<div className="dv-split" role="group" aria-label="시작 타일 색">
|
||||||
|
{Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => (
|
||||||
|
<button key={b} type="button" aria-pressed={chosen === b} disabled={!okPick(b)} onClick={() => setBlack(b)} aria-label={`검정 ${b}개 · 흰색 ${n - b}개`}>
|
||||||
|
<span aria-hidden>
|
||||||
|
{Array.from({ length: n }, (_, k) => (
|
||||||
|
<span key={k} className={`dv-mini ${k < b ? 'black' : 'white'}`} style={{ marginRight: 2 }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
검 {b} · 흰 {n - b}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(lim.black < n || lim.white < n) && (
|
||||||
|
<span className="small muted">
|
||||||
|
먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-primary" disabled={chosen === null || pending} onClick={() => act({ type: 'chooseStart', black: chosen, white: n - chosen! })}>
|
||||||
|
이렇게 받기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- side
|
||||||
|
|
||||||
|
function DavinciSide({ view: v, me, players, deadline }: BoardProps<DavinciView>) {
|
||||||
|
const running = v.phase !== 'finished';
|
||||||
|
const ranking = v.final?.ranking ?? null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
<div className="card stack" style={{ gap: 8, padding: 12 }}>
|
||||||
|
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<strong>{running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'}</strong>
|
||||||
|
{running && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
||||||
|
</div>
|
||||||
|
<span className="small muted">{optionsSummary(v.options)}</span>
|
||||||
|
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="참가자">
|
||||||
|
{v.seats.map((p) => {
|
||||||
|
const st = v.stats[p] ?? { guesses: 0, correct: 0 };
|
||||||
|
const out = v.eliminationOrder.indexOf(p);
|
||||||
|
return (
|
||||||
|
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: v.alive[p] || !running ? 1 : 0.6 }}>
|
||||||
|
<Avatar user={userOf(players, p)} size={24} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }}>
|
||||||
|
{nick(players, p)}
|
||||||
|
{running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''}
|
||||||
|
{v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="small muted" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{ranking && (
|
||||||
|
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||||
|
<strong>최종 순위</strong>
|
||||||
|
{ranking.map((g, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<details className="card" open style={{ padding: 12 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>추리 기록 ({v.history.length})</summary>
|
||||||
|
{v.history.length === 0 ? (
|
||||||
|
<p className="small muted" style={{ margin: '8px 0 0' }}>
|
||||||
|
아직 추리가 없어요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="dv-log" style={{ marginTop: 8 }} tabIndex={0} aria-label="추리 기록 목록">
|
||||||
|
{[...v.history].reverse().map((g, i) => (
|
||||||
|
<li key={v.history.length - i} className={g.correct ? 'hit' : 'miss'}>
|
||||||
|
{guessLine(g, players)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
<details className="card" style={{ padding: 12 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>정렬 규칙</summary>
|
||||||
|
<SortPicture tieOrder={v.options.tieOrder} />
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortPicture({ tieOrder }: { tieOrder: DavinciOptions['tieOrder'] }) {
|
||||||
|
const tiles: [TColor, number][] = tieOrder === 'black-left' ? [['black', 2], ['black', 5], ['white', 5], ['white', 9]] : [['black', 2], ['white', 5], ['black', 5], ['white', 9]];
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 6, marginTop: 8 }}>
|
||||||
|
<div className="row" style={{ gap: 6 }} role="img" aria-label={`정렬 예: ${tiles.map(([c, n]) => `${COLOR_SHORT[c]} ${n}`).join(', ')}`}>
|
||||||
|
{tiles.map(([c, n], i) => (
|
||||||
|
<Tile key={i} color={c} n={n} revealed={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="small muted">작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- status
|
||||||
|
|
||||||
|
function isMyTurn(v: DavinciView, me: string | null): boolean {
|
||||||
|
if (!me || !v.seats.includes(me) || v.phase === 'finished' || !v.alive[me]) return false;
|
||||||
|
if (v.phase === 'chooseStart') return !!v.picked && !v.picked[me];
|
||||||
|
return v.current === me;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
|
||||||
|
if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요';
|
||||||
|
if (v.phase === 'chooseStart') return isMyTurn(v, me) ? '내 차례예요. 시작 타일 색을 골라 주세요' : '다른 사람이 시작 타일 색을 고르는 중이에요';
|
||||||
|
if (isMyTurn(v, me)) {
|
||||||
|
switch (v.phase) {
|
||||||
|
case 'draw':
|
||||||
|
return '내 차례예요. 더미에서 타일을 가져와 주세요';
|
||||||
|
case 'guess':
|
||||||
|
return '내 차례예요. 상대 타일을 골라 숫자를 맞혀 보세요';
|
||||||
|
case 'afterCorrect':
|
||||||
|
return '내 차례예요. 맞혔어요! 한 번 더 할지 멈출지 골라 주세요';
|
||||||
|
case 'placeJoker':
|
||||||
|
return '내 차례예요. 조커를 넣을 자리를 골라 주세요';
|
||||||
|
case 'revealOwn':
|
||||||
|
return '내 차례예요. 공개할 내 타일을 골라 주세요';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = !!me && v.seats.includes(me) && !v.alive[me] ? '탈락했어요 · ' : '';
|
||||||
|
return `${out}${nick(players, v.current)}님 차례예요`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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 }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Options({ value: o, onChange }: OptionsProps<DavinciOptions>) {
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const set = (patch: Partial<DavinciOptions>) => onChange({ ...o, ...patch });
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 16 }}>
|
||||||
|
<Field label="조커">
|
||||||
|
<Seg label="조커" value={o.jokers} options={[[false, '안 씀'], [true, '씀(검정·흰색 각 1개)']]} onChange={(jokers) => set({ jokers })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="시작 타일 수">
|
||||||
|
<Seg label="시작 타일 수" value={o.startTiles} options={[['auto', '자동(2·3명 4개, 4명 3개)'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(startTiles) => set({ startTiles })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="추리 제한 시간">
|
||||||
|
<Seg label="추리 제한 시간" value={o.turnSeconds} options={[[20, '20초'], [30, '30초'], [45, '45초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="가능한 숫자 후보 표시(초보 도움)">
|
||||||
|
<Seg label="후보 표시" value={o.deductionHelper} options={[[false, '끄기'], [true, '켜기']]} onChange={(deductionHelper) => set({ deductionHelper })} />
|
||||||
|
</Field>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||||
|
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||||
|
</button>
|
||||||
|
{more && (
|
||||||
|
<>
|
||||||
|
<Field label="같은 숫자일 때 색 순서">
|
||||||
|
<Seg label="같은 숫자일 때 색 순서" value={o.tieOrder} options={[['black-left', '검정이 왼쪽'], ['white-left', '흰색이 왼쪽']]} onChange={(tieOrder) => set({ tieOrder })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="타일을 가져올 때">
|
||||||
|
<Seg label="타일을 가져올 때" value={o.chooseColors} options={[[true, '색을 골라서'], [false, '무작위로']]} onChange={(chooseColors) => set({ chooseColors })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="더미가 빈 뒤 틀렸을 때">
|
||||||
|
<Seg label="더미가 빈 뒤 틀렸을 때" value={o.emptyPoolPenalty} options={[['reveal-own', '내 숨은 타일 1개 공개'], ['none', '벌칙 없음']]} onChange={(emptyPoolPenalty) => set({ emptyPoolPenalty })} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsSummary(o: DavinciOptions): string {
|
||||||
|
return [
|
||||||
|
o.jokers ? '조커 사용' : '조커 없음',
|
||||||
|
o.startTiles === 'auto' ? '시작 타일 자동' : `시작 타일 ${o.startTiles}개`,
|
||||||
|
o.chooseColors ? '색 골라 가져오기' : '무작위로 가져오기',
|
||||||
|
`추리 ${o.turnSeconds}초`,
|
||||||
|
...(o.tieOrder === 'white-left' ? ['같은 숫자는 흰색 먼저'] : []),
|
||||||
|
...(o.deductionHelper ? ['후보 표시'] : []),
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rules({ options }: { options?: DavinciOptions }) {
|
||||||
|
const o = options;
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<p>
|
||||||
|
검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, <strong>상대 타일의 숫자를 추리해 맞히는</strong> 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은
|
||||||
|
타일이 남은 사람이 이겨요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>준비</strong>: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요.
|
||||||
|
{o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>정렬</strong>: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지
|
||||||
|
않아요.
|
||||||
|
</p>
|
||||||
|
<SortPicture tieOrder={o?.tieOrder ?? 'black-left'} />
|
||||||
|
<p>
|
||||||
|
<strong>내 차례</strong>: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>맞히면</strong> 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>틀리면</strong> 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>더미가 비면</strong> 가져오지 않고 바로 추리해요. 이때 틀리면{' '}
|
||||||
|
{o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'}
|
||||||
|
</p>
|
||||||
|
{o?.jokers && (
|
||||||
|
<p>
|
||||||
|
<strong>조커</strong>: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때
|
||||||
|
"조커"라고 말할 수도 있어요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 <strong>추리 기록</strong>에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui: GameUI<DavinciView, DavinciOptions> = {
|
||||||
|
Board: DavinciBoard,
|
||||||
|
Side: DavinciSide,
|
||||||
|
statusText,
|
||||||
|
isMyTurn,
|
||||||
|
Options,
|
||||||
|
optionsSummary,
|
||||||
|
Rules,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ui;
|
||||||
@@ -13,6 +13,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
yut: () => import('./yut'),
|
yut: () => import('./yut'),
|
||||||
'one-card': () => import('./one-card'),
|
'one-card': () => import('./one-card'),
|
||||||
rummikub: () => import('./rummikub'),
|
rummikub: () => import('./rummikub'),
|
||||||
|
'davinci-code': () => import('./davinci-code'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -21,5 +21,10 @@ export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
|||||||
rummikub: '🔢',
|
rummikub: '🔢',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
|
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
||||||
|
'davinci-code': '🔢',
|
||||||
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
||||||
|
|||||||
@@ -319,6 +319,40 @@ const PLANS: Record<string, Plan> = {
|
|||||||
if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`);
|
if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`);
|
||||||
if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') });
|
if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') });
|
||||||
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
|
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
|
||||||
|
'davinci-code': {
|
||||||
|
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
|
||||||
|
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".
|
||||||
|
drive: async (pages) => {
|
||||||
|
for (const p of pages) {
|
||||||
|
await p.getByRole('button', { name: '검정 2개 · 흰색 2개' }).click();
|
||||||
|
await p.getByRole('button', { name: '이렇게 받기' }).click();
|
||||||
|
}
|
||||||
|
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||||
|
for (let turn = 0; turn < 5; turn++) {
|
||||||
|
let mover: Page | null = null;
|
||||||
|
for (let t = 0; t < 100 && !mover; t++) {
|
||||||
|
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
|
||||||
|
if (!mover) await pages[0]!.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
if (!mover) throw new Error(`turn ${turn + 1}: nobody has the turn`);
|
||||||
|
const pile = mover.getByRole('button', { name: turn % 2 ? /^흰색 타일 가져오기/ : /^검은색 타일 가져오기/ });
|
||||||
|
await pile.click();
|
||||||
|
await mover.getByText('맞힐 상대 타일을 눌러 주세요.').waitFor();
|
||||||
|
await mover.locator('.dv-opps .dv-pick.can').nth(turn % 3).click();
|
||||||
|
await mover.getByRole('button', { name: String((turn * 5) % 12), exact: true }).click();
|
||||||
|
if (turn < 2) await mover.screenshot({ path: join(SHOTS, `davinci-code-3-pad-${mover === pages[0] ? 'pc' : 'phone'}.png`), fullPage: true });
|
||||||
|
await mover.getByRole('button', { name: /이 숫자!$/ }).click();
|
||||||
|
// A hit → stop (the drawn tile goes in hidden); a miss ends the turn by itself.
|
||||||
|
for (let t = 0; t < 60; t++) {
|
||||||
|
const stop = mover.getByRole('button', { name: '여기서 멈추기' });
|
||||||
|
if (await stop.isVisible().catch(() => false)) await stop.click().catch(() => {});
|
||||||
|
if (!(await myTurn(mover))) break;
|
||||||
|
await mover.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
if (await myTurn(mover)) throw new Error(`turn ${turn + 1}: turn did not pass`);
|
||||||
|
}
|
||||||
|
// Both screens show the guess log with every guess.
|
||||||
|
for (const p of pages) await p.getByText('추리 기록 (5)').waitFor();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
othello: {
|
othello: {
|
||||||
|
|||||||
634
packages/games/src/davinci-code/davinci-code.test.ts
Normal file
634
packages/games/src/davinci-code/davinci-code.test.ts
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
|
||||||
|
import {
|
||||||
|
allTileIds,
|
||||||
|
autoAction,
|
||||||
|
candidatesFor,
|
||||||
|
davinci,
|
||||||
|
insertIndex,
|
||||||
|
sortKey,
|
||||||
|
tileOf,
|
||||||
|
type DavinciAction,
|
||||||
|
type DavinciOptions,
|
||||||
|
type DavinciState,
|
||||||
|
type DavinciView,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
let clock = 1_000;
|
||||||
|
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||||
|
|
||||||
|
function setup(opts: Partial<DavinciOptions> = {}, players = ['A', 'B'], seed = 1): DavinciState {
|
||||||
|
return davinci.setup({ players, options: davinci.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game already in play with the given rows (left → right as written, all hidden), `first` to move
|
||||||
|
* and the pool fronts as given (rest of the tiles follow in id order).
|
||||||
|
*/
|
||||||
|
function rigged(
|
||||||
|
rows: Record<string, string[]>,
|
||||||
|
opts: Partial<DavinciOptions> = {},
|
||||||
|
pool: { black?: string[]; white?: string[] } = {},
|
||||||
|
first?: string,
|
||||||
|
): DavinciState {
|
||||||
|
const players = Object.keys(rows);
|
||||||
|
const s = setup({ chooseColors: true, ...opts }, players);
|
||||||
|
const used = new Set([...Object.values(rows).flat(), ...(pool.black ?? []), ...(pool.white ?? [])]);
|
||||||
|
for (const p of players) s.rows[p] = rows[p]!.map((id) => ({ id, revealed: false, wrong: [] }));
|
||||||
|
const rest = allTileIds(s.options.jokers).filter((id) => !used.has(id));
|
||||||
|
s.pool = {
|
||||||
|
black: [...(pool.black ?? []), ...rest.filter((id) => tileOf(id).color === 'black')],
|
||||||
|
white: [...(pool.white ?? []), ...rest.filter((id) => tileOf(id).color === 'white')],
|
||||||
|
};
|
||||||
|
s.jokerReserve = [];
|
||||||
|
s.phase = s.pool.black.length + s.pool.white.length ? { kind: 'draw' } : { kind: 'guess' };
|
||||||
|
s.current = first ?? players[0]!;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actE(s: DavinciState, p: string, a: DavinciAction): { state: DavinciState; events: GameEvent[] } {
|
||||||
|
const v = davinci.validate(s, p, a);
|
||||||
|
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||||
|
return davinci.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
|
||||||
|
}
|
||||||
|
const act = (s: DavinciState, p: string, a: DavinciAction) => actE(s, p, a).state;
|
||||||
|
const reject = (s: DavinciState, p: string, a: DavinciAction) => {
|
||||||
|
const v = davinci.validate(s, p, a);
|
||||||
|
return v.ok ? null : v.reason;
|
||||||
|
};
|
||||||
|
const ids = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.id);
|
||||||
|
const shown = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.revealed);
|
||||||
|
const guess = (target: string, index: number, value: number | 'joker'): DavinciAction => ({ type: 'guess', target, index, value });
|
||||||
|
|
||||||
|
/** Non-joker tiles of every row are in order; every tile exists exactly once. */
|
||||||
|
function checkInvariants(st: unknown): void {
|
||||||
|
const s = st as DavinciState;
|
||||||
|
const all = [...Object.values(s.rows).flat().map((t) => t.id), ...s.pool.black, ...s.pool.white, ...s.jokerReserve, ...(s.pending ? [s.pending] : [])];
|
||||||
|
const expected = allTileIds(s.options.jokers);
|
||||||
|
if (all.length !== expected.length || new Set(all).size !== all.length || !expected.every((id) => all.includes(id))) {
|
||||||
|
throw new Error(`tiles not conserved: ${all.length}/${expected.length}`);
|
||||||
|
}
|
||||||
|
for (const p of s.seats) {
|
||||||
|
const keys = s.rows[p]!.map((t) => tileOf(t.id)).filter((t) => t.n !== 'joker').map((t) => sortKey(t.color, t.n as number, s.options.tieOrder));
|
||||||
|
for (let i = 1; i < keys.length; i++) if (keys[i - 1]! >= keys[i]!) throw new Error(`row of ${p} not sorted: ${s.rows[p]!.map((t) => t.id)}`);
|
||||||
|
if (s.phase.kind !== 'chooseStart' && s.rows[p]!.length > 0) {
|
||||||
|
const hidden = s.rows[p]!.some((t) => !t.revealed);
|
||||||
|
if (s.alive[p] !== hidden && !s.left[p]) throw new Error(`alive flag of ${p} does not match hidden tiles`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.pool.black.some((id) => tileOf(id).color !== 'black') || s.pool.white.some((id) => tileOf(id).color !== 'white')) throw new Error('pool colour mixed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hidden tiles of others must be `{ color, revealed: false, wrong[, candidates] }` only. */
|
||||||
|
function checkViewShape(s: DavinciState, v: DavinciView, viewer: string | null): void {
|
||||||
|
for (const p of s.seats) {
|
||||||
|
v.rows[p]!.forEach((t, i) => {
|
||||||
|
const real = s.rows[p]![i]!;
|
||||||
|
if (t.color !== tileOf(real.id).color || t.revealed !== real.revealed) throw new Error('view row mismatch');
|
||||||
|
if (s.phase.kind === 'finished' || real.revealed || p === viewer) {
|
||||||
|
if (t.n !== tileOf(real.id).n) throw new Error('known tile without number');
|
||||||
|
} else {
|
||||||
|
const keys = Object.keys(t).sort().join(',');
|
||||||
|
if (keys !== 'color,revealed,wrong' && keys !== 'candidates,color,revealed,wrong') throw new Error(`hidden tile leaks fields: ${keys}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (v.pending && s.current !== viewer && ('n' in v.pending || 'id' in v.pending)) throw new Error('pending tile leaks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- setup
|
||||||
|
|
||||||
|
describe('셋업 (§4)', () => {
|
||||||
|
test('2·3인 4개, 4인 3개; 시작 타일에 조커 없음; 같은 시드 = 같은 분배', () => {
|
||||||
|
for (const [n, k] of [[2, 4], [3, 4], [4, 3]] as const) {
|
||||||
|
const players = ['A', 'B', 'C', 'D'].slice(0, n);
|
||||||
|
for (let seed = 1; seed <= 40; seed++) {
|
||||||
|
const s = setup({ chooseColors: false, jokers: true }, players, seed);
|
||||||
|
for (const p of players) {
|
||||||
|
expect(s.rows[p]).toHaveLength(k);
|
||||||
|
expect(s.rows[p]!.some((t) => tileOf(t.id).n === 'joker')).toBe(false);
|
||||||
|
}
|
||||||
|
expect([...s.pool.black, ...s.pool.white].filter((id) => id.endsWith('J')).sort()).toEqual(['bJ', 'wJ']);
|
||||||
|
checkInvariants(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9))).toBe(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9)));
|
||||||
|
expect(setup({ startTiles: 5 }, ['A', 'B', 'C', 'D']).startCount).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chooseColors=on: 동시에 색을 고르고, 다 고르면 분배; 조커는 분배 뒤 더미에 섞임', () => {
|
||||||
|
let s = setup({ jokers: true }, ['A', 'B', 'C']);
|
||||||
|
expect(s.phase.kind).toBe('chooseStart');
|
||||||
|
expect(davinci.activePlayers(s)).toEqual(['A', 'B', 'C']);
|
||||||
|
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 1, white: 3 });
|
||||||
|
expect(s.phase.kind).toBe('draw');
|
||||||
|
for (const p of ['A', 'B', 'C']) {
|
||||||
|
expect(s.rows[p]!.map((t) => tileOf(t.id).color).filter((c) => c === 'black')).toHaveLength(1);
|
||||||
|
expect(s.rows[p]!.some((t) => t.id.endsWith('J'))).toBe(false);
|
||||||
|
}
|
||||||
|
expect(s.pool.black).toContain('bJ');
|
||||||
|
expect(s.pool.white).toContain('wJ');
|
||||||
|
checkInvariants(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chooseStart (§8.2)', () => {
|
||||||
|
test('3인이 모두 흰4 → 12개로 충분', () => {
|
||||||
|
let s = setup({}, ['A', 'B', 'C']);
|
||||||
|
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 0, white: 4 });
|
||||||
|
expect(s.pool.white).toHaveLength(0);
|
||||||
|
expect(s.phase.kind).toBe('draw');
|
||||||
|
expect(Object.values(s.rows).flat().every((t) => tileOf(t.id).color === 'white')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인이 모두 검3 → 성공, 검정이 0개 남음; 남은 수를 넘는 선택은 거부', () => {
|
||||||
|
let s = setup({}, ['A', 'B', 'C', 'D']);
|
||||||
|
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 3, white: 0 });
|
||||||
|
expect(davinci.view(s, 'D').pickLimits).toEqual({ black: 3, white: 12 });
|
||||||
|
s = act(s, 'D', { type: 'chooseStart', black: 3, white: 0 });
|
||||||
|
expect(s.pool.black).toHaveLength(0);
|
||||||
|
expect(davinci.view(s, 'A').pool).toEqual({ black: 0, white: 12 });
|
||||||
|
expect(reject(s, s.current, { type: 'draw', color: 'black' })).toBe('검은색 타일이 더 이상 없어요.');
|
||||||
|
|
||||||
|
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
|
||||||
|
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
|
||||||
|
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
|
||||||
|
expect(reject(t, 'C', { type: 'chooseStart', black: 3, white: 2 })).toBe('검은색은 2개까지 고를 수 있어요.');
|
||||||
|
expect(reject(t, 'C', { type: 'chooseStart', black: 2, white: 2 })).toBe('시작 타일 5개를 골라 주세요.');
|
||||||
|
expect(reject(t, 'A', { type: 'chooseStart', black: 0, white: 5 })).toBe('이미 골랐어요.');
|
||||||
|
expect(reject(t, 'C', { type: 'draw', color: 'white' })).toBe('먼저 시작 타일을 골라 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('다른 사람의 선택은 제출 여부만 보임; 시간 초과는 반반(홀수면 검정 하나 더)', () => {
|
||||||
|
let s = setup({ startTiles: 3 }, ['A', 'B']);
|
||||||
|
s = act(s, 'A', { type: 'chooseStart', black: 3, white: 0 });
|
||||||
|
const vb = davinci.view(s, 'B');
|
||||||
|
expect(vb.picked).toEqual({ A: true, B: false });
|
||||||
|
expect(vb.myPick).toBeNull();
|
||||||
|
expect(JSON.stringify(davinci.view(s, null))).not.toContain('"black":3,"white":0');
|
||||||
|
expect(davinci.view(s, 'A').myPick).toEqual({ black: 3, white: 0 });
|
||||||
|
expect(autoAction(s, 'B')).toEqual({ type: 'chooseStart', black: 2, white: 1 });
|
||||||
|
// The start choice has one shared deadline (15 s), not restarted by each pick.
|
||||||
|
const fresh = setup({}, ['A', 'B']);
|
||||||
|
expect(fresh.deadlineAt).toBe(15_000);
|
||||||
|
expect(davinci.apply(fresh, 'A', { type: 'chooseStart', black: 2, white: 2 }, { rng: rng(), now: 5000 }).state.deadlineAt).toBe(15_000);
|
||||||
|
// Not enough black left → the rest white.
|
||||||
|
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
|
||||||
|
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
|
||||||
|
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
|
||||||
|
expect(autoAction(t, 'C')).toEqual({ type: 'chooseStart', black: 2, white: 3 });
|
||||||
|
t = act(t, 'C', { type: 'timeout' });
|
||||||
|
expect(t.rows.C!.map((x) => tileOf(x.id).color).sort()).toEqual(['black', 'black', 'white', 'white', 'white']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- ordering
|
||||||
|
|
||||||
|
describe('정렬 규칙 (§5.1)', () => {
|
||||||
|
function dealt(order: DavinciOptions['tieOrder']) {
|
||||||
|
let s = setup({ tieOrder: order, startTiles: 3 }, ['A', 'B']);
|
||||||
|
s.pool.black = ['b5', 'b2', ...s.pool.black.filter((x) => x !== 'b5' && x !== 'b2')];
|
||||||
|
s.pool.white = ['w5', ...s.pool.white.filter((x) => x !== 'w5')];
|
||||||
|
s = act(s, 'B', { type: 'chooseStart', black: 0, white: 3 });
|
||||||
|
s = act(s, 'A', { type: 'chooseStart', black: 2, white: 1 });
|
||||||
|
return ids(s, 'A');
|
||||||
|
}
|
||||||
|
test('[흰5, 검5, 검2] → [검2, 검5, 흰5]; white-left → [검2, 흰5, 검5]', () => {
|
||||||
|
expect(dealt('black-left')).toEqual(['b2', 'b5', 'w5']);
|
||||||
|
expect(dealt('white-left')).toEqual(['b2', 'w5', 'b5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertIndex: 새 타일은 정렬 위치, 조커는 건너뛰고 제자리', () => {
|
||||||
|
const row = (xs: string[]) => xs.map(tileOf);
|
||||||
|
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'black-left')).toBe(1);
|
||||||
|
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'white-left')).toBe(2);
|
||||||
|
expect(insertIndex(row(['b2', 'w5']), 'white', 0, 'black-left')).toBe(0);
|
||||||
|
expect(insertIndex(row(['b2', 'w5']), 'white', 11, 'black-left')).toBe(2);
|
||||||
|
// [2, J, 7] + 5 → left of the joker: [2, 5, J, 7]
|
||||||
|
expect(insertIndex(row(['b2', 'bJ', 'w7']), 'white', 5, 'black-left')).toBe(1);
|
||||||
|
expect(insertIndex(row(['bJ', 'w7']), 'white', 9, 'black-left')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('새 타일은 끼워지고 기존 타일 위치(순서)는 바뀌지 않음', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w2', 'b6', 'w9'] }, {}, { white: ['w7'] });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'white' });
|
||||||
|
s = act(s, 'A', guess('B', 0, 3));
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'w7', 'b8', 'w10']);
|
||||||
|
expect(s.lastPlaced).toEqual({ player: 'A', index: 2, revealed: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- turns
|
||||||
|
|
||||||
|
describe('턴 진행 (§5.2)', () => {
|
||||||
|
test('맞힘 → 계속 → 틀림: 맞힌 타일은 공개된 채, 이번 턴 타일은 앞면으로 들어감', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { white: ['w9'] });
|
||||||
|
const r = actE(s, 'A', { type: 'draw', color: 'white' });
|
||||||
|
expect(r.events).toEqual([{ type: 'drawn', player: 'A', color: 'white' }]);
|
||||||
|
s = r.state;
|
||||||
|
expect(davinci.view(s, 'B').pending).toEqual({ color: 'white' });
|
||||||
|
expect(davinci.view(s, 'A').pending).toEqual({ color: 'white', n: 9, id: 'w9' });
|
||||||
|
s = act(s, 'A', guess('B', 1, 3));
|
||||||
|
expect(s.phase.kind).toBe('afterCorrect');
|
||||||
|
expect(shown(s, 'B')).toEqual([false, true, false, false]);
|
||||||
|
s = act(s, 'A', { type: 'continue' });
|
||||||
|
expect(s.phase.kind).toBe('guess');
|
||||||
|
s = act(s, 'A', guess('B', 3, 10));
|
||||||
|
expect(shown(s, 'B')).toEqual([false, true, false, false]);
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b8', 'w9', 'w10']);
|
||||||
|
expect(shown(s, 'A')).toEqual([false, false, false, true, false]);
|
||||||
|
expect(s.rows.B![3]!.wrong).toEqual([10]);
|
||||||
|
expect([s.current, s.phase.kind]).toEqual(['B', 'draw']);
|
||||||
|
expect(s.history.map((h) => [h.by, h.target, h.index, h.said, h.correct])).toEqual([
|
||||||
|
['A', 'B', 1, 3, true],
|
||||||
|
['A', 'B', 3, 10, false],
|
||||||
|
]);
|
||||||
|
expect(s.stats.A).toEqual({ guesses: 2, correct: 1 });
|
||||||
|
checkInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('맞힘 → 멈춤: 이번 턴 타일은 숨긴 채로; 상대 view에는 색만', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
s = act(s, 'A', guess('B', 0, 0));
|
||||||
|
const r = actE(s, 'A', { type: 'stop' });
|
||||||
|
s = r.state;
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'b8', 'w10']);
|
||||||
|
expect(shown(s, 'A')).toEqual([false, false, false, false, false]);
|
||||||
|
expect(r.events.find((e) => e.type === 'placed')).toEqual({ type: 'placed', player: 'A', index: 2, revealed: false, color: 'black' });
|
||||||
|
for (const viewer of ['B', null]) {
|
||||||
|
const v = davinci.view(s, viewer);
|
||||||
|
expect(v.rows.A![2]).toEqual({ color: 'black', revealed: false, wrong: [] });
|
||||||
|
expect(JSON.stringify(v)).not.toContain('"b5"');
|
||||||
|
checkViewShape(s, v, viewer);
|
||||||
|
}
|
||||||
|
expect(davinci.view(s, 'A').rows.A![2]).toMatchObject({ n: 5, id: 'b5', revealed: false });
|
||||||
|
expect(s.current).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마지막 숨은 타일을 맞히면 즉시 탈락; 2인이면 바로 끝(afterCorrect 없음)', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
|
||||||
|
for (let i = 0; i < 3; i++) s.rows.B![i]!.revealed = true;
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
const r = actE(s, 'A', guess('B', 3, 11));
|
||||||
|
s = r.state;
|
||||||
|
expect(s.phase.kind).toBe('finished');
|
||||||
|
expect(s.alive.B).toBe(false);
|
||||||
|
expect(r.events.map((e) => e.type)).toEqual(['guessed', 'revealed', 'eliminated', 'placed', 'gameEnded']);
|
||||||
|
expect(davinci.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
|
||||||
|
expect(davinci.result(s)!.summary).toContain('숨은 타일 5개가 끝까지 풀리지 않아 이겼어요');
|
||||||
|
// finalRows: everything is public after the game.
|
||||||
|
const v = davinci.view(s, 'B');
|
||||||
|
expect(v.rows.A!.map((t) => t.n)).toEqual([1, 4, 5, 8, 10]);
|
||||||
|
expect(davinci.secretsOf!(s, 'A').filter((x) => ids(s, 'A').includes(x))).toEqual([]);
|
||||||
|
expect(davinci.activePlayers(s)).toEqual([]);
|
||||||
|
expect(davinci.deadline!(s)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3인: 한 명이 탈락해도 계속; 순위는 늦게 탈락한 순', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 }, { black: ['b5'] });
|
||||||
|
for (let i = 0; i < 2; i++) s.rows.B![i]!.revealed = true;
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
s = act(s, 'A', guess('B', 2, 6));
|
||||||
|
expect([s.alive.B, s.phase.kind]).toEqual([false, 'afterCorrect']);
|
||||||
|
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
|
||||||
|
s = act(s, 'A', { type: 'continue' });
|
||||||
|
expect(reject(s, 'A', guess('B', 2, 6))).toBe('이미 탈락한 사람의 타일이에요.');
|
||||||
|
s.rows.C![0]!.revealed = true;
|
||||||
|
s.rows.C![1]!.revealed = true;
|
||||||
|
s = act(s, 'A', guess('C', 2, 5));
|
||||||
|
expect(s.phase.kind).toBe('finished');
|
||||||
|
expect(davinci.result(s)!.ranking).toEqual([['A'], ['C'], ['B']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('더미가 빈 뒤: draw 생략, 틀리면 revealOwn, 마지막 숨은 타일 공개 시 본인 탈락', () => {
|
||||||
|
const all = allTileIds(false);
|
||||||
|
const A = ['b1', 'w4'];
|
||||||
|
const B = all.filter((x) => !A.includes(x));
|
||||||
|
let s = rigged({ A, B: B.slice(0, 11), C: B.slice(11) }, {}, {}, 'A');
|
||||||
|
expect(s.pool.black.length + s.pool.white.length).toBe(0);
|
||||||
|
expect(s.phase.kind).toBe('guess');
|
||||||
|
s.rows.A![0]!.revealed = true;
|
||||||
|
s = act(s, 'A', guess('B', 0, 11));
|
||||||
|
expect(s.phase.kind).toBe('revealOwn');
|
||||||
|
expect(reject(s, 'A', { type: 'revealOwn', index: 0 })).toBe('이미 공개된 타일이에요.');
|
||||||
|
s = act(s, 'A', { type: 'revealOwn', index: 1 });
|
||||||
|
expect(s.alive.A).toBe(false);
|
||||||
|
expect(s.eliminationOrder).toEqual(['A']);
|
||||||
|
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
|
||||||
|
checkInvariants(s);
|
||||||
|
// 2 players: self-elimination ends the game with the opponent winning.
|
||||||
|
let t = rigged({ A: ['b1', 'w4'], B: all.filter((x) => x !== 'b1' && x !== 'w4') }, {}, {}, 'A');
|
||||||
|
t.rows.A![0]!.revealed = true;
|
||||||
|
t = act(t, 'A', guess('B', 0, 11));
|
||||||
|
t = act(t, 'A', { type: 'revealOwn', index: 1 });
|
||||||
|
expect(davinci.result(t)!.ranking).toEqual([['B'], ['A']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emptyPoolPenalty=none: 더미가 빈 뒤 틀려도 벌칙 없이 턴 종료', () => {
|
||||||
|
const all = allTileIds(false);
|
||||||
|
let s = rigged({ A: all.slice(0, 12), B: all.slice(12) }, { emptyPoolPenalty: 'none' }, {}, 'A');
|
||||||
|
s = act(s, 'A', guess('B', 0, 5));
|
||||||
|
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
|
||||||
|
expect(shown(s, 'A').every((x) => !x)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('조커 (§5.1, §5.2)', () => {
|
||||||
|
test('조커를 가져와 틀리면 placeJoker에서 고른 위치에 앞면으로; 이후 정렬은 조커를 옮기지 않음', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, { jokers: true }, { black: ['bJ', 'b5'], white: ['w6'] });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
expect(davinci.view(s, 'A').pending).toEqual({ color: 'black', n: 'joker', id: 'bJ' });
|
||||||
|
s = act(s, 'A', guess('B', 0, 'joker'));
|
||||||
|
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: true });
|
||||||
|
expect(reject(s, 'A', { type: 'placeJoker', index: 4 })).toBeNull();
|
||||||
|
expect(reject(s, 'A', { type: 'placeJoker', index: 5 })).toBe('그 자리에는 놓을 수 없어요.');
|
||||||
|
s = act(s, 'A', { type: 'placeJoker', index: 2 });
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'bJ', 'b8', 'w10']);
|
||||||
|
expect(shown(s, 'A')).toEqual([false, false, true, false, false]);
|
||||||
|
expect(davinci.view(s, 'B').rows.A![2]).toMatchObject({ n: 'joker', revealed: true });
|
||||||
|
// B's turn, then A draws b5 → it goes left of the joker, which keeps its place.
|
||||||
|
s = act(s, 'B', { type: 'draw', color: 'white' });
|
||||||
|
s = act(s, 'B', guess('A', 0, 9));
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
s = act(s, 'A', guess('B', 0, 0));
|
||||||
|
s = act(s, 'A', { type: 'stop' });
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'bJ', 'b8', 'w10']);
|
||||||
|
checkInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('조커를 가져와 맞히고 멈추면 숨긴 채로 고른 위치에; 시간 초과는 맨 오른쪽', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { jokers: true }, { white: ['wJ'] });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'white' });
|
||||||
|
s = act(s, 'A', guess('B', 0, 0));
|
||||||
|
s = act(s, 'A', { type: 'stop' });
|
||||||
|
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: false });
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'placeJoker', index: 2 });
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'wJ']);
|
||||||
|
expect(shown(s, 'A')).toEqual([false, false, false]);
|
||||||
|
expect(davinci.view(s, 'B').rows.A![2]).toEqual({ color: 'white', revealed: false, wrong: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('거부 (§8.2)', () => {
|
||||||
|
test('공개된 타일, 탈락자 타일, 자기 타일, 범위 밖 값, 조커 꺼짐, 차례 아님', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 });
|
||||||
|
expect(reject(s, 'B', { type: 'draw', color: 'black' })).toBe('지금은 내 차례가 아니에요.');
|
||||||
|
expect(reject(s, 'A', { type: 'draw' })).toBe('가져올 색을 골라 주세요.');
|
||||||
|
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
s.rows.B![1]!.revealed = true;
|
||||||
|
s.alive.C = false;
|
||||||
|
for (const t of s.rows.C!) t.revealed = true;
|
||||||
|
expect(reject(s, 'A', guess('B', 1, 3))).toBe('이미 공개된 타일이에요.');
|
||||||
|
expect(reject(s, 'A', guess('C', 0, 0))).toBe('이미 탈락한 사람의 타일이에요.');
|
||||||
|
expect(reject(s, 'A', guess('A', 0, 1))).toBe('자기 타일은 고를 수 없어요.');
|
||||||
|
expect(reject(s, 'A', guess('B', 3, 1))).toBe('그 자리에는 타일이 없어요.');
|
||||||
|
expect(reject(s, 'A', guess('Z', 0, 1))).toBe('이 게임의 플레이어가 아니에요.');
|
||||||
|
for (const bad of [12, -1, 3.5]) expect(reject(s, 'A', guess('B', 0, bad))).toBe('0부터 11 사이 숫자를 골라 주세요.');
|
||||||
|
expect(reject(s, 'A', guess('B', 0, 'joker'))).toBe('이 방은 조커를 쓰지 않아요.');
|
||||||
|
expect(reject(s, 'A', { type: 'stop' })).toBe('맞힌 뒤에만 멈출 수 있어요.');
|
||||||
|
expect(reject(s, 'A', { type: 'continue' })).toBe('맞힌 뒤에만 계속할 수 있어요.');
|
||||||
|
expect(reject(s, 'C', { type: 'stop' })).toBe('탈락해서 더 할 수 없어요.');
|
||||||
|
expect(reject(s, 'X', { type: 'stop' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||||
|
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
|
||||||
|
// Logically impossible guesses (already visible combination) are still allowed (§5.2).
|
||||||
|
expect(reject(s, 'A', guess('B', 0, 3))).toBeNull();
|
||||||
|
// chooseColors=off: no colour choice.
|
||||||
|
const off = rigged({ A: ['b1'], B: ['b0'] }, { chooseColors: false });
|
||||||
|
expect(reject(off, 'A', { type: 'draw', color: 'black' })).toBe('이 방은 색을 고르지 않고 무작위로 가져와요.');
|
||||||
|
expect(reject(off, 'A', { type: 'draw' })).toBeNull();
|
||||||
|
const pool = off.pool.black.length + off.pool.white.length;
|
||||||
|
const after = act(off, 'A', { type: 'draw' });
|
||||||
|
expect(after.pool.black.length + after.pool.white.length).toBe(pool - 1);
|
||||||
|
expect(after.pending).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- timeouts
|
||||||
|
|
||||||
|
describe('시간 초과·연결 끊김 (§8.5)', () => {
|
||||||
|
test('draw: 더 많이 남은 색(같으면 검정); afterCorrect: 멈춤; revealOwn: 가장 왼쪽', () => {
|
||||||
|
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] });
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'black' });
|
||||||
|
s.pool.black.pop();
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'white' });
|
||||||
|
s.phase = { kind: 'afterCorrect' };
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'stop' });
|
||||||
|
s.phase = { kind: 'revealOwn' };
|
||||||
|
s.rows.A![0]!.revealed = true;
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'revealOwn', index: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guess: 숨은 타일이 가장 적은 상대의 가장 왼쪽 타일, 가능한 가장 작은 값', () => {
|
||||||
|
let s = rigged({ A: ['b0', 'w1', 'b3'], B: ['b2', 'w5', 'b7', 'w9'], C: ['w0', 'b1', 'w8', 'b10'] }, { startTiles: 3 });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'white' }); // w2 (first white left in id order)
|
||||||
|
s.rows.C![0]!.revealed = true;
|
||||||
|
s.rows.C![1]!.revealed = true;
|
||||||
|
// C has the fewest hidden tiles (w8, b10); its leftmost hidden tile is white and right of the revealed b1,
|
||||||
|
// so it is white 1 or more; w1 and w2 (pending) are mine → 3.
|
||||||
|
expect(autoAction(s, 'A')).toEqual({ type: 'guess', target: 'C', index: 2, value: 3 });
|
||||||
|
// Same public state, different hidden numbers → same guess (the auto-guess never reads hidden numbers).
|
||||||
|
const t = structuredClone(s);
|
||||||
|
t.rows.C![2]!.id = 'w11';
|
||||||
|
t.pool.white = t.pool.white.map((x) => (x === 'w11' ? 'w8' : x));
|
||||||
|
expect(autoAction(t, 'A')).toEqual(autoAction(s, 'A'));
|
||||||
|
// Wrong guesses are remembered per tile, so repeated timeouts eventually find the number.
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
expect(s.rows.C![2]!.wrong).toEqual([3]);
|
||||||
|
expect(s.history.at(-1)).toMatchObject({ by: 'A', auto: true, said: 3, correct: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('candidatesFor: 조커가 가능하면 후보에 넣고, 범위는 공개된 숫자 타일 사이', () => {
|
||||||
|
const v = (n?: number | 'joker', revealed = false, color: 'black' | 'white' = 'black') =>
|
||||||
|
n === undefined ? { color, revealed: false, wrong: [] } : { color, revealed, n, wrong: [] };
|
||||||
|
const rows = { me: [v(4, false, 'white')], o: [v(2, true), v(), v('joker', true, 'white'), v(), v(9, true, 'white')] };
|
||||||
|
expect(candidatesFor(rows, null, 'o', 1, { jokers: false, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9]);
|
||||||
|
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9, 'joker']);
|
||||||
|
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'white-left' })).toEqual([3, 4, 5, 6, 7, 8, 'joker']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('연결이 끊긴 플레이어: 매번 시간 초과여도 게임이 끝까지 진행; 연속 3턴이면 마감 5초', () => {
|
||||||
|
for (let seed = 1; seed <= 30; seed++) {
|
||||||
|
const players = ['A', 'B', 'C'];
|
||||||
|
let s = davinci.setup({ players, options: davinci.optionsSchema.parse({ jokers: seed % 2 === 0 }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed + 500));
|
||||||
|
const pick = SeededRng.fromSeed(seedN(seed + 900));
|
||||||
|
let sawShort = false;
|
||||||
|
for (let step = 0; step < 3000 && s.phase.kind !== 'finished'; step++) {
|
||||||
|
const p = davinci.timeoutPlayers!(s)[0]!;
|
||||||
|
// A never acts (always times out); B and C play randomly.
|
||||||
|
const legal = davinci.legalActions!(s, p);
|
||||||
|
const a = p === 'A' ? davinci.onTimeout(s, p) : legal[pick.int(legal.length)]!;
|
||||||
|
expect(davinci.validate(s, p, a)).toEqual({ ok: true });
|
||||||
|
s = davinci.apply(s, p, a, { rng: r, now: step * 1000 }).state;
|
||||||
|
checkInvariants(s);
|
||||||
|
if (s.phase.kind !== 'finished' && s.current === 'A' && s.phase.kind !== 'chooseStart' && s.idle.A! >= 3) {
|
||||||
|
expect(s.deadlineAt! - s.phaseStartedAt).toBe(5000);
|
||||||
|
sawShort = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(s.phase.kind).toBe('finished');
|
||||||
|
if (seed === 1) expect(sawShort).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all-AFK game (everyone times out) still ends', () => {
|
||||||
|
for (let seed = 1; seed <= 20; seed++) {
|
||||||
|
for (const emptyPoolPenalty of ['reveal-own', 'none'] as const) {
|
||||||
|
let s = setup({ emptyPoolPenalty, jokers: seed % 2 === 0 }, ['A', 'B', 'C', 'D'].slice(0, 2 + (seed % 3)), seed);
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed));
|
||||||
|
let step = 0;
|
||||||
|
for (; step < 5000 && s.phase.kind !== 'finished'; step++) {
|
||||||
|
const p = davinci.timeoutPlayers!(s)[0]!;
|
||||||
|
s = davinci.apply(s, p, davinci.onTimeout(s, p), { rng: r, now: step }).state;
|
||||||
|
}
|
||||||
|
expect(s.phase.kind).toBe('finished');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기권(나가기): 탈락하고 타일 공개; 차례였으면 다음 사람으로', () => {
|
||||||
|
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'], C: ['w0', 'b2'] }, {}, { black: ['b5'] });
|
||||||
|
s = act(s, 'A', { type: 'draw', color: 'black' });
|
||||||
|
expect(davinci.onLeave!(s, 'A')).toEqual({ type: 'leave' });
|
||||||
|
s = act(s, 'A', { type: 'leave' });
|
||||||
|
expect([s.alive.A, s.current, s.phase.kind]).toEqual([false, 'B', 'draw']);
|
||||||
|
expect(shown(s, 'A')).toEqual([true, true, true]);
|
||||||
|
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5']);
|
||||||
|
expect(davinci.onLeave!(s, 'A')).toBeNull();
|
||||||
|
s = act(s, 'C', { type: 'leave' });
|
||||||
|
expect(s.phase.kind).toBe('finished');
|
||||||
|
expect(davinci.result(s)).toMatchObject({ ranking: [['B'], ['C'], ['A']], reason: 'resign' });
|
||||||
|
checkInvariants(s);
|
||||||
|
// Leaving during the start choice: the rest are dealt when they have chosen.
|
||||||
|
let t = setup({}, ['A', 'B', 'C']);
|
||||||
|
t = act(t, 'A', { type: 'chooseStart', black: 2, white: 2 });
|
||||||
|
t = act(t, 'B', { type: 'leave' });
|
||||||
|
expect(t.phase.kind).toBe('chooseStart');
|
||||||
|
t = act(t, 'C', { type: 'chooseStart', black: 2, white: 2 });
|
||||||
|
expect(t.phase.kind).toBe('draw');
|
||||||
|
expect(t.rows.B).toEqual([]);
|
||||||
|
expect(t.current).not.toBe('B');
|
||||||
|
checkInvariants(t);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- info
|
||||||
|
|
||||||
|
describe('정보 유출 (§8.3)', () => {
|
||||||
|
test('관전자·상대 view와 이벤트에 남의 숨은 숫자·ID·이번 턴 타일 숫자가 없음', () => {
|
||||||
|
for (let seed = 1; seed <= 300; seed++) {
|
||||||
|
const n = 2 + (seed % 3);
|
||||||
|
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||||
|
const options = davinci.optionsSchema.parse({ jokers: seed % 2 === 0, chooseColors: seed % 3 !== 0, deductionHelper: seed % 4 === 0 });
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed));
|
||||||
|
const chooser = SeededRng.fromSeed(seedN(seed + 77));
|
||||||
|
let s = davinci.setup({ players, options, rng: r, now: 0 });
|
||||||
|
for (let step = 0; step < 400 && s.phase.kind !== 'finished'; step++) {
|
||||||
|
const views = new Map([...players, null].map((v) => [v, davinci.view(s, v)] as const));
|
||||||
|
for (const [viewer, v] of views) checkViewShape(s, v, viewer);
|
||||||
|
const json = new Map([...views].map(([k, v]) => [k, JSON.stringify(v)]));
|
||||||
|
for (const owner of players)
|
||||||
|
for (const secret of davinci.secretsOf!(s, owner))
|
||||||
|
for (const [viewer, j] of json) if (viewer !== owner && j.includes(JSON.stringify(secret))) throw new Error(`leak ${secret} of ${owner} to ${viewer}`);
|
||||||
|
const actives = davinci.activePlayers(s);
|
||||||
|
const actor = actives[chooser.int(actives.length)]!;
|
||||||
|
const legal = davinci.legalActions!(s, actor);
|
||||||
|
const a = chooser.int(6) === 0 ? davinci.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||||
|
const out = davinci.apply(s, actor, a, { rng: r, now: step * 1000 });
|
||||||
|
const hiddenAfter = new Set(Object.values(out.state.rows).flatMap((row) => row.filter((t) => !t.revealed).map((t) => t.id)));
|
||||||
|
if (out.state.pending) hiddenAfter.add(out.state.pending);
|
||||||
|
for (const e of out.events) {
|
||||||
|
const ej = JSON.stringify(e);
|
||||||
|
if (out.state.phase.kind !== 'finished') for (const id of hiddenAfter) if (ej.includes(JSON.stringify(id))) throw new Error(`event leaks ${id}`);
|
||||||
|
if (e.type === 'drawn' && Object.keys(e).sort().join() !== 'color,player,type') throw new Error('drawn event leaks');
|
||||||
|
if (e.type === 'placed' && !e.revealed && 'n' in e) throw new Error('placed event leaks');
|
||||||
|
}
|
||||||
|
s = out.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- schema
|
||||||
|
|
||||||
|
describe('options & schema', () => {
|
||||||
|
test('every option has Korean metadata; defaults match the doc', () => {
|
||||||
|
const shape = (davinci.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(davinci.defaultOptions).toEqual({
|
||||||
|
jokers: false,
|
||||||
|
tieOrder: 'black-left',
|
||||||
|
startTiles: 'auto',
|
||||||
|
chooseColors: true,
|
||||||
|
emptyPoolPenalty: 'reveal-own',
|
||||||
|
turnSeconds: 30,
|
||||||
|
deductionHelper: false,
|
||||||
|
});
|
||||||
|
expect(davinci.optionsSchema.safeParse({ startTiles: 6 }).success).toBe(false);
|
||||||
|
expect(davinci.optionsSchema.safeParse({ turnSeconds: 25 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client actions only (system actions are not in the schema)', () => {
|
||||||
|
expect(davinci.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||||
|
expect(davinci.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||||
|
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: 'joker' }).success).toBe(true);
|
||||||
|
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: '3' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deductionHelper: 상대 숨은 타일에만 후보, 끄면 없음', () => {
|
||||||
|
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { deductionHelper: true });
|
||||||
|
const v = davinci.view(s, 'A');
|
||||||
|
expect(v.rows.B![0]!.candidates).toEqual([0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
|
expect(v.rows.A![0]!.candidates).toBeUndefined();
|
||||||
|
expect(davinci.view(rigged({ A: ['b1'], B: ['b0'] }), 'A').rows.B![0]!.candidates).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- properties
|
||||||
|
|
||||||
|
describe('random playouts (docs/09 §7)', () => {
|
||||||
|
const cfg = (i: number): { players: string[]; options: DavinciOptions } => ({
|
||||||
|
players: Array.from({ length: 2 + (i % 3) }, (_, k) => `P${k}`),
|
||||||
|
options: davinci.optionsSchema.parse({
|
||||||
|
jokers: i % 2 === 0,
|
||||||
|
tieOrder: i % 7 === 0 ? 'white-left' : 'black-left',
|
||||||
|
startTiles: (['auto', 3, 4, 5] as const)[i % 4],
|
||||||
|
chooseColors: i % 5 !== 0,
|
||||||
|
emptyPoolPenalty: i % 6 === 0 ? 'none' : 'reveal-own',
|
||||||
|
deductionHelper: i % 9 === 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const invariant = (st: unknown) => {
|
||||||
|
checkInvariants(st);
|
||||||
|
const s = st as DavinciState;
|
||||||
|
// onTimeout is legal for every timed player in every state, and so is the move it stands for.
|
||||||
|
for (const p of davinci.timeoutPlayers!(s)) {
|
||||||
|
const v = davinci.validate(s, p, davinci.onTimeout(s, p));
|
||||||
|
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
|
||||||
|
const c = davinci.validate(s, p, autoAction(s, p));
|
||||||
|
if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [from, to] of [[1, 250], [251, 500], [501, 750], [751, 1000]] as const) {
|
||||||
|
test(`seeds ${from}–${to}: finish, conserve tiles, sorted rows, no leaks, onTimeout legal`, () => {
|
||||||
|
for (let i = from; i <= to; i++) {
|
||||||
|
const { players, options } = cfg(i);
|
||||||
|
const r = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, invariant, maxApplyMs: 50 });
|
||||||
|
expect(r.finished).toBe(true);
|
||||||
|
const res = davinci.result(r.finalState as DavinciState)!;
|
||||||
|
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||||
|
if (i % 50 === 1) {
|
||||||
|
const b = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, maxApplyMs: 50 });
|
||||||
|
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState));
|
||||||
|
expect(b.trace).toEqual(r.trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
801
packages/games/src/davinci-code/index.ts
Normal file
801
packages/games/src/davinci-code/index.ts
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
/**
|
||||||
|
* 다빈치 코드 (docs/games/davinci-code.md).
|
||||||
|
*
|
||||||
|
* Secret-id design: tile ids ('b5', 'wJ', ...) encode the number, so they only ever appear in a view
|
||||||
|
* for tiles the viewer may know (own tiles, revealed tiles, everything after the game ends). Other
|
||||||
|
* players' hidden tiles are sent as `{ color, revealed: false, wrong }` and addressed by position only.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||||
|
|
||||||
|
export type TColor = 'black' | 'white';
|
||||||
|
export type TileValue = number | 'joker';
|
||||||
|
export const COLORS: TColor[] = ['black', 'white'];
|
||||||
|
export const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
|
||||||
|
export const MAX_N = 11;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- options
|
||||||
|
|
||||||
|
export const davinciOptionsSchema = z.object({
|
||||||
|
jokers: z.boolean().default(false).meta({ title: '조커 사용(검정·흰색 각 1개, 숫자 대신 "-")' }),
|
||||||
|
tieOrder: z
|
||||||
|
.enum(['black-left', 'white-left'])
|
||||||
|
.default('black-left')
|
||||||
|
.meta({ title: '같은 숫자일 때 색 순서', labels: { 'black-left': '검정이 왼쪽', 'white-left': '흰색이 왼쪽' } }),
|
||||||
|
startTiles: z
|
||||||
|
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
|
||||||
|
.default('auto')
|
||||||
|
.meta({ title: '시작 타일 수', labels: { auto: '자동(2·3명 4개, 4명 3개)', 3: '3개', 4: '4개', 5: '5개' } }),
|
||||||
|
chooseColors: z.boolean().default(true).meta({ title: '타일을 가져올 때 색 고르기(끄면 무작위)' }),
|
||||||
|
emptyPoolPenalty: z
|
||||||
|
.enum(['reveal-own', 'none'])
|
||||||
|
.default('reveal-own')
|
||||||
|
.meta({ title: '더미가 빈 뒤 틀렸을 때', labels: { 'reveal-own': '내 숨은 타일 1개 공개', none: '벌칙 없음' } }),
|
||||||
|
turnSeconds: z
|
||||||
|
.union([z.literal(20), z.literal(30), z.literal(45), z.literal(60)])
|
||||||
|
.default(30)
|
||||||
|
.meta({ title: '추리 제한 시간', labels: { 20: '20초', 30: '30초', 45: '45초', 60: '60초' } }),
|
||||||
|
deductionHelper: z.boolean().default(false).meta({ title: '가능한 숫자 후보 표시(초보 도움)' }),
|
||||||
|
});
|
||||||
|
export type DavinciOptions = z.infer<typeof davinciOptionsSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- actions
|
||||||
|
|
||||||
|
const colorSchema = z.enum(['black', 'white']);
|
||||||
|
export const davinciActionSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('chooseStart'), black: z.number().int().min(0).max(12), white: z.number().int().min(0).max(12) }),
|
||||||
|
z.object({ type: z.literal('draw'), color: colorSchema.optional() }),
|
||||||
|
// Value range / integer checks live in validate so the player gets a Korean reason.
|
||||||
|
z.object({ type: z.literal('guess'), target: z.string().max(64), index: z.number().int().min(0).max(40), value: z.union([z.number(), z.literal('joker')]) }),
|
||||||
|
z.object({ type: z.literal('continue') }),
|
||||||
|
z.object({ type: z.literal('stop') }),
|
||||||
|
z.object({ type: z.literal('placeJoker'), index: z.number().int().min(0).max(40) }),
|
||||||
|
z.object({ type: z.literal('revealOwn'), index: z.number().int().min(0).max(40) }),
|
||||||
|
]);
|
||||||
|
export type DavinciClientAction = z.infer<typeof davinciActionSchema>;
|
||||||
|
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
|
||||||
|
export type DavinciAction = DavinciClientAction | { type: 'timeout' } | { type: 'leave' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- state
|
||||||
|
|
||||||
|
export interface RowTile {
|
||||||
|
id: string;
|
||||||
|
revealed: boolean;
|
||||||
|
/** Values already guessed wrong for this tile (public). */
|
||||||
|
wrong: TileValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DavinciPhase =
|
||||||
|
| { kind: 'chooseStart'; picks: Record<PlayerId, { black: number; white: number } | null> }
|
||||||
|
| { kind: 'draw' }
|
||||||
|
| { kind: 'guess' }
|
||||||
|
| { kind: 'afterCorrect' }
|
||||||
|
| { kind: 'placeJoker'; reveal: boolean }
|
||||||
|
| { kind: 'revealOwn' }
|
||||||
|
| { kind: 'finished' };
|
||||||
|
export type PhaseKind = DavinciPhase['kind'];
|
||||||
|
|
||||||
|
export interface DavinciGuess {
|
||||||
|
by: PlayerId;
|
||||||
|
target: PlayerId;
|
||||||
|
/** 0-based position in the target's row at the time of the guess. */
|
||||||
|
index: number;
|
||||||
|
color: TColor;
|
||||||
|
said: TileValue;
|
||||||
|
correct: boolean;
|
||||||
|
auto: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DavinciFinal {
|
||||||
|
ranking: PlayerId[][];
|
||||||
|
winner: PlayerId;
|
||||||
|
summary: string;
|
||||||
|
reason: 'normal' | 'resign';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DavinciState {
|
||||||
|
options: DavinciOptions;
|
||||||
|
seats: PlayerId[];
|
||||||
|
startCount: number;
|
||||||
|
/** Hidden from everyone; drawn from the front. */
|
||||||
|
pool: { black: string[]; white: string[] };
|
||||||
|
/** Jokers wait here until the start tiles are dealt (start tiles never contain a joker). */
|
||||||
|
jokerReserve: string[];
|
||||||
|
rows: Record<PlayerId, RowTile[]>;
|
||||||
|
phase: DavinciPhase;
|
||||||
|
current: PlayerId;
|
||||||
|
/** This turn's drawn tile (only the drawer knows the number). */
|
||||||
|
pending: string | null;
|
||||||
|
alive: Record<PlayerId, boolean>;
|
||||||
|
left: Record<PlayerId, boolean>;
|
||||||
|
eliminationOrder: PlayerId[];
|
||||||
|
stats: Record<PlayerId, { guesses: number; correct: number }>;
|
||||||
|
history: DavinciGuess[];
|
||||||
|
lastPlaced: { player: PlayerId; index: number; revealed: boolean } | null;
|
||||||
|
turnNo: number;
|
||||||
|
/** Consecutive turns with a timeout; at 3 the deadline drops to 5 s. */
|
||||||
|
idle: Record<PlayerId, number>;
|
||||||
|
idleTurn: Record<PlayerId, number>;
|
||||||
|
phaseStartedAt: number;
|
||||||
|
deadlineAt: number | null;
|
||||||
|
seq: number;
|
||||||
|
final: DavinciFinal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- view
|
||||||
|
|
||||||
|
export interface DavinciTileView {
|
||||||
|
color: TColor;
|
||||||
|
revealed: boolean;
|
||||||
|
/** Present only when the viewer may know it (own tile, revealed, or game over). */
|
||||||
|
n?: TileValue;
|
||||||
|
id?: string;
|
||||||
|
wrong: TileValue[];
|
||||||
|
/** deductionHelper: values still possible from the viewer's information. */
|
||||||
|
candidates?: TileValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DavinciView {
|
||||||
|
options: DavinciOptions;
|
||||||
|
me: PlayerId | null;
|
||||||
|
seats: PlayerId[];
|
||||||
|
phase: PhaseKind;
|
||||||
|
current: PlayerId;
|
||||||
|
startCount: number;
|
||||||
|
rows: Record<PlayerId, DavinciTileView[]>;
|
||||||
|
pool: { black: number; white: number };
|
||||||
|
pending: { color: TColor; n?: TileValue; id?: string } | null;
|
||||||
|
/** chooseStart: who has submitted (others' choices stay secret). */
|
||||||
|
picked: Record<PlayerId, boolean> | null;
|
||||||
|
myPick: { black: number; white: number } | null;
|
||||||
|
/** chooseStart: how many of each colour I may still take. */
|
||||||
|
pickLimits: { black: number; white: number } | null;
|
||||||
|
alive: Record<PlayerId, boolean>;
|
||||||
|
left: Record<PlayerId, boolean>;
|
||||||
|
eliminationOrder: PlayerId[];
|
||||||
|
stats: Record<PlayerId, { guesses: number; correct: number }>;
|
||||||
|
history: DavinciGuess[];
|
||||||
|
lastPlaced: DavinciState['lastPlaced'];
|
||||||
|
idle: Record<PlayerId, number>;
|
||||||
|
deadlineAt: number | null;
|
||||||
|
final: DavinciFinal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tiles
|
||||||
|
|
||||||
|
const CHOOSE_START_MS = 15_000;
|
||||||
|
const SHORT_MS = 10_000;
|
||||||
|
const IDLE_MS = 5_000;
|
||||||
|
const IDLE_TURNS = 3;
|
||||||
|
|
||||||
|
export function tileOf(id: string): { color: TColor; n: TileValue } {
|
||||||
|
const color: TColor = id[0] === 'b' ? 'black' : 'white';
|
||||||
|
const rest = id.slice(1);
|
||||||
|
return { color, n: rest === 'J' ? 'joker' : Number(rest) };
|
||||||
|
}
|
||||||
|
const tileId = (color: TColor, n: TileValue) => `${color[0]}${n === 'joker' ? 'J' : n}`;
|
||||||
|
export const allTileIds = (jokers: boolean): string[] => [
|
||||||
|
...COLORS.flatMap((c) => Array.from({ length: MAX_N + 1 }, (_, n) => tileId(c, n))),
|
||||||
|
...(jokers ? COLORS.map((c) => tileId(c, 'joker')) : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Sort key of a numbered tile (lower = further left). */
|
||||||
|
export function sortKey(color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
|
||||||
|
const first: TColor = tieOrder === 'black-left' ? 'black' : 'white';
|
||||||
|
return n * 2 + (color === first ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-computed slot for a numbered tile: right after the rightmost numbered tile that must precede it (jokers keep their place). */
|
||||||
|
export function insertIndex(row: readonly { color: TColor; n: TileValue }[], color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
|
||||||
|
const k = sortKey(color, n, tieOrder);
|
||||||
|
let at = 0;
|
||||||
|
row.forEach((t, i) => {
|
||||||
|
if (t.n !== 'joker' && sortKey(t.color, t.n, tieOrder) < k) at = i + 1;
|
||||||
|
});
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCountFor(o: DavinciOptions, players: number): number {
|
||||||
|
return o.startTiles === 'auto' ? (players >= 4 ? 3 : 4) : o.startTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenCount = (row: readonly RowTile[]) => row.reduce((a, t) => a + (t.revealed ? 0 : 1), 0);
|
||||||
|
const aliveSeats = (s: DavinciState) => s.seats.filter((p) => s.alive[p]);
|
||||||
|
const isValue = (v: unknown, jokers: boolean): v is TileValue =>
|
||||||
|
(v === 'joker' && jokers) || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_N);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- deduction (public + own info only)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values the viewer can still consider for `rows[target][index]`, computed from a view only
|
||||||
|
* (revealed numbers, the viewer's own numbers, colours, positions and wrong guesses).
|
||||||
|
* Sound: the true value is always included. Used by the helper and by the timeout auto-guess.
|
||||||
|
*/
|
||||||
|
export function candidatesFor(
|
||||||
|
rows: Record<PlayerId, DavinciTileView[]>,
|
||||||
|
pending: DavinciView['pending'],
|
||||||
|
target: PlayerId,
|
||||||
|
index: number,
|
||||||
|
o: Pick<DavinciOptions, 'jokers' | 'tieOrder'>,
|
||||||
|
): TileValue[] {
|
||||||
|
const row = rows[target] ?? [];
|
||||||
|
const tile = row[index];
|
||||||
|
if (!tile || tile.n !== undefined) return [];
|
||||||
|
const known = new Set<string>();
|
||||||
|
for (const r of Object.values(rows)) for (const t of r) if (t.n !== undefined) known.add(tileId(t.color, t.n));
|
||||||
|
if (pending?.n !== undefined) known.add(tileId(pending.color, pending.n));
|
||||||
|
let lo = -Infinity;
|
||||||
|
let hi = Infinity;
|
||||||
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
|
const t = row[i]!;
|
||||||
|
if (t.n !== undefined && t.n !== 'joker') {
|
||||||
|
lo = sortKey(t.color, t.n, o.tieOrder);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = index + 1; i < row.length; i++) {
|
||||||
|
const t = row[i]!;
|
||||||
|
if (t.n !== undefined && t.n !== 'joker') {
|
||||||
|
hi = sortKey(t.color, t.n, o.tieOrder);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: TileValue[] = [];
|
||||||
|
for (let n = 0; n <= MAX_N; n++) {
|
||||||
|
if (known.has(tileId(tile.color, n)) || tile.wrong.includes(n)) continue;
|
||||||
|
const k = sortKey(tile.color, n, o.tieOrder);
|
||||||
|
if (k > lo && k < hi) out.push(n);
|
||||||
|
}
|
||||||
|
if (o.jokers && !known.has(tileId(tile.color, 'joker')) && !tile.wrong.includes('joker')) out.push('joker');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
function refreshDeadline(s: DavinciState, now: number, phaseChanged: boolean): void {
|
||||||
|
if (!phaseChanged) return;
|
||||||
|
s.phaseStartedAt = now;
|
||||||
|
let ms: number | null;
|
||||||
|
switch (s.phase.kind) {
|
||||||
|
case 'finished':
|
||||||
|
ms = null;
|
||||||
|
break;
|
||||||
|
case 'chooseStart':
|
||||||
|
ms = CHOOSE_START_MS;
|
||||||
|
break;
|
||||||
|
case 'guess':
|
||||||
|
ms = s.options.turnSeconds * 1000;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ms = SHORT_MS;
|
||||||
|
}
|
||||||
|
if (ms !== null && s.phase.kind !== 'chooseStart' && (s.idle[s.current] ?? 0) >= IDLE_TURNS) ms = IDLE_MS;
|
||||||
|
s.deadlineAt = ms === null ? null : now + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTile(s: DavinciState, p: PlayerId, id: string, revealed: boolean, events: GameEvent[], at?: number): void {
|
||||||
|
const row = s.rows[p]!;
|
||||||
|
const t = tileOf(id);
|
||||||
|
const index = at ?? (t.n === 'joker' ? row.length : insertIndex(row.map((r) => tileOf(r.id)), t.color, t.n, s.options.tieOrder));
|
||||||
|
row.splice(index, 0, { id, revealed, wrong: [] });
|
||||||
|
s.lastPlaced = { player: p, index, revealed };
|
||||||
|
events.push(revealed ? { type: 'placed', player: p, index, revealed, color: t.color, n: t.n } : { type: 'placed', player: p, index, revealed, color: t.color });
|
||||||
|
}
|
||||||
|
|
||||||
|
function eliminate(s: DavinciState, p: PlayerId, events: GameEvent[]): void {
|
||||||
|
if (!s.alive[p]) return;
|
||||||
|
s.alive[p] = false;
|
||||||
|
s.eliminationOrder.push(p);
|
||||||
|
events.push({ type: 'eliminated', player: p });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the game if only one player keeps hidden tiles. Returns true when finished. */
|
||||||
|
function checkFinish(s: DavinciState, events: GameEvent[], byLeave = false): boolean {
|
||||||
|
const alive = aliveSeats(s);
|
||||||
|
if (alive.length > 1) return false;
|
||||||
|
if (s.pending) {
|
||||||
|
const owner = s.current;
|
||||||
|
if (s.rows[owner]) insertTile(s, owner, s.pending, !s.alive[owner], events);
|
||||||
|
s.pending = null;
|
||||||
|
}
|
||||||
|
const winner = alive[0] ?? s.eliminationOrder[s.eliminationOrder.length - 1]!;
|
||||||
|
const ranking: PlayerId[][] = [[winner], ...[...s.eliminationOrder].reverse().filter((p) => p !== winner).map((p) => [p])];
|
||||||
|
// Players who never got tiles (left during the start choice) are ranked last, in seat order.
|
||||||
|
for (const p of s.seats) if (!ranking.some((g) => g.includes(p))) ranking.push([p]);
|
||||||
|
const left = hiddenCount(s.rows[winner] ?? []);
|
||||||
|
const others = s.seats.length - 1;
|
||||||
|
const reason: DavinciFinal['reason'] = byLeave ? 'resign' : 'normal';
|
||||||
|
const st = s.stats[winner]!;
|
||||||
|
const summary =
|
||||||
|
reason === 'resign'
|
||||||
|
? `마지막 상대가 나가서 게임이 끝났어요. (추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`
|
||||||
|
: `숨은 타일 ${left}개가 끝까지 풀리지 않아 이겼어요! (상대 ${others}명 탈락 · 추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`;
|
||||||
|
s.final = { ranking, winner, summary, reason };
|
||||||
|
s.phase = { kind: 'finished' };
|
||||||
|
events.push({ type: 'gameEnded', winner });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endTurn(s: DavinciState, events: GameEvent[]): void {
|
||||||
|
s.pending = null;
|
||||||
|
if (checkFinish(s, events)) return;
|
||||||
|
const i = s.seats.indexOf(s.current);
|
||||||
|
s.current = s.seats[nextSeat(i, s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
|
||||||
|
s.turnNo += 1;
|
||||||
|
s.phase = s.pool.black.length + s.pool.white.length > 0 ? { kind: 'draw' } : { kind: 'guess' };
|
||||||
|
events.push({ type: 'turn', player: s.current });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the start tiles are dealt: sort rows, shuffle the jokers into the pool, start the first turn. */
|
||||||
|
function afterDeal(s: DavinciState, rng: Rng, events: GameEvent[]): void {
|
||||||
|
for (const p of s.seats) {
|
||||||
|
s.rows[p]!.sort((a, b) => {
|
||||||
|
const x = tileOf(a.id);
|
||||||
|
const y = tileOf(b.id);
|
||||||
|
return sortKey(x.color, x.n as number, s.options.tieOrder) - sortKey(y.color, y.n as number, s.options.tieOrder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const j of s.jokerReserve) {
|
||||||
|
const pile = s.pool[tileOf(j).color];
|
||||||
|
pile.splice(rng.int(pile.length + 1), 0, j);
|
||||||
|
}
|
||||||
|
s.jokerReserve = [];
|
||||||
|
if (!s.alive[s.current]) s.current = s.seats[nextSeat(s.seats.indexOf(s.current), s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
|
||||||
|
s.phase = { kind: 'draw' };
|
||||||
|
events.push({ type: 'dealt', counts: Object.fromEntries(s.seats.map((p) => [p, s.rows[p]!.length])) });
|
||||||
|
events.push({ type: 'turn', player: s.current });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** chooseStart: once every remaining player has chosen, deal from the front of each colour pile. */
|
||||||
|
function dealIfReady(s: DavinciState, rng: Rng, events: GameEvent[]): void {
|
||||||
|
const ph = s.phase;
|
||||||
|
if (ph.kind !== 'chooseStart' || !s.seats.every((p) => !s.alive[p] || ph.picks[p])) return;
|
||||||
|
for (const p of s.seats) {
|
||||||
|
const pick = ph.picks[p];
|
||||||
|
if (!pick || !s.alive[p]) continue;
|
||||||
|
const ids = [...s.pool.black.splice(0, pick.black), ...s.pool.white.splice(0, pick.white)];
|
||||||
|
s.rows[p] = ids.map((id) => ({ id, revealed: false, wrong: [] }));
|
||||||
|
}
|
||||||
|
afterDeal(s, rng, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLimits(s: DavinciState): { black: number; white: number } {
|
||||||
|
const lim = { black: s.pool.black.length, white: s.pool.white.length };
|
||||||
|
if (s.phase.kind === 'chooseStart') {
|
||||||
|
for (const pick of Object.values(s.phase.picks)) {
|
||||||
|
if (!pick) continue;
|
||||||
|
lim.black -= pick.black;
|
||||||
|
lim.white -= pick.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeout choice for the start: as even as possible (odd → one more black), within what is left. */
|
||||||
|
export function autoPick(s: DavinciState): { black: number; white: number } {
|
||||||
|
const n = s.startCount;
|
||||||
|
const lim = pickLimits(s);
|
||||||
|
let black = Math.min(Math.ceil(n / 2), lim.black);
|
||||||
|
let white = n - black;
|
||||||
|
if (white > lim.white) {
|
||||||
|
white = lim.white;
|
||||||
|
black = n - white;
|
||||||
|
}
|
||||||
|
return { black, white };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic auto-guess (§8.5) from the player's own view only: the living opponent with the fewest
|
||||||
|
* hidden tiles (ties: clockwise from me), their leftmost hidden tile, the smallest possible value.
|
||||||
|
*/
|
||||||
|
export function autoGuess(s: DavinciState, player: PlayerId): { type: 'guess'; target: PlayerId; index: number; value: TileValue } {
|
||||||
|
const v = davinci.view(s, player);
|
||||||
|
const me = v.seats.indexOf(player);
|
||||||
|
const order = v.seats.map((_, k) => v.seats[(me + 1 + k) % v.seats.length]!).filter((p) => p !== player && v.alive[p]);
|
||||||
|
let target = order[0]!;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const p of order) {
|
||||||
|
const h = v.rows[p]!.filter((t) => !t.revealed).length;
|
||||||
|
if (h > 0 && h < best) {
|
||||||
|
best = h;
|
||||||
|
target = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const index = v.rows[target]!.findIndex((t) => !t.revealed);
|
||||||
|
const cands = candidatesFor(v.rows, v.pending, target, index, v.options);
|
||||||
|
const nums = cands.filter((c): c is number => c !== 'joker');
|
||||||
|
const value: TileValue = nums.length ? Math.min(...nums) : cands.length ? 'joker' : 0;
|
||||||
|
return { type: 'guess', target, index, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The concrete action a timeout stands for in the current state (docs §8.5). */
|
||||||
|
export function autoAction(s: DavinciState, player: PlayerId): DavinciClientAction {
|
||||||
|
switch (s.phase.kind) {
|
||||||
|
case 'chooseStart':
|
||||||
|
return { type: 'chooseStart', ...autoPick(s) };
|
||||||
|
case 'draw':
|
||||||
|
if (!s.options.chooseColors) return { type: 'draw' };
|
||||||
|
return { type: 'draw', color: s.pool.white.length > s.pool.black.length ? 'white' : 'black' };
|
||||||
|
case 'guess':
|
||||||
|
return autoGuess(s, player);
|
||||||
|
case 'afterCorrect':
|
||||||
|
return { type: 'stop' };
|
||||||
|
case 'placeJoker':
|
||||||
|
return { type: 'placeJoker', index: s.rows[player]!.length };
|
||||||
|
case 'revealOwn':
|
||||||
|
return { type: 'revealOwn', index: s.rows[player]!.findIndex((t) => !t.revealed) };
|
||||||
|
case 'finished':
|
||||||
|
throw new Error('davinci: no auto action after the game');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waiting(s: DavinciState): PlayerId[] {
|
||||||
|
if (s.phase.kind === 'finished') return [];
|
||||||
|
if (s.phase.kind === 'chooseStart') {
|
||||||
|
const picks = s.phase.picks;
|
||||||
|
return s.seats.filter((p) => s.alive[p] && !picks[p]);
|
||||||
|
}
|
||||||
|
return [s.current];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction): Validation {
|
||||||
|
const o = s.options;
|
||||||
|
const ph = s.phase;
|
||||||
|
if (a.type === 'chooseStart') {
|
||||||
|
if (ph.kind !== 'chooseStart') return fail('지금은 시작 타일을 고를 때가 아니에요.');
|
||||||
|
if (ph.picks[actor]) return fail('이미 골랐어요.');
|
||||||
|
if (a.black + a.white !== s.startCount) return fail(`시작 타일 ${s.startCount}개를 골라 주세요.`);
|
||||||
|
const lim = pickLimits(s);
|
||||||
|
if (a.black > lim.black) return fail(lim.black === 0 ? '검은색 타일이 더 이상 없어요.' : `검은색은 ${lim.black}개까지 고를 수 있어요.`);
|
||||||
|
if (a.white > lim.white) return fail(lim.white === 0 ? '흰색 타일이 더 이상 없어요.' : `흰색은 ${lim.white}개까지 고를 수 있어요.`);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
if (ph.kind === 'chooseStart') return fail('먼저 시작 타일을 골라 주세요.');
|
||||||
|
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
|
||||||
|
switch (a.type) {
|
||||||
|
case 'draw': {
|
||||||
|
if (ph.kind !== 'draw') return fail('지금은 타일을 가져올 때가 아니에요.');
|
||||||
|
if (!o.chooseColors) return a.color ? fail('이 방은 색을 고르지 않고 무작위로 가져와요.') : ok;
|
||||||
|
if (!a.color) return fail('가져올 색을 골라 주세요.');
|
||||||
|
if (s.pool[a.color].length === 0) return fail(`${COLOR_KO[a.color]} 타일이 더 이상 없어요.`);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'guess': {
|
||||||
|
if (ph.kind !== 'guess') return fail('지금은 추리할 때가 아니에요.');
|
||||||
|
if (a.target === actor) return fail('자기 타일은 고를 수 없어요.');
|
||||||
|
if (!s.seats.includes(a.target)) return fail('이 게임의 플레이어가 아니에요.');
|
||||||
|
if (!s.alive[a.target]) return fail('이미 탈락한 사람의 타일이에요.');
|
||||||
|
const t = s.rows[a.target]![a.index];
|
||||||
|
if (!t) return fail('그 자리에는 타일이 없어요.');
|
||||||
|
if (t.revealed) return fail('이미 공개된 타일이에요.');
|
||||||
|
if (a.value === 'joker' && !o.jokers) return fail('이 방은 조커를 쓰지 않아요.');
|
||||||
|
if (!isValue(a.value, o.jokers)) return fail('0부터 11 사이 숫자를 골라 주세요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'continue':
|
||||||
|
if (ph.kind !== 'afterCorrect') return fail('맞힌 뒤에만 계속할 수 있어요.');
|
||||||
|
return aliveSeats(s).some((p) => p !== actor) ? ok : fail('남은 상대가 없어요.');
|
||||||
|
case 'stop':
|
||||||
|
return ph.kind === 'afterCorrect' ? ok : fail('맞힌 뒤에만 멈출 수 있어요.');
|
||||||
|
case 'placeJoker':
|
||||||
|
if (ph.kind !== 'placeJoker') return fail('지금은 조커를 놓을 때가 아니에요.');
|
||||||
|
return a.index >= 0 && a.index <= s.rows[actor]!.length ? ok : fail('그 자리에는 놓을 수 없어요.');
|
||||||
|
case 'revealOwn': {
|
||||||
|
if (ph.kind !== 'revealOwn') return fail('지금은 내 타일을 공개할 때가 아니에요.');
|
||||||
|
const t = s.rows[actor]![a.index];
|
||||||
|
if (!t) return fail('그 자리에는 타일이 없어요.');
|
||||||
|
return t.revealed ? fail('이미 공개된 타일이에요.') : ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction, rng: Rng, events: GameEvent[], auto: boolean): void {
|
||||||
|
switch (a.type) {
|
||||||
|
case 'chooseStart': {
|
||||||
|
const ph = s.phase as Extract<DavinciPhase, { kind: 'chooseStart' }>;
|
||||||
|
ph.picks[actor] = { black: a.black, white: a.white };
|
||||||
|
events.push({ type: 'picked', player: actor });
|
||||||
|
dealIfReady(s, rng, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'draw': {
|
||||||
|
let color = a.color;
|
||||||
|
if (!color) {
|
||||||
|
const nb = s.pool.black.length;
|
||||||
|
color = rng.int(nb + s.pool.white.length) < nb ? 'black' : 'white';
|
||||||
|
}
|
||||||
|
s.pending = s.pool[color].shift()!;
|
||||||
|
s.phase = { kind: 'guess' };
|
||||||
|
events.push({ type: 'drawn', player: actor, color });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'guess': {
|
||||||
|
const t = s.rows[a.target]![a.index]!;
|
||||||
|
const real = tileOf(t.id);
|
||||||
|
const correct = real.n === a.value;
|
||||||
|
s.stats[actor]!.guesses += 1;
|
||||||
|
s.history.push({ by: actor, target: a.target, index: a.index, color: real.color, said: a.value, correct, auto });
|
||||||
|
events.push({ type: 'guessed', by: actor, target: a.target, index: a.index, value: a.value, correct });
|
||||||
|
if (correct) {
|
||||||
|
t.revealed = true;
|
||||||
|
s.stats[actor]!.correct += 1;
|
||||||
|
events.push({ type: 'revealed', player: a.target, index: a.index, color: real.color, n: real.n });
|
||||||
|
if (hiddenCount(s.rows[a.target]!) === 0) eliminate(s, a.target, events);
|
||||||
|
if (!checkFinish(s, events)) s.phase = { kind: 'afterCorrect' };
|
||||||
|
} else {
|
||||||
|
t.wrong.push(a.value);
|
||||||
|
if (s.pending) {
|
||||||
|
if (tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: true };
|
||||||
|
else {
|
||||||
|
insertTile(s, actor, s.pending, true, events);
|
||||||
|
endTurn(s, events);
|
||||||
|
}
|
||||||
|
} else if (s.options.emptyPoolPenalty === 'reveal-own') s.phase = { kind: 'revealOwn' };
|
||||||
|
else endTurn(s, events);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'continue':
|
||||||
|
s.phase = { kind: 'guess' };
|
||||||
|
break;
|
||||||
|
case 'stop':
|
||||||
|
if (s.pending && tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: false };
|
||||||
|
else {
|
||||||
|
if (s.pending) insertTile(s, actor, s.pending, false, events);
|
||||||
|
endTurn(s, events);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'placeJoker': {
|
||||||
|
const reveal = (s.phase as Extract<DavinciPhase, { kind: 'placeJoker' }>).reveal;
|
||||||
|
insertTile(s, actor, s.pending!, reveal, events, a.index);
|
||||||
|
endTurn(s, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'revealOwn': {
|
||||||
|
const t = s.rows[actor]![a.index]!;
|
||||||
|
t.revealed = true;
|
||||||
|
const real = tileOf(t.id);
|
||||||
|
events.push({ type: 'revealed', player: actor, index: a.index, color: real.color, n: real.n, own: true });
|
||||||
|
if (hiddenCount(s.rows[actor]!) === 0) eliminate(s, actor, events);
|
||||||
|
endTurn(s, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLeave(s: DavinciState, actor: PlayerId, rng: Rng, events: GameEvent[]): void {
|
||||||
|
s.left[actor] = true;
|
||||||
|
events.push({ type: 'left', player: actor });
|
||||||
|
if (!s.alive[actor]) return;
|
||||||
|
if (s.phase.kind === 'chooseStart') {
|
||||||
|
// Leaving before the deal: out without tiles; the others are dealt as soon as they have all chosen.
|
||||||
|
delete s.phase.picks[actor];
|
||||||
|
eliminate(s, actor, events);
|
||||||
|
if (!checkFinish(s, events, true)) dealIfReady(s, rng, events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Leaving in play: the leaver is out and their tiles are opened.
|
||||||
|
for (const t of s.rows[actor]!) t.revealed = true;
|
||||||
|
if (s.current === actor && s.pending) {
|
||||||
|
insertTile(s, actor, s.pending, true, events);
|
||||||
|
s.pending = null;
|
||||||
|
}
|
||||||
|
eliminate(s, actor, events);
|
||||||
|
if (checkFinish(s, events, true)) return;
|
||||||
|
if (s.current === actor) endTurn(s, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- definition
|
||||||
|
|
||||||
|
export const davinci: GameDefinition<DavinciState, DavinciAction, DavinciView, DavinciOptions> = {
|
||||||
|
id: 'davinci-code',
|
||||||
|
nameKo: '다빈치 코드',
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 4,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: davinciOptionsSchema.parse({}),
|
||||||
|
optionsSchema: davinciOptionsSchema as unknown as z.ZodType<DavinciOptions>,
|
||||||
|
actionSchema: davinciActionSchema as unknown as z.ZodType<DavinciAction>,
|
||||||
|
|
||||||
|
setup({ players, options, rng, now }) {
|
||||||
|
if (players.length < 2 || players.length > 4) throw new Error(`davinci-code: ${players.length} players not allowed`);
|
||||||
|
const startCount = startCountFor(options, players.length);
|
||||||
|
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
|
||||||
|
const first = players[rng.int(players.length)]!;
|
||||||
|
const numbered = allTileIds(false);
|
||||||
|
const jokers = options.jokers ? COLORS.map((c) => tileId(c, 'joker')) : [];
|
||||||
|
const s: DavinciState = {
|
||||||
|
options,
|
||||||
|
seats: [...players],
|
||||||
|
startCount,
|
||||||
|
pool: { black: [], white: [] },
|
||||||
|
jokerReserve: jokers,
|
||||||
|
rows: per(() => []),
|
||||||
|
phase: { kind: 'chooseStart', picks: per(() => null) },
|
||||||
|
current: first,
|
||||||
|
pending: null,
|
||||||
|
alive: per(() => true),
|
||||||
|
left: per(() => false),
|
||||||
|
eliminationOrder: [],
|
||||||
|
stats: per(() => ({ guesses: 0, correct: 0 })),
|
||||||
|
history: [],
|
||||||
|
lastPlaced: null,
|
||||||
|
turnNo: 0,
|
||||||
|
idle: per(() => 0),
|
||||||
|
idleTurn: per(() => -1),
|
||||||
|
phaseStartedAt: now,
|
||||||
|
deadlineAt: null,
|
||||||
|
seq: 0,
|
||||||
|
final: null,
|
||||||
|
};
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
if (options.chooseColors) {
|
||||||
|
for (const c of COLORS) s.pool[c] = rng.shuffle(numbered.filter((id) => tileOf(id).color === c));
|
||||||
|
} else {
|
||||||
|
const deck = rng.shuffle(numbered);
|
||||||
|
for (const p of players) s.rows[p] = deck.splice(0, startCount).map((id) => ({ id, revealed: false, wrong: [] }));
|
||||||
|
for (const c of COLORS) s.pool[c] = deck.filter((id) => tileOf(id).color === c);
|
||||||
|
afterDeal(s, rng, events);
|
||||||
|
}
|
||||||
|
refreshDeadline(s, now, true);
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
validate(s, actor, a) {
|
||||||
|
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
||||||
|
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
|
||||||
|
if (a.type === 'leave') return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
|
||||||
|
if (!s.alive[actor]) return fail('탈락해서 더 할 수 없어요.');
|
||||||
|
if (a.type === 'timeout') return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
|
||||||
|
return validateClient(s, actor, a);
|
||||||
|
},
|
||||||
|
|
||||||
|
apply(prev, actor, a, { rng, now }) {
|
||||||
|
const s = structuredClone(prev);
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
const before = s.phase.kind;
|
||||||
|
if (a.type === 'leave') applyLeave(s, actor, rng, events);
|
||||||
|
else if (a.type === 'timeout') {
|
||||||
|
const concrete = autoAction(s, actor);
|
||||||
|
const v = validateClient(s, actor, concrete);
|
||||||
|
if (!v.ok) throw new Error(`davinci: auto action rejected: ${v.reason}`);
|
||||||
|
if (s.phase.kind !== 'chooseStart' && s.idleTurn[actor] !== s.turnNo) {
|
||||||
|
s.idleTurn[actor] = s.turnNo;
|
||||||
|
s.idle[actor] = (s.idle[actor] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
events.push({ type: 'timeout', player: actor });
|
||||||
|
applyClient(s, actor, concrete, rng, events, true);
|
||||||
|
} else {
|
||||||
|
s.idle[actor] = 0;
|
||||||
|
applyClient(s, actor, a, rng, events, false);
|
||||||
|
}
|
||||||
|
s.seq += 1;
|
||||||
|
// The start choice has one shared deadline; every other step restarts the clock.
|
||||||
|
refreshDeadline(s, now, !(before === 'chooseStart' && s.phase.kind === 'chooseStart'));
|
||||||
|
return { state: s, events };
|
||||||
|
},
|
||||||
|
|
||||||
|
view(s, viewer) {
|
||||||
|
const me = viewer && s.seats.includes(viewer) ? viewer : null;
|
||||||
|
const over = s.phase.kind === 'finished';
|
||||||
|
const rows: Record<PlayerId, DavinciTileView[]> = {};
|
||||||
|
for (const p of s.seats) {
|
||||||
|
rows[p] = s.rows[p]!.map((t) => {
|
||||||
|
const known = over || t.revealed || p === me;
|
||||||
|
if (!known) return { color: tileOf(t.id).color, revealed: false, wrong: [...t.wrong] };
|
||||||
|
const real = tileOf(t.id);
|
||||||
|
return { color: real.color, revealed: t.revealed, n: real.n, id: t.id, wrong: [...t.wrong] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let pending: DavinciView['pending'] = null;
|
||||||
|
if (s.pending) {
|
||||||
|
const real = tileOf(s.pending);
|
||||||
|
pending = s.current === me ? { color: real.color, n: real.n, id: s.pending } : { color: real.color };
|
||||||
|
}
|
||||||
|
if (s.options.deductionHelper && !over) {
|
||||||
|
for (const p of s.seats) {
|
||||||
|
if (p === me) continue;
|
||||||
|
rows[p]!.forEach((t, i) => {
|
||||||
|
if (t.n === undefined) t.candidates = candidatesFor(rows, pending, p, i, s.options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ph = s.phase;
|
||||||
|
const choosing = ph.kind === 'chooseStart';
|
||||||
|
return {
|
||||||
|
options: s.options,
|
||||||
|
me,
|
||||||
|
seats: s.seats,
|
||||||
|
phase: ph.kind,
|
||||||
|
current: s.current,
|
||||||
|
startCount: s.startCount,
|
||||||
|
rows,
|
||||||
|
pool: { black: s.pool.black.length, white: s.pool.white.length },
|
||||||
|
pending,
|
||||||
|
picked: choosing ? Object.fromEntries(s.seats.map((p) => [p, !!ph.picks[p]])) : null,
|
||||||
|
myPick: choosing && me ? (ph.picks[me] ?? null) : null,
|
||||||
|
pickLimits: choosing ? pickLimits(s) : null,
|
||||||
|
alive: s.alive,
|
||||||
|
left: s.left,
|
||||||
|
eliminationOrder: s.eliminationOrder,
|
||||||
|
stats: s.stats,
|
||||||
|
history: s.history,
|
||||||
|
lastPlaced: s.lastPlaced,
|
||||||
|
idle: s.idle,
|
||||||
|
deadlineAt: s.deadlineAt,
|
||||||
|
final: s.final,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
activePlayers: waiting,
|
||||||
|
|
||||||
|
deadline(s) {
|
||||||
|
return s.phase.kind === 'finished' ? null : s.deadlineAt;
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutPlayers: waiting,
|
||||||
|
|
||||||
|
onTimeout() {
|
||||||
|
return { type: 'timeout' };
|
||||||
|
},
|
||||||
|
|
||||||
|
onLeave(s, player) {
|
||||||
|
if (!s.seats.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,
|
||||||
|
summary: s.final.summary,
|
||||||
|
reason: s.final.reason,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
legalActions(s, p) {
|
||||||
|
if (!s.seats.includes(p) || !s.alive[p] || s.phase.kind === 'finished') return [];
|
||||||
|
const out: DavinciAction[] = [];
|
||||||
|
const ph = s.phase;
|
||||||
|
if (ph.kind === 'chooseStart') {
|
||||||
|
if (ph.picks[p]) return [];
|
||||||
|
const lim = pickLimits(s);
|
||||||
|
for (let b = 0; b <= s.startCount; b++) {
|
||||||
|
const w = s.startCount - b;
|
||||||
|
if (b <= lim.black && w <= lim.white) out.push({ type: 'chooseStart', black: b, white: w });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (s.current !== p) return [];
|
||||||
|
switch (ph.kind) {
|
||||||
|
case 'draw':
|
||||||
|
if (!s.options.chooseColors) return [{ type: 'draw' }];
|
||||||
|
return COLORS.filter((c) => s.pool[c].length > 0).map((color) => ({ type: 'draw', color }));
|
||||||
|
case 'guess': {
|
||||||
|
const values: TileValue[] = [...Array.from({ length: MAX_N + 1 }, (_, n) => n), ...(s.options.jokers ? ['joker' as const] : [])];
|
||||||
|
for (const target of s.seats) {
|
||||||
|
if (target === p || !s.alive[target]) continue;
|
||||||
|
s.rows[target]!.forEach((t, index) => {
|
||||||
|
if (!t.revealed) for (const value of values) out.push({ type: 'guess', target, index, value });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case 'afterCorrect':
|
||||||
|
return [{ type: 'continue' }, { type: 'stop' }];
|
||||||
|
case 'placeJoker':
|
||||||
|
return Array.from({ length: s.rows[p]!.length + 1 }, (_, index) => ({ type: 'placeJoker', index }));
|
||||||
|
case 'revealOwn':
|
||||||
|
return s.rows[p]!.flatMap((t, index) => (t.revealed ? [] : [{ type: 'revealOwn' as const, index }]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Ids of `player`'s hidden tiles and pending tile, plus the pool (secret to everyone). */
|
||||||
|
secretsOf(s, player) {
|
||||||
|
const pool = [...s.pool.black, ...s.pool.white, ...s.jokerReserve];
|
||||||
|
if (s.phase.kind === 'finished') return pool;
|
||||||
|
const own = (s.rows[player] ?? []).filter((t) => !t.revealed).map((t) => t.id);
|
||||||
|
if (s.current === player && s.pending) own.push(s.pending);
|
||||||
|
return [...own, ...pool];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { yacht } from './yacht';
|
|||||||
import { yut } from './yut';
|
import { yut } from './yut';
|
||||||
import { oneCard } from './one-card';
|
import { oneCard } from './one-card';
|
||||||
import { rummikub } from './rummikub';
|
import { rummikub } from './rummikub';
|
||||||
|
import { davinci } from './davinci-code';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -23,4 +24,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[yut.id]: yut,
|
[yut.id]: yut,
|
||||||
[oneCard.id]: oneCard,
|
[oneCard.id]: oneCard,
|
||||||
[rummikub.id]: rummikub,
|
[rummikub.id]: rummikub,
|
||||||
|
[davinci.id]: davinci,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
|
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
|
||||||
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
||||||
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
||||||
|
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user