diff --git a/apps/web/src/games/davinci-code/index.tsx b/apps/web/src/games/davinci-code/index.tsx new file mode 100644 index 0000000..122daf7 --- /dev/null +++ b/apps/web/src/games/davinci-code/index.tsx @@ -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 = { black: '검은색', white: '흰색' }; +const COLOR_SHORT: Record = { black: '검정', white: '흰색' }; +const NUMBERS = Array.from({ length: 12 }, (_, i) => i); + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => + players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const 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 { + const out = new Set(); + 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 { + 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 ( + + {known ? faceOf(n) : ?} + + ); +} + +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) { + 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(null); + const [val, setVal] = useState(null); + const [fx, setFx] = useState>(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(); + 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 ( +
+ +
+ {opponentsOf(v, me).map((p) => ( + + ))} +
+
+
+ (setSel(null), setVal(null))} /> +
+ {amPlayer && } +

+ {announce} +

+
+ ); +} + +function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record; 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 ( +
+ + + {u.nickname} + {id === me && (나)} + + {isTurn && 차례} + {v.final?.winner === id && 승리} + {!v.alive[id] && {v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}} + {v.alive[id] && (v.idle[id] ?? 0) >= 3 && 자리 비움} + {v.phase === 'chooseStart' && v.alive[id] && {choosing ? '고르는 중' : '다 골랐어요'}} + + {v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses} + + {(isTurn || choosing) && } +
+ ); +} + +function PlayerRow({ + id, + view: v, + me, + players, + deadline, + fx, + canPick, + sel, + onPick, +}: { + id: string; + view: DavinciView; + me: string | null; + players: Record; + deadline: number | null; + fx: Map; + 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 ( +
+ +
+ {row.length === 0 && {v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}} + {row.map((t, i) => { + const can = canPick && !t.revealed; + const selected = sel?.target === id && sel.index === i; + return ( +
+ + + {i + 1} + +
+ ); + })} +
+
+ ); +} + +function MyRow({ + view: v, + me, + players, + act, + pending, + myTurn, + deadline, + fx, +}: { + view: DavinciView; + me: string; + players: Record; + act: (a: unknown) => void; + pending: boolean; + myTurn: boolean; + deadline: number | null; + fx: Map; +}) { + 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 && ( + + ); + return ( +
+ +
+ {row.length === 0 && {v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}} + {row.map((t, i) => { + const can = revealing && !t.revealed; + return [ + gap(i), +
+ {can ? ( + + ) : ( + + + + )} + + {i + 1} + +
, + ]; + })} + {gap(row.length)} +
+ {row.length > 0 && ( +
+ + 점선: 상대에게 숨김 + + + 아래 노란 줄: 모두에게 공개됨 + +
+ )} +
+ ); +} + +function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record; 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 ( +
+ {v.options.chooseColors ? ( +
+ {(['black', 'white'] as const).map((c) => { + const can = drawing && v.pool[c] > 0; + return ( + + ); + })} +
+ ) : ( + + )} + {v.pending ? ( +
+ + + {v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`} +
+ + {v.current === me ? '나만 보여요' : '색만 보여요'} + +
+
+ ) : ( + v.phase !== 'chooseStart' && + v.phase !== 'finished' && + total === 0 && 더미가 비었어요. 가져오지 않고 바로 추리해요. + )} + {last && ( + + 최근: {guessLine(last, players)} + + )} +
+ ); +} + +function ActionPanel({ + view: v, + me, + players, + act, + pending, + myTurn, + sel, + val, + setVal, + onCancel, +}: { + view: DavinciView; + me: string | null; + players: Record; + 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 ( +
+ 게임이 끝났어요 + {v.final?.summary} + 모든 타일을 공개했어요. +
+ ); + } + if (v.phase === 'chooseStart') return ; + if (!myTurn) { + const who = nick(players, v.current); + const doing: Record = { + chooseStart: '', + draw: '타일을 가져오는 중이에요', + guess: '추리하는 중이에요', + afterCorrect: '맞혔어요! 계속할지 고르는 중이에요', + placeJoker: '조커를 넣을 자리를 고르는 중이에요', + revealOwn: '틀려서 자기 타일을 공개하는 중이에요', + finished: '', + }; + return ( +
+ + {who}님이 {doing[v.phase]} + + {me && v.seats.includes(me) && !v.alive[me] && 나는 탈락했어요. 끝까지 구경할 수 있어요.} + 공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요. +
+ ); + } + switch (v.phase) { + case 'draw': + return ( +
+ 더미에서 타일을 하나 가져와 주세요. + + {v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요. + +
+ ); + case 'guess': + return sel ? : ( +
+ 맞힐 상대 타일을 눌러 주세요. + 파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}. +
+ ); + case 'afterCorrect': + return ( +
+ 맞혔어요! +
+ + +
+ + {v.pending + ? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.' + : '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'} + +
+ ); + case 'placeJoker': + return ( +
+ 조커를 넣을 자리를 골라 주세요. + 내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요. +
+ ); + case 'revealOwn': + return ( +
+ 틀렸어요. 공개할 내 타일을 하나 골라 주세요. + 더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요. +
+ ); + default: + return null; + } +} + +function NumberPad({ + view: v, + players, + act, + pending, + sel, + val, + setVal, + onCancel, +}: { + view: DavinciView; + players: Record; + 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(null); + useEffect(() => { + padRef.current?.querySelector('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 ( +
{ + if (e.key === 'Escape') onCancel(); + }} + > + + {nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요? + + {tile.wrong.length > 0 && 이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}} +
+ {values.map((x) => ( + + ))} +
+ + {warn ? `${warn} 그래도 말할 수 있어요.` : ''} + +
+ + +
+
+ ); +} + +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(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 ( +
+ 모두 시작 타일 색을 고르는 중이에요 ({done}/{total}) +
+ ); + } + if (v.myPick) { + return ( +
+ + 검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요. + + + 다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요. + +
+ ); + } + const okPick = (b: number) => b <= lim.black && n - b <= lim.white; + const chosen = okPick(black) ? black : null; + return ( +
+ 시작 타일 {n}개의 색을 골라 주세요. + 숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요. +
+ {Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => ( + + ))} +
+ {(lim.black < n || lim.white < n) && ( + + 먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요. + + )} + +
+ ); +} + +// ---------------------------------------------------------------- side + +function DavinciSide({ view: v, me, players, deadline }: BoardProps) { + const running = v.phase !== 'finished'; + const ranking = v.final?.ranking ?? null; + return ( + <> + +
+
+ {running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'} + {running && } +
+ {optionsSummary(v.options)} +
    + {v.seats.map((p) => { + const st = v.stats[p] ?? { guesses: 0, correct: 0 }; + const out = v.eliminationOrder.indexOf(p); + return ( +
  1. + + + {nick(players, p)} + {running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''} + {v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''} + + + 숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses} + +
  2. + ); + })} +
+
+ {ranking && ( +
+ 최종 순위 + {ranking.map((g, i) => ( + + {i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')} + + ))} +
+ )} +
+ 추리 기록 ({v.history.length}) + {v.history.length === 0 ? ( +

+ 아직 추리가 없어요. +

+ ) : ( +
    + {[...v.history].reverse().map((g, i) => ( +
  1. + {guessLine(g, players)} +
  2. + ))} +
+ )} +
+
+ 정렬 규칙 + +
+ + ); +} + +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 ( +
+
`${COLOR_SHORT[c]} ${n}`).join(', ')}`}> + {tiles.map(([c, n], i) => ( + + ))} +
+ 작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요. +
+ ); +} + +// ---------------------------------------------------------------- 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 { + 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({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ jokers })} /> + + + set({ startTiles })} /> + + + set({ turnSeconds })} /> + + + set({ deductionHelper })} /> + + + {more && ( + <> + + set({ tieOrder })} /> + + + set({ chooseColors })} /> + + + set({ emptyPoolPenalty })} /> + + + )} +
+ ); +} + +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 ( +
+

+ 검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, 상대 타일의 숫자를 추리해 맞히는 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은 + 타일이 남은 사람이 이겨요. +

+

+ 준비: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요. + {o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요. +

+

+ 정렬: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지 + 않아요. +

+ +

+ 내 차례: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요. +

+
    +
  • + 맞히면 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요. +
  • +
  • + 틀리면 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요. +
  • +
+

+ 더미가 비면 가져오지 않고 바로 추리해요. 이때 틀리면{' '} + {o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'} +

+ {o?.jokers && ( +

+ 조커: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때 + "조커"라고 말할 수도 있어요. +

+ )} +

+ 이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 추리 기록에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요. +

+

+ 시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요. +

+
+ ); +} + +const ui: GameUI = { + Board: DavinciBoard, + Side: DavinciSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index eb83eb1..570d217 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -13,6 +13,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { yut: () => import('./yut'), 'one-card': () => import('./one-card'), rummikub: () => import('./rummikub'), + 'davinci-code': () => import('./davinci-code'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 03072b7..86f11d2 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -21,5 +21,10 @@ export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; 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). */ export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 11bb765..8837032 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -319,6 +319,40 @@ const PLANS: Record = { if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`); if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') }); console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`); + '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: { diff --git a/packages/games/src/davinci-code/davinci-code.test.ts b/packages/games/src/davinci-code/davinci-code.test.ts new file mode 100644 index 0000000..5e1c90d --- /dev/null +++ b/packages/games/src/davinci-code/davinci-code.test.ts @@ -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 = {}, 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, + opts: Partial = {}, + 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 }).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); + } +}); diff --git a/packages/games/src/davinci-code/index.ts b/packages/games/src/davinci-code/index.ts new file mode 100644 index 0000000..b7dca5b --- /dev/null +++ b/packages/games/src/davinci-code/index.ts @@ -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 = { 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; + +// ---------------------------------------------------------------- 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; +/** 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 } + | { 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; + phase: DavinciPhase; + current: PlayerId; + /** This turn's drawn tile (only the drawer knows the number). */ + pending: string | null; + alive: Record; + left: Record; + eliminationOrder: PlayerId[]; + stats: Record; + 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; + idleTurn: Record; + 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; + pool: { black: number; white: number }; + pending: { color: TColor; n?: TileValue; id?: string } | null; + /** chooseStart: who has submitted (others' choices stay secret). */ + picked: Record | 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; + left: Record; + eliminationOrder: PlayerId[]; + stats: Record; + history: DavinciGuess[]; + lastPlaced: DavinciState['lastPlaced']; + idle: Record; + 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, + pending: DavinciView['pending'], + target: PlayerId, + index: number, + o: Pick, +): TileValue[] { + const row = rows[target] ?? []; + const tile = row[index]; + if (!tile || tile.n !== undefined) return []; + const known = new Set(); + 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; + 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).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 = { + id: 'davinci-code', + nameKo: '다빈치 코드', + minPlayers: 2, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: davinciOptionsSchema.parse({}), + optionsSchema: davinciOptionsSchema as unknown as z.ZodType, + actionSchema: davinciActionSchema as unknown as z.ZodType, + + 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 = (f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record; + 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 = {}; + 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]; + }, +}; diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 6302a9e..a4d2a12 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -10,6 +10,7 @@ import { yacht } from './yacht'; import { yut } from './yut'; import { oneCard } from './one-card'; import { rummikub } from './rummikub'; +import { davinci } from './davinci-code'; export const GAMES: Record = { [omok.id]: omok, @@ -23,4 +24,5 @@ export const GAMES: Record = { [yut.id]: yut, [oneCard.id]: oneCard, [rummikub.id]: rummikub, + [davinci.id]: davinci, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index c5d0505..b3bd701 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -22,6 +22,7 @@ export const CATALOG: CatalogEntry[] = [ { 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: '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);