diff --git a/apps/server/src/rooms/dobble.room.test.ts b/apps/server/src/rooms/dobble.room.test.ts new file mode 100644 index 0000000..384bbe4 --- /dev/null +++ b/apps/server/src/rooms/dobble.room.test.ts @@ -0,0 +1,120 @@ +/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */ +import { describe, expect, test } from 'bun:test'; +import type { ServerMessage } from '@bg/shared'; +import { GAMES } from '@bg/games'; +import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble'; +import { openDb } from '../db'; +import { Room, type Conn, type RoomConfig } from './room'; +import { RoomStore } from './store'; + +function harness(players: string[], opts: Partial = {}) { + const db = openDb(':memory:'); + const store = new RoomStore(db); + let now = 1_000_000; + const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) }; + const games = { dobble: GAMES.dobble! }; + const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now }; + const config: RoomConfig = { + gameId: 'dobble', + options: games.dobble.optionsSchema.parse(opts), + maxPlayers: players.length, + visibility: 'private', + allowSpectators: true, + chatEnabled: true, + chatFilter: true, + graceSec: 60, + }; + const room = new Room('room1', '482913', config, players[0]!, deps); + store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now }); + const conns: Record = {}; + for (const p of players) { + const msgs: ServerMessage[] = []; + conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} }; + room.join(conns[p], 'player'); + } + expect(room.start(players[0]!)).toBeNull(); + room.clearTimer(); + // 3s countdown → playing. + clock.advance(3000); + room.onTimer(); + room.clearTimer(); + const state = () => room.game!.state as DobbleState; + /** The correct claim of `p` (first pair), as the client would send it. */ + const right = (p: string) => { + const pair = pairsFor(state(), p)[0]!; + return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) }; + }; + return { room, clock, conns, state, right }; +} + +describe('도블 room flow', () => { + test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => { + const { room, conns, state, right } = harness(['A', 'B', 'C']); + const a = right('A'); + const b = right('B'); + const towerB = state().piles.B!.length; + room.act('B', 1, b); + room.act('A', 1, a); + room.clearTimer(); + expect(state().piles.B).toHaveLength(towerB + 1); + expect(state().piles.A).toHaveLength(1); + expect(state().lockedUntil.A).toBe(0); + expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]); + // Nobody's view carries the answer or hidden cards; the hit is public. + const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract; + expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' }); + }); + + test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => { + const { room, conns } = harness(['A', 'B']); + room.act('A', 1, { type: 'tick' }); + room.act('A', 2, { type: 'leave' }); + expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']); + }); + + test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => { + const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' }); + // RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap). + const s = state(); + s.rtt.A = 20; + s.rtt.B = 200; + const a = right('A'); + const b = right('B'); + room.act('A', 1, a); + clock.advance(40); + room.act('B', 1, b); + expect(state().pendingWindow?.claims).toHaveLength(2); + expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60); + clock.set(room.effectiveDeadline()!); + room.onTimer(); + room.clearTimer(); + expect(state().pendingWindow).toBeNull(); + // B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10). + expect(state().lastHit).toMatchObject({ player: 'B' }); + }); + + test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => { + const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' }); + const t0 = clock.now(); + room.disconnect(conns.C!); + room.clearTimer(); + let fired = 0; + while (state().status.C === 'playing' && fired < 50) { + const d = room.effectiveDeadline()!; + if (d > clock.now()) clock.set(d); + room.onTimer(); + room.clearTimer(); + fired++; + // The others keep finding pictures now and then. + if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A')); + room.clearTimer(); + } + expect(state().status.C).toBe('forfeit'); + const took = clock.now() - t0; + expect(took).toBeGreaterThanOrEqual(60_000 + 20_000); + expect(took).toBeLessThanOrEqual(60_000 + 45_000); + expect(fired).toBeLessThan(20); + expect(room.status).toBe('playing'); + expect(state().phase).toBe('playing'); + }); +}); diff --git a/apps/web/src/games/dobble/art.test.ts b/apps/web/src/games/dobble/art.test.ts new file mode 100644 index 0000000..f532d75 --- /dev/null +++ b/apps/web/src/games/dobble/art.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from 'bun:test'; +import { SYMBOL_NAMES } from '@bg/games/dobble'; +import { ICONS, ICON_NAMES } from './art'; + +test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => { + expect(ICONS).toHaveLength(57); + expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]); +}); diff --git a/apps/web/src/games/dobble/art.tsx b/apps/web/src/games/dobble/art.tsx new file mode 100644 index 0000000..7d29158 --- /dev/null +++ b/apps/web/src/games/dobble/art.tsx @@ -0,0 +1,556 @@ +/** + * 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box. + * Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly). + * Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble). + */ +import type { ReactNode } from 'react'; + +const O = '#1f2937'; +/** Outline props for filled shapes. */ +const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const; +/** Thick stroked lines (no fill). */ +const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const; + +const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) => + Array.from({ length: n }, (_, i) => { + const a = a0 + (i * 2 * Math.PI) / n; + return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`; + }).join(' '); + +const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) => + Array.from({ length: n * 2 }, (_, i) => { + const a = -Math.PI / 2 + (i * Math.PI) / n; + const r = i % 2 ? ri : ro; + return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`; + }).join(' '); + +/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */ +export const ICON_NAMES = [ + '사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름', + '나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지', + '물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠', + '시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻', + '자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성', + '선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개', +]; + +export const ICONS: (() => ReactNode)[] = [ + // 0 사과 + () => ( + <> + + + + + + ), + // 1 해 + () => ( + <> + + + + ), + // 2 달 + () => , + // 3 별 + () => , + // 4 하트 + () => , + // 5 물방울 + () => ( + <> + + + + ), + // 6 불꽃 + () => ( + <> + + + + ), + // 7 눈송이 + () => ( + <> + + { + const a = Math.PI / 2 + (i * Math.PI) / 3; + const bx = 50 + Math.cos(a) * 26; + const by = 50 + Math.sin(a) * 26; + const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`; + return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`; + }).join(' ')} {...W('#0284c7', 5)} /> + + ), + // 8 번개 + () => , + // 9 구름 + () => , + // 10 나무 (pine) + () => ( + <> + + + + ), + // 11 꽃 + () => ( + <> + {[0, 1, 2, 3, 4].map((i) => { + const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; + return ; + })} + + + ), + // 12 나뭇잎 + () => ( + <> + + + + ), + // 13 선인장 + () => ( + <> + + + + + + ), + // 14 버섯 + () => ( + <> + + + + + + + + ), + // 15 당근 + () => ( + <> + + + + + ), + // 16 레몬 + () => ( + <> + + + + ), + // 17 체리 + () => ( + <> + + + + + + + + ), + // 18 고양이 + () => ( + <> + + + + + + + ), + // 19 강아지 + () => ( + <> + + + + + + + + ), + // 20 물고기 + () => ( + <> + + + + + + ), + // 21 새 + () => ( + <> + + + + + ), + // 22 거북이 + () => ( + <> + + + + + + + ), + // 23 무당벌레 + () => ( + <> + + + + + + + + + + ), + // 24 거미 + () => ( + <> + + + + + + + + ), + // 25 공룡 + () => ( + + ), + // 26 토끼 + () => ( + <> + + + + + + + + ), + // 27 펭귄 + () => ( + <> + + + + + + + + + + ), + // 28 열쇠 + () => ( + <> + + + + + ), + // 29 자물쇠 + () => ( + <> + + + + + + + ), + // 30 시계 + () => ( + <> + + + + + + + ), + // 31 전구 + () => ( + <> + + + + + + + ), + // 32 연필 + () => ( + + + + + + + + ), + // 33 가위 + () => ( + <> + + + + + + + + + ), + // 34 안경 + () => ( + <> + + + + + + ), + // 35 우산 + () => ( + <> + + + + + ), + // 36 모자 (top hat) + () => ( + <> + + + + + ), + // 37 왕관 + () => ( + <> + + + + + + + ), + // 38 종 + () => ( + <> + + + + + + ), + // 39 닻 + () => ( + <> + + + + ), + // 40 자전거 + () => ( + <> + + + + + + ), + // 41 자동차 + () => ( + <> + + + + + + + + + ), + // 42 로켓 + () => ( + <> + + + + + + + ), + // 43 배 (sailboat) + () => ( + <> + + + + + + ), + // 44 공 (soccer ball) + () => ( + <> + + i % 2 === 0).join(' ')} fill={O} /> + + {[0, 1, 2, 3, 4].map((i) => { + const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; + return ; + })} + + ), + // 45 주사위 + () => ( + <> + + {[ + [30, 30], + [70, 30], + [50, 50], + [30, 70], + [70, 70], + ].map(([x, y]) => ( + + ))} + + ), + // 46 음표 + () => ( + <> + + + + ), + // 47 카메라 + () => ( + <> + + + + + + + + ), + // 48 집 + () => ( + <> + + + + + + + ), + // 49 성 + () => ( + <> + + + + + + + ), + // 50 선물상자 + () => ( + <> + + + + + + ), + // 51 촛불 + () => ( + <> + + + + + + + ), + // 52 망치 + () => ( + + + + + ), + // 53 느낌표 + () => ( + <> + + + + ), + // 54 물음표 + () => ( + <> + + + + + ), + // 55 눈사람 + () => ( + <> + + + + + + + + + + + ), + // 56 무지개 + () => ( + <> + {['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => ( + + ))} + + + + ), +]; + +/** One icon in its own 100×100 SVG. */ +export function SymbolArt({ art, className }: { art: number; className?: string }) { + const draw = ICONS[art]; + return ( + + {draw ? draw() : null} + + ); +} diff --git a/apps/web/src/games/dobble/index.tsx b/apps/web/src/games/dobble/index.tsx new file mode 100644 index 0000000..42ac46b --- /dev/null +++ b/apps/web/src/games/dobble/index.tsx @@ -0,0 +1,854 @@ +/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } from '../../components/ui'; +import { useServerNow } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import { socket, useRoom } from '../../net/socket'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { ICON_NAMES, SymbolArt } from './art'; + +/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */ +const TAP_GRACE_MS = 250; +const MINI_KO: Record = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' }; +const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? '')); +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 who = (players: Record, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); + +const CSS = ` +.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); } +.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; } +.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; } +.db-pill.warn { background: var(--warning-soft); color: var(--warning); } +.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; } +.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; } +.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; } +button.db-seat { cursor: pointer; } +.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } +.db-seat[data-out='true'] { opacity: .55; } +.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; } +.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; } +.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; } +.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; } +.db-tag.ok { background: #15803d; } +.db-tag.warn { background: #f59e0b; color: #111827; } +.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; } +.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); } +.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); } +.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; } +.db-card[data-shake='true'] { animation: db-shake .4s ease; } +.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } +.db-sym:disabled { cursor: default; } +@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } } +.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; } +.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); } +.db-sym .ic svg { width: 100%; height: 100%; display: block; } +.db-sym .ic.in { animation: db-in .25s ease-out; } +.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; } +.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); } +.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; } +.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; } +.db-thumb .db-sym { cursor: inherit; } +.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; } +.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; } +.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px; + font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; } +.db-count small { font-size: 1.1rem; padding: 0 12px; } +.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px; + font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; } +.db-bubble .bi { width: 34px; height: 34px; flex: none; } +.db-bubble.ok { border: 4px solid #16a34a; } +.db-bubble.bad { border: 4px solid #dc2626; } +.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; } +.db-bubble.info { border: 4px solid #2563eb; } +.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; } +.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; } +.db-sus { color: var(--warning); font-weight: 800; } +@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } } +@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } } +@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } } +@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } } +@media (max-width: 719px) { + .db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; } + .db-slot { position: relative; } + .db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; } + .db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; } + .db-pill { font-size: .8rem; padding: 2px 8px; } + .db-strip { justify-content: flex-start; } + .db-seat { padding: 3px 8px; gap: 6px; } + .db-seat .meta.rtt, .db-pill.mode { display: none; } + .db-seat .who { flex-direction: row; align-items: center; gap: 5px; } + .db-seat .nm { max-width: 72px; font-size: .8rem; } + .db-thumb { width: 38px; height: 38px; } + .db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; } + .db-bubble .bi { width: 26px; height: 26px; } + .db-count { font-size: 4rem; } +} +@media (prefers-reduced-motion: reduce) { + .db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; } + .db-sym[data-hit='true'] .ic { transition: none; } +} +`; + +// ---------------------------------------------------------------- card + +function usePhone(): boolean { + const q = '(max-width: 719px)'; + const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches); + useEffect(() => { + const mq = window.matchMedia(q); + const on = () => setM(mq.matches); + mq.addEventListener('change', on); + return () => mq.removeEventListener('change', on); + }, []); + return m; +} + +/** + * A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes + * (the picture inside is keyed by art and pops in). `onPick` null = look only. + */ +function DobbleCard({ + card, + label, + side, + onPick, + hint, + hit, + shake, + children, +}: { + card: CardView; + label: string; + side: string; + onPick: ((art: number) => void) | null; + hint?: number[]; + hit?: number | null; + shake?: boolean; + children?: ReactNode; +}) { + return ( +
+ {card.items.map((it, i) => { + const d = it.r * 100; + return ( + + ); + })} + {children} +
+ ); +} + +/** Small look-only card (target picker, rules). */ +function Thumb({ card }: { card: CardView | null }) { + if (!card) return ; + return ( + + {card.items.map((it, i) => { + const d = it.r * 100; + return ( + + + + + + ); + })} + + ); +} + +// ---------------------------------------------------------------- seats + +function countText(mode: MiniGame, s: SeatView): string { + switch (mode) { + case 'tower': + return `탑 ${s.cards}장`; + case 'well': + return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`; + case 'poisonedGift': + return `받은 카드 ${s.cards}장`; + case 'hotPotato': + return `${s.cards}장 · 벌점 ${s.penalty}`; + } +} + +function statusTag(s: SeatView): { text: string; cls: string } | null { + if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' }; + if (s.status === 'escaped') return { text: '탈출', cls: 'ok' }; + if (s.status === 'forfeit') return { text: '기권', cls: '' }; + if (s.status === 'left') return { text: '나감', cls: '' }; + return null; +} + +function Rtt({ ms }: { ms: number | null }) { + if (ms === null) return null; + const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626'; + return ( + + + {ms}ms + + ); +} + +function seatLabel(v: DobbleView, s: SeatView, players: Record, me: string | null): string { + const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)]; + const tag = statusTag(s); + if (tag) parts.push(tag.text); + return parts.join(', '); +} + +// ---------------------------------------------------------------- board + +interface Bubble { + key: string; + kind: 'ok' | 'bad' | 'late' | 'info'; + text: string; + art?: number | null; +} + +function DobbleBoard({ view: v, me, players, act, pending }: BoardProps) { + const now = useServerNow(100); + const phone = usePhone(); + const rtt = useRoom((s) => s.rttMs); + const [sel, setSel] = useState(null); + const [bubble, setBubble] = useState(null); + const [shake, setShake] = useState(false); + const [hitArt, setHitArt] = useState(null); + const [announce, setAnnounce] = useState(''); + const prev = useRef(null); + const changed = useRef({ sig: '', at: 0 }); + + const mode = v.options.miniGame; + const targetMode = mode === 'hotPotato' || mode === 'poisonedGift'; + const mine = v.seats.find((s) => s.id === me) ?? null; + const others = v.seats.filter((s) => s.id !== me); + const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null); + // The opponent whose card is shown big (also for spectators / while waiting). + const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null; + const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null; + // Clamped to the lock length: the client clock estimate may be a little off. + const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0; + const playing = v.phase === 'playing'; + const canTap = !!pair && playing && lockedLeft === 0 && !pending; + + const claim = (art: number) => { + if (!pair) return; + // A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty. + if (performance.now() - changed.current.at < TAP_GRACE_MS) { + setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' }); + return; + } + if (!canTap) { + if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` }); + return; + } + haptic(15); + act({ + type: 'claim', + base: pair.base, + symbol: art, + ...(pair.target ? { target: pair.target } : {}), + ...(pair.mine !== null ? { mine: pair.mine } : {}), + ...(rtt !== null ? { rtt } : {}), + }); + }; + + // "늦었어요" rejects come back as `reject` messages. + useEffect( + () => + socket.subscribe((m) => { + if (m.t === 'reject' && m.reason.startsWith('늦었어요')) { + setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason }); + setAnnounce(m.reason); + } + }), + [], + ); + + // Diff against the previous view: my judgement, other people's finds, rounds. + useEffect(() => { + const p = prev.current; + prev.current = v; + if (!p) return; + const f = v.feedback; + if (f && f.seq !== p.feedback?.seq) { + if (f.kind === 'ok') { + sounds.ding(); + haptic([30, 30, 60]); + setHitArt(f.art); + const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art }; + setBubble(b); + setAnnounce(`찾았어요! ${artName(f.art)}`); + } else if (f.kind === 'wrong') { + sounds.buzz(); + haptic([90, 50, 90]); + setShake(true); + const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : ''; + const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art }; + setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`); + setBubble(b); + } else if (f.kind === 'late') { + setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' }); + setAnnounce('늦었어요'); + } else if (f.kind === 'locked') { + setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' }); + } + return; + } + const h = v.lastHit; + if (h && h.seq !== p.lastHit?.seq && h.player !== me) { + sounds.stone(); + let text = `${nick(players, h.player)}님이 찾았어요`; + if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`; + if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`; + setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art }); + setAnnounce(`${text} · ${artName(h.art)}`); + } + const r = v.lastRound; + if (r && r.round !== p.lastRound?.round) { + const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`; + setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text }); + setAnnounce(text); + } + }, [v]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!bubble) return; + const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700); + return () => clearTimeout(t); + }, [bubble]); + useEffect(() => { + if (!shake) return; + const t = setTimeout(() => setShake(false), 450); + return () => clearTimeout(t); + }, [shake]); + useEffect(() => { + if (hitArt === null) return; + const t = setTimeout(() => setHitArt(null), 500); + return () => clearTimeout(t); + }, [hitArt]); + + const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0; + const hint = v.hintUntil > now ? v.hint : []; + const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null; + + // Which two cards are compared (§5.2): A = top/left, B = bottom/right. + const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드'); + let a: { card: CardView | null; cap: string; side: string }; + let b: { card: CardView | null; cap: string; side: string }; + if (mode === 'hotPotato') { + a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' }; + b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' }; + } else if (mode === 'poisonedGift') { + a = { card: v.center, cap: '가운데 카드', side: 'center' }; + b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' }; + } else { + a = { card: v.center, cap: '가운데 카드', side: 'center' }; + b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' }; + } + const emptyText = (side: string): string => { + if (v.phase === 'countdown') return '곧 공개돼요'; + if (side === 'mine') { + if (!mine) return '관전 중이에요'; + if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`; + if (mine.status === 'escaped') return '이번 라운드 탈출!'; + if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요'; + } + if (v.phase === 'roundEnd') return '다음 라운드를 준비해요'; + return '카드가 없어요'; + }; + const slot = (x: typeof a, withLock: boolean) => ( +
+ {x.cap} + {x.card ? ( + + {withLock && lockedLeft > 0 && ( + + 잠깐! {Math.ceil(lockedLeft / 1000)}초 + + )} + + ) : ( +
+ {emptyText(x.side)} +
+ )} +
+ ); + + const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`; + if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() }; + + const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`; + const pickable = new Set(v.pairs.map((p) => p.target)); + + return ( +
+ +
+
+ {MINI_KO[mode]} + {remaining && {remaining}} + {mine && 내 카드 · {countText(mode, mine)}} +
+
+ {v.options.showRtt && rtt !== null && ( + = 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}> + + {rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`} + + )} +
+
+ + {others.length > 0 && ( +
    + {others.map((s) => { + const tag = statusTag(s); + const canPick = targetMode && pickable.has(s.id); + const inner = ( + <> + {targetMode && } + {!targetMode && } + + + {targetMode && } + {nick(players, s.id)} + + + {countText(mode, s)} + {s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''} + + {v.options.showRtt && } + + {tag && {tag.text}} + + ); + return ( +
  • + {canPick ? ( + + ) : ( +
    + {inner} +
    + )} +
  • + ); + })} +
+ )} + +
+ {slot(a, false)} + {slot(b, true)} + {bubble && ( +
+ {bubble.art !== undefined && bubble.art !== null && } + {bubble.text} +
+ )} + {countdown > 0 && ( +
+ {countdown} + {v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'} +
+ )} +
+ + {targetMode && v.pairs.length > 1 && playing && ( +
+ {mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: {targetSeat ? nick(players, targetSeat.id) : ''}님 +
+ )} + {!phone && pair && playing && ( + + Tab 키로 그림을 고르고 Enter 키로 눌러요 + + )} +

+ {announce} +

+
+ ); +} + +// ---------------------------------------------------------------- side + +function logText(e: LogEntry, mode: MiniGame, players: Record, me: string | null): string { + switch (e.t) { + case 'hit': { + const n = who(players, me, e.p); + const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`; + if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`; + if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`; + if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`; + return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`; + } + case 'done': + return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`; + case 'round': + return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`; + case 'out': + return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`; + case 'hint': + return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요'; + } +} + +function rulesLine(o: DobbleOptions): string { + const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`]; + if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`); + parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음'); + if (o.symbolStyle === 'easy') parts.push('그림 크기 같게'); + if (o.latencyComp === 'light') parts.push('지연 보정'); + if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝'); + if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납'); + return parts.join(' · '); +} + +function DobbleSide({ view: v, me, players }: BoardProps) { + const mode = v.options.miniGame; + return ( + <> + +
+ 도블{v.phase === 'ended' ? ' · 게임 끝' : ''} + {rulesLine(v.options)} +
    + {v.seats.map((s) => { + const sus = v.suspects.includes(s.id); + const tag = statusTag(s); + return ( +
  1. + + + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {tag ? ` · ${tag.text}` : ''} + + {sus && ( + + ⚠ 의심 + + )} + {v.options.showRtt && s.rtt !== null && ( + + + {s.rtt} + + )} + + ✓{s.correct} ✗{s.wrong} + + + {mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`} + +
  2. + ); + })} +
+ {v.suspects.length > 0 && ⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.} +
+ {v.result && ( +
+ 최종 순위 + {v.result.ranking.map((r) => ( + + {r.rank}등 {nick(players, r.id)} + + {' '} + · {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0} + + + ))} + {v.result.summary} +
+ )} + {v.log.length > 0 && ( +
+ 기록 +
    + {[...v.log].reverse().map((e) => ( +
  1. + {logText(e, mode, players, me)} +
  2. + ))} +
+
+ )} + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: DobbleView, me: string | null): boolean { + return !!me && v.phase === 'playing' && v.pairs.length > 0; +} + +function statusText(v: DobbleView, me: string | null, players: Record): string { + if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요'; + if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요'; + if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요'; + const mine = v.seats.find((s) => s.id === me); + if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`; + if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요'; + if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요'; + if (isMyTurn(v, me)) { + return '지금 찾아요! 같은 그림을 눌러요'; + } + const leader = v.lastHit ? nick(players, v.lastHit.player) : null; + return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요'; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { + return ( +
+ {label} + {children} + {help && {help}} +
+ ); +} + +const onOff: [boolean, string][] = [ + [true, '켜기'], + [false, '끄기'], +]; + +const MINI_HELP: Record = { + tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!', + well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!', + hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!', + poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!', +}; + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ miniGame })} + /> + + {o.miniGame === 'hotPotato' && ( + + set({ hotPotatoRounds })} + /> + + )} + + set({ wrongLockoutMs })} + /> + + + set({ symbolStyle })} + /> + + + {more && ( + <> + + set({ deckSize })} + /> + + + set({ latencyComp })} + /> + + + set({ showRtt })} /> + + {o.miniGame === 'well' && ( + + set({ wellEndAtFirst })} /> + + )} + {o.miniGame === 'tower' && ( + + set({ wrongGivesCard })} /> + + )} + + )} +
+ ); +} + +function optionsSummary(o: DobbleOptions): string { + return rulesLine(o); +} + +// ---------------------------------------------------------------- rules + +/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */ +const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]]; +const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) }); +const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])]; + +function Rules({ options }: { options?: DobbleOptions }) { + const lock = options?.wrongLockoutMs ?? 1000; + return ( +
+ +

+ 동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 똑같은 그림이 딱 하나 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요. +

+
+ + + + + + + → 같은 그림은 고양이! +
+
    +
  • + 탑 쌓기: {MINI_HELP.tower} +
  • +
  • + 우물: {MINI_HELP.well} +
  • +
  • + 뜨거운 감자: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요. +
  • +
  • + 독이 든 선물: {MINI_HELP.poisonedGift} +
  • +
+

+ 틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요). +

+

PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.

+
+ ); +} + +const ui: GameUI = { + Board: DobbleBoard, + Side: DobbleSide, + 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 6103b01..e1197dd 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -25,6 +25,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'las-vegas': () => import('./las-vegas'), 'cockroach-poker': () => import('./cockroach-poker'), 'ninety-nine': () => import('./ninety-nine'), + dobble: () => import('./dobble'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 6190cf9..bff45f1 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -22,6 +22,7 @@ export const GAME_ICON: Record = { 'las-vegas': '⚅', 'cockroach-poker': '🐞', 'ninety-nine': '💯', + dobble: '👀', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 60630d7..5ef754d 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -641,6 +641,36 @@ async function ninetyNineStep(p: Page, stats: Record): Promise { + const arts = (side: string) => p.locator(`[data-card="${side}"] .db-sym`).evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-art')))); + const mine = p.locator('[data-card="mine"] .db-sym:not([disabled])'); + if ((await mine.count()) === 0) return null; + const [a, b] = await Promise.all([arts('center'), arts('mine')]); + const shared = b.filter((x) => a.includes(x)); + if (shared.length !== 1) return null; // the cards changed between the two reads + const art = wrong ? b.find((x) => x !== shared[0])! : shared[0]!; + const key = await p.locator('[data-card="center"]').getAttribute('data-key'); + const old = (await p.locator('.db-bubble').getAttribute('data-bubble', { timeout: 100 }).catch(() => null)) ?? ''; + const btn = p.locator(`[data-card="mine"] .db-sym[data-art="${art}"]`); + if (tag(p) === 'pc') { + await btn.focus(); + await p.keyboard.press('Enter'); + } else await btn.tap(); + // My own judgement is a new bubble (another one may still be showing). + const bubble = await p + .locator(`.db-bubble:not([data-bubble="${old}"])`) + .first() + .innerText({ timeout: 1500 }) + .then((t) => t.trim()) + .catch(() => ''); + if (!wrong) await p.waitForFunction((k) => document.querySelector('[data-card="center"]')?.getAttribute('data-key') !== k, key, { timeout: 1500 }).catch(() => {}); + return bubble; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -1606,6 +1636,67 @@ const PLANS: Record = { console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); }, }, + dobble: { + // PC host + phone guest, 탑 쌓기 with the 55-card deck and a 1-second lock. Both players find the shared picture of their own card and the centre + // card on their own screen (PC: keyboard focus + Enter, phone: tap), one deliberate wrong tap (shake + lock), until the draw pile runs out. + lobby: async (host) => { + await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); + await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + await host.locator('[data-card="mine"] .db-sym:not([disabled])').first().waitFor({ timeout: 10_000 }); + const st = { ok: 0, wrong: 0, late: 0, other: 0 }; + let n = 0; + let shots = 0; + const until = Date.now() + 180_000; + while (Date.now() < until && !(await over())) { + const p = pages[n++ % pages.length]!; + const wantWrong = st.wrong === 0 && st.ok >= 3 && p === guest; + const text = await dobbleTap(p, wantWrong); + if (text === null) { + await host.waitForTimeout(150); + continue; + } + if (text.startsWith('찾았어요')) st.ok++; + else if (text.includes('틀렸어요')) { + st.wrong++; + await guest.screenshot({ path: join(SHOTS, 'dobble-wrong-phone.png') }); + if (!(await guest.locator('.db-lock').count())) throw new Error('no lock after a wrong tap'); + } else if (text.includes('늦었어요')) st.late++; + else { + st.other++; + if (st.other <= 3) console.log(` dobble: other judgement ${JSON.stringify(text)}`); + } + // Human pace: a tap right after the cards changed counts as aimed at the old card (the screen drops it as late). + await host.waitForTimeout(300); + if (shots === 0 && st.ok >= 6) { + shots = 1; + await host.waitForTimeout(500); + await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + } + if (!(await over())) throw new Error(`game did not finish ${JSON.stringify(st)}`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); + await host.screenshot({ path: join(SHOTS, 'dobble-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'dobble-result-phone.png') }); + if (st.ok < 20 || st.wrong < 1) throw new Error(`too few real finds / wrong taps: ${JSON.stringify(st)}`); + console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/dobble/deck.ts b/packages/games/src/dobble/deck.ts new file mode 100644 index 0000000..5dc503b --- /dev/null +++ b/packages/games/src/dobble/deck.ts @@ -0,0 +1,63 @@ +/** Projective-plane deck and card layouts (docs/games/dobble.md §3). */ +import type { Rng } from '@bg/engine'; +import type { CardLayout } from './types'; + +export const ORDER = 7; +export const SYMBOLS = ORDER * ORDER + ORDER + 1; // 57 +export const FULL_DECK = SYMBOLS; // 57 cards +export const PER_CARD = ORDER + 1; // 8 + +/** §3.1: the order-n projective plane (n prime). Card i → its n+1 symbol numbers. */ +export function buildDeck(n = ORDER): number[][] { + const cards: number[][] = []; + cards.push(Array.from({ length: n + 1 }, (_, i) => i)); + for (let j = 0; j < n; j++) { + const c = [0]; + for (let k = 0; k < n; k++) c.push(n + 1 + n * j + k); + cards.push(c); + } + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + const c = [i + 1]; + for (let k = 0; k < n; k++) c.push(n + 1 + n * k + ((i * k + j) % n)); + cards.push(c); + } + } + return cards; +} + +/** The single symbol two different cards share (projective plane: exactly one). */ +export function commonSymbol(a: readonly number[], b: readonly number[]): number { + for (const x of a) if (b.includes(x)) return x; + throw new Error('dobble: cards share no symbol'); +} + +/** + * §3.3: six circle-packing templates, slot = [x, y, r] in the unit circle. Every slot is at least 0.48 of + * the radius wide (≥ 72px on a 300px card, well over the 48px touch target) and no two slots overlap. + */ +export const TEMPLATES: readonly (readonly [number, number, number])[][] = [ + [[-0.448, 0.516, 0.287], [-0.683, -0.028, 0.287], [0.011, -0.014, 0.287], [0.18, -0.659, 0.287], [0.628, -0.271, 0.287], [0.603, 0.322, 0.287], [-0.404, -0.552, 0.287], [0.124, 0.672, 0.287]], + [[0, 0.002, 0.399], [-0.695, 0.005, 0.275], [0.568, -0.4, 0.275], [-0.465, -0.516, 0.275], [0.078, -0.691, 0.275], [0.677, 0.159, 0.275], [-0.457, 0.523, 0.275], [0.33, 0.612, 0.275]], + [[-0.134, 0.603, 0.353], [0.365, -0.498, 0.353], [-0.654, -0.246, 0.271], [-0.261, -0.648, 0.271], [-0.129, -0.044, 0.271], [0.5, 0.488, 0.271], [0.726, 0.003, 0.244], [-0.666, 0.289, 0.244]], + [[-0.033, 0.019, 0.343], [0.051, -0.653, 0.315], [0.595, 0.274, 0.315], [0.59, -0.346, 0.286], [-0.414, 0.545, 0.286], [0.147, 0.683, 0.272], [-0.526, -0.48, 0.257], [-0.662, 0.037, 0.257]], + [[0.008, -0.646, 0.324], [0.059, 0.021, 0.324], [-0.568, -0.307, 0.324], [0.679, 0.108, 0.282], [-0.61, 0.318, 0.282], [-0.146, 0.672, 0.282], [0.568, -0.436, 0.254], [0.404, 0.591, 0.254]], + [[0.363, 0.461, 0.383], [-0.043, -0.128, 0.312], [0.393, -0.562, 0.284], [-0.307, 0.614, 0.284], [-0.591, -0.376, 0.27], [-0.678, 0.177, 0.27], [0.708, -0.1, 0.255], [-0.158, -0.695, 0.241]], +]; + +/** Fixes one card's look: template, which symbol sits in which slot, and (classic) size and rotation. */ +export function makeLayout(symbols: readonly number[], style: 'easy' | 'classic', rng: Rng): CardLayout { + const template = rng.int(TEMPLATES.length); + const slots = TEMPLATES[template]!; + const order = rng.shuffle(symbols); + return { + template, + items: order.map((symbol, i) => { + const [x, y, r] = slots[i]!; + if (style === 'easy') return { symbol, x, y, r, scale: 1, rot: 0 }; + const scale = Math.round((0.6 + rng.next() * 0.6) * 100) / 100; + const rot = rng.int(360); + return { symbol, x, y, r, scale, rot }; + }), + }; +} diff --git a/packages/games/src/dobble/dobble.test.ts b/packages/games/src/dobble/dobble.test.ts new file mode 100644 index 0000000..31ece15 --- /dev/null +++ b/packages/games/src/dobble/dobble.test.ts @@ -0,0 +1,849 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; +import { + AWAY_STEP_MS, + COUNTDOWN_MS, + FAST_MS, + HINT_MS, + LATE_TAKEN, + ROUND_BREAK_MS, + STALL_MS, + SYMBOLS, + SYMBOL_NAMES, + TEMPLATES, + WINDOW_MS, + allCardIds, + answerArt, + buildDeck, + cardKey, + commonSymbol, + dobble, + dueAt, + pairsFor, + type ClaimAction, + type DobbleAction, + type DobbleOptions, + type DobbleState, +} from './index'; + +// ---------------------------------------------------------------- helpers + +const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); +const T0 = 1_000_000; +const START = T0 + COUNTDOWN_MS; + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], seed = 1): DobbleState { + const options = dobble.optionsSchema.parse(opts); + return dobble.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 }); +} + +function applyE(s: DobbleState, p: string, a: DobbleAction, now: number): { state: DobbleState; events: GameEvent[] } { + const v = dobble.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return dobble.apply(s, p, a, { rng: rng(), now }); +} +const act = (s: DobbleState, p: string, a: DobbleAction, now: number) => applyE(s, p, a, now).state; +/** Countdown over → playing. */ +const started = (s: DobbleState, now = START) => act(s, s.order[0]!, { type: 'tick' }, now); + +/** The correct claim of `p` (on `target` for gift / hot potato). */ +function right(s: DobbleState, p: string, target?: string, extra: Partial = {}): ClaimAction { + const pair = pairsFor(s, p).find((x) => (x.target ?? undefined) === target); + if (!pair) throw new Error(`${p} has no pair on ${target}`); + return { + type: 'claim', + base: pair.base, + symbol: answerArt(s, p, pair)!, + ...(pair.target ? { target: pair.target } : {}), + ...(pair.mine !== null ? { mine: pair.mine } : {}), + ...extra, + }; +} + +/** A symbol that is on one compared card but is not the shared one. */ +function wrong(s: DobbleState, p: string, target?: string): ClaimAction { + const r = right(s, p, target); + const card = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[p]!.at(-1)!; + const sym = s.cards[card]!.find((x) => s.symbolArt[x] !== r.symbol)!; + return { ...r, symbol: s.symbolArt[sym]! }; +} + +const top = (s: DobbleState, p: string) => s.piles[p]!.at(-1); +/** Ranking groups with ids sorted inside each group (seats are shuffled, so tie order follows the seat order). */ +const groups = (r: { ranking: PlayerId[][] }) => r.ranking.map((g) => [...g].sort()); + +function conserved(s: DobbleState): void { + const all = allCardIds(s); + if (all.length !== 57) throw new Error(`card count ${all.length}`); + if (new Set(all).size !== 57) throw new Error('duplicate card'); +} + +/** Puts the given cards (bottom → top) on the given piles, everything else into the draw pile (tests only). */ +function rig(s: DobbleState, r: { piles?: Record; center?: number[]; draw?: number[] }): DobbleState { + const used = new Set([...Object.values(r.piles ?? {}).flat(), ...(r.center ?? []), ...(r.draw ?? [])]); + for (const p of s.order) { + s.piles[p] = r.piles?.[p] ?? []; + s.taken[p] = []; + } + s.center = r.center ?? []; + s.drawPile = r.draw ?? []; + s.unused = Array.from({ length: 57 }, (_, i) => i).filter((i) => !used.has(i)); + return s; +} + +// ---------------------------------------------------------------- §3 + +describe('덱과 카드 모양 (§3)', () => { + test('57장·57종, 카드당 서로 다른 8개, 모든 쌍(1596쌍) 공유 1개, 그림마다 8번', () => { + const deck = buildDeck(); + expect(deck).toHaveLength(57); + expect(SYMBOLS).toBe(57); + expect(SYMBOL_NAMES).toHaveLength(57); + expect(new Set(SYMBOL_NAMES).size).toBe(57); + const seen = new Map(); + for (const c of deck) { + expect(new Set(c).size).toBe(8); + for (const x of c) seen.set(x, (seen.get(x) ?? 0) + 1); + } + expect(seen.size).toBe(57); + expect([...seen.keys()].every((x) => x >= 0 && x < 57)).toBe(true); + expect([...seen.values()].every((n) => n === 8)).toBe(true); + let pairs = 0; + for (let i = 0; i < 57; i++) { + for (let j = i + 1; j < 57; j++) { + expect(deck[i]!.filter((x) => deck[j]!.includes(x))).toHaveLength(1); + pairs++; + } + } + expect(pairs).toBe(1596); + }); + + test('55장 모드: RNG로 2장을 빼고 남은 55장도 쌍마다 공유 1개', () => { + for (const seed of [1, 2, 3]) { + const s = make({ deckSize: 55, miniGame: 'tower' }, ['A', 'B'], seed); + conserved(s); + expect(s.unused).toHaveLength(2); + const inPlay = allCardIds(s).filter((id) => !s.unused.includes(id)); + expect(inPlay).toHaveLength(55); + for (const a of inPlay) for (const b of inPlay) if (a < b) expect(s.cards[a]!.filter((x) => s.cards[b]!.includes(x))).toHaveLength(1); + } + // Different seeds remove different cards. + expect(make({ deckSize: 55 }, ['A', 'B'], 1).unused).not.toEqual(make({ deckSize: 55 }, ['A', 'B'], 2).unused); + }); + + test('템플릿 6종: 원 안, 겹침 없음, 지름 300px 카드에서 칸이 48px 이상', () => { + expect(TEMPLATES).toHaveLength(6); + for (const t of TEMPLATES) { + expect(t).toHaveLength(8); + for (const [x, y, r] of t) { + expect(Math.hypot(x, y) + r).toBeLessThanOrEqual(1); + expect(2 * r * 150).toBeGreaterThanOrEqual(48); + } + for (let i = 0; i < 8; i++) for (let j = i + 1; j < 8; j++) expect(Math.hypot(t[i]![0] - t[j]![0], t[i]![1] - t[j]![1])).toBeGreaterThanOrEqual(t[i]![2] + t[j]![2]); + } + }); + + test('레이아웃은 셋업 때 카드마다 확정: classic은 크기 0.6~1.2·회전 0~359, easy는 1·0', () => { + const c = make({ symbolStyle: 'classic' }); + const e = make({ symbolStyle: 'easy' }); + for (const [s, easy] of [ + [c, false], + [e, true], + ] as const) { + expect(s.layouts).toHaveLength(57); + s.layouts.forEach((l, id) => { + expect(l.items.map((i) => i.symbol).sort((a, b) => a - b)).toEqual([...s.cards[id]!].sort((a, b) => a - b)); + for (const i of l.items) { + if (easy) expect([i.scale, i.rot]).toEqual([1, 0]); + else { + expect(i.scale).toBeGreaterThanOrEqual(0.6); + expect(i.scale).toBeLessThanOrEqual(1.2); + expect(i.rot).toBeGreaterThanOrEqual(0); + expect(i.rot).toBeLessThan(360); + } + } + }); + } + expect(new Set(c.layouts.map((l) => l.template)).size).toBeGreaterThan(3); + expect(new Set(c.layouts.flatMap((l) => l.items.map((i) => i.scale))).size).toBeGreaterThan(20); + }); + + test('그림 번호 → 아이콘 배정·좌석·덱 순서는 시드로 정해지고 판마다 달라요', () => { + const a = make({}, ['A', 'B', 'C'], 1); + const b = make({}, ['A', 'B', 'C'], 1); + const c = make({}, ['A', 'B', 'C'], 2); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + expect(a.symbolArt).not.toEqual(c.symbolArt); + expect([...a.symbolArt].sort((x, y) => x - y)).toEqual(Array.from({ length: 57 }, (_, i) => i)); + expect(a.host).toBe('A'); + }); +}); + +// ---------------------------------------------------------------- §4 + +describe('준비 (§4)', () => { + test('탑 쌓기·독이 든 선물: 각자 1장 공개, 가운데 1장 공개, 나머지는 뽑기 더미. 3초 카운트다운 동안 그림은 안 보여요', () => { + for (const miniGame of ['tower', 'poisonedGift'] as const) { + const s = make({ miniGame }, ['A', 'B', 'C', 'D']); + conserved(s); + for (const p of s.order) expect(s.piles[p]).toHaveLength(1); + expect(s.center).toHaveLength(1); + expect(s.drawPile).toHaveLength(57 - 5); + expect(s.phase).toBe('countdown'); + const v = dobble.view(s, 'A'); + expect(v.center).toBeNull(); + expect(v.seats.every((x) => x.top === null)).toBe(true); + expect(v.pairs).toEqual([]); + expect(dobble.validate(s, 'A', { type: 'claim', base: s.center[0]!, symbol: 0 })).toEqual({ ok: false, reason: '곧 시작해요. 잠시만 기다려 주세요.' }); + expect(dueAt(s)).toBe(START); + const p = started(s); + expect(p.phase).toBe('playing'); + const v2 = dobble.view(p, 'A'); + expect(v2.center?.key).toBe(cardKey(p.center[0]!)); + expect(v2.seats.every((x) => x.top !== null)).toBe(true); + } + }); + + test.each([2, 3, 4, 5, 6, 7, 8])('우물 %i명: 가운데 1장, 1인당 floor((덱-1)/N)장, 나머지는 사용 안 함', (n) => { + const players = Array.from({ length: n }, (_, i) => `P${i}`); + for (const deckSize of [57, 55] as const) { + const s = make({ miniGame: 'well', deckSize }, players); + conserved(s); + const per = Math.floor((deckSize - 1) / n); + for (const p of players) expect(s.piles[p]).toHaveLength(per); + expect(s.center).toHaveLength(1); + expect(s.unused).toHaveLength(57 - 1 - per * n); + // Only the top of each pile is face up (after the countdown). + const v = dobble.view(started(s), players[0]!); + for (const seat of v.seats) expect(seat.top?.key).toBe(cardKey(s.piles[seat.id]!.at(-1)!)); + } + }); + + test('뜨거운 감자: 라운드마다 각자 1장, 8명 × 7라운드도 57장이면 충분, 55장이면 시작 전에 막아요', () => { + const s = make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']); + for (const p of s.order) expect(s.piles[p]).toHaveLength(1); + expect(s.drawPile).toHaveLength(54); + const eight = Array.from({ length: 8 }, (_, i) => `P${i}`); + expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7 }), 8)).toBeNull(); + expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 8)).toContain('카드가 모자라요'); + expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 7)).toBeNull(); + expect(make({ miniGame: 'hotPotato', hotPotatoRounds: 7 }, eight).drawPile).toHaveLength(57 - 8); + }); +}); + +// ---------------------------------------------------------------- §5 + +describe('찾기 판정 (§5.1, §5.3)', () => { + test('탑 쌓기: 같은 가운데 카드에 두 사람이 정답 → 먼저 도착한 사람만 가져가고, 나중 사람은 "늦었어요"(잠금 없음)', () => { + let s = started(make({}, ['A', 'B', 'C'])); + const center = s.center[0]!; + const a = right(s, 'A'); + const b = right(s, 'B'); + expect(a.base).toBe(center); + expect(b.base).toBe(center); + const { state, events } = applyE(s, 'A', a, START + 500); + s = state; + expect(s.piles.A).toEqual([expect.any(Number), center]); + expect(top(s, 'A')).toBe(center); + expect(s.center).toHaveLength(1); + expect(s.center[0]).not.toBe(center); + expect(events.map((e) => e.type)).toEqual(['hit', 'reveal']); + expect(s.feedback.A).toMatchObject({ kind: 'ok', art: a.symbol }); + // B's claim for the same centre card arrives 50ms later. + expect(dobble.validate(s, 'B', b)).toEqual({ ok: false, reason: LATE_TAKEN }); + expect(s.lockedUntil.B).toBe(0); + expect(s.stats.B).toMatchObject({ correct: 0, wrong: 0 }); + }); + + test('틀린 그림 → wrongLockoutMs 동안 정답도 무시, 잠금이 풀리면 허용 (serverTs 기준)', () => { + let s = started(make({ wrongLockoutMs: 2000 }, ['A', 'B'])); + s = act(s, 'A', wrong(s, 'A'), START + 100); + expect(s.lockedUntil.A).toBe(START + 2100); + expect(s.stats.A!.wrong).toBe(1); + expect(s.feedback.A?.kind).toBe('wrong'); + const before = s.piles.A!.length; + s = act(s, 'A', right(s, 'A'), START + 2099); + expect(s.feedback.A?.kind).toBe('locked'); + expect(s.piles.A).toHaveLength(before); + s = act(s, 'A', right(s, 'A'), START + 2100); + expect(s.feedback.A?.kind).toBe('ok'); + expect(s.piles.A).toHaveLength(before + 1); + }); + + test('wrongLockoutMs 0: 틀려도 바로 다시 누를 수 있어요', () => { + let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B'])); + s = act(s, 'A', wrong(s, 'A'), START + 100); + s = act(s, 'A', right(s, 'A'), START + 100); + expect(s.feedback.A?.kind).toBe('ok'); + }); + + test('house rule wrongGivesCard: 탑 쌓기에서 틀리면 내 탑 맨 위가 뽑기 더미 맨 아래로(마지막 1장은 지켜요)', () => { + let s = started(make({ wrongGivesCard: true, wrongLockoutMs: 0 }, ['A', 'B'])); + s = act(s, 'A', wrong(s, 'A'), START + 10); + expect(s.piles.A).toHaveLength(1); + s = act(s, 'A', right(s, 'A'), START + 20); + const myTop = top(s, 'A')!; + const draw = s.drawPile.length; + s = act(s, 'A', wrong(s, 'A'), START + 30); + expect(s.piles.A).toHaveLength(1); + expect(s.drawPile).toHaveLength(draw + 1); + expect(s.drawPile[0]).toBe(myTop); + conserved(s); + }); + + test('잘못된 요청: 탑·우물은 대상 없음, 선물·감자는 대상 필수, 이미 바뀐 내 카드는 늦음', () => { + const s = started(make({}, ['A', 'B'])); + expect(dobble.validate(s, 'A', { ...right(s, 'A'), target: 'B' }).ok).toBe(false); + const g = started(make({ miniGame: 'poisonedGift' }, ['A', 'B'])); + expect(dobble.validate(g, 'A', { type: 'claim', base: g.center[0]!, symbol: 1 })).toEqual({ ok: false, reason: '누구 카드에서 찾을지 먼저 골라 주세요.' }); + expect(dobble.validate(s, 'Z', right(s, 'A')).ok).toBe(false); + const h = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C'])); + const c = right(h, 'A', 'B'); + expect(dobble.validate(h, 'A', { ...c, mine: (c.mine! + 1) % 57 }).ok).toBe(false); + }); +}); + +describe('미니게임 (§5.2, §6)', () => { + test('탑 쌓기: 뽑기 더미가 끝나면 카드가 가장 많은 사람이 1등, 동점은 공동 순위, 점수 3·2·1', () => { + let s = started(make({}, ['A', 'B', 'C'])); + rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5, 6] }); + let now = START; + for (const p of ['A', 'A', 'B', 'C']) s = act(s, p, right(s, p), (now += 300)); + expect(s.phase).toBe('ended'); + expect(dobble.view(s, 'A').center).toBeNull(); + const r = dobble.result(s)!; + expect(groups(r)).toEqual([['A'], ['B', 'C']]); + expect(r.scores).toEqual({ A: 3, B: 2, C: 2 }); + expect(r.summary).toBe('탑 쌓기 끝! 카드 3장을 모은 사람이 1등이에요.'); + expect(s.final!.ranking[0]).toMatchObject({ id: 'A', rank: 1, cards: 3, points: 3 }); + }); + + test('우물: 내 카드를 가운데로, 다 비운 순서가 순위, 마지막 1명은 꼴찌로 바로 종료', () => { + let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C'])); + rig(s, { piles: { A: [0], B: [1, 2], C: [3, 4] }, center: [5] }); + let now = START; + const aTop = top(s, 'A')!; + s = act(s, 'A', right(s, 'A'), (now += 200)); + expect(s.center.at(-1)).toBe(aTop); + expect(s.status.A).toBe('finished'); + expect(s.finishedOrder).toEqual(['A']); + expect(dobble.validate(s, 'A', { type: 'claim', base: s.center.at(-1)!, symbol: 0 })).toEqual({ ok: false, reason: '이미 카드를 다 비웠어요.' }); + s = act(s, 'C', right(s, 'C'), (now += 200)); + s = act(s, 'C', right(s, 'C'), (now += 200)); + expect(s.phase).toBe('ended'); + const r = dobble.result(s)!; + expect(groups(r)).toEqual([['A'], ['C'], ['B']]); + expect(r.scores).toEqual({ A: 3, C: 2, B: 1 }); + expect(dobble.view(s, null).seats.find((x) => x.id === 'C')!.place).toBe(2); + }); + + test('우물 wellEndAtFirst: 처음 다 비운 사람이 나오면 바로 끝, 나머지는 남은 장수 순', () => { + let s = started(make({ miniGame: 'well', wellEndAtFirst: true }, ['A', 'B', 'C'])); + rig(s, { piles: { A: [0, 1], B: [2, 3, 4], C: [5, 6] }, center: [7] }); + let now = START; + s = act(s, 'A', right(s, 'A'), (now += 200)); + expect(s.phase).toBe('playing'); + s = act(s, 'A', right(s, 'A'), (now += 200)); + expect(s.phase).toBe('ended'); + expect(groups(dobble.result(s)!)).toEqual([['A'], ['C'], ['B']]); + }); + + test('뜨거운 감자: 묶음 전체를 상대 위로, 탈출자는 대상 불가, 한 명이 다 가지면 라운드 끝·벌점 누적', () => { + let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B', 'C'])); + rig(s, { piles: { A: [0], B: [1], C: [2] }, draw: Array.from({ length: 10 }, (_, i) => 10 + i) }); + let now = START; + s = act(s, 'A', right(s, 'A', 'B'), (now += 300)); + expect(s.piles.A).toEqual([]); + expect(s.piles.B).toEqual([1, 0]); + expect(s.status.A).toBe('escaped'); + expect(s.outThisRound).toEqual(['A']); + // Nobody may target the escaped A, and A cannot claim this round. + expect(dobble.validate(s, 'C', { type: 'claim', base: 0, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '이미 이번 라운드를 탈출한 사람이에요.' }); + expect(dobble.validate(s, 'A', { type: 'claim', base: 0, target: 'C', symbol: 1 }).ok).toBe(false); + expect(pairsFor(s, 'C').map((x) => x.target).sort()).toEqual(['B']); + // C's claim was made against B's old top → late. + expect(dobble.validate(s, 'C', { type: 'claim', base: 1, target: 'B', symbol: 1 }).ok).toBe(false); + s = act(s, 'C', right(s, 'C', 'B'), (now += 300)); + expect(s.phase).toBe('roundEnd'); + expect(s.penalty).toEqual({ A: 0, B: 3, C: 0 }); + expect(s.taken.B).toEqual([1, 0, 2]); + expect(s.lastRound).toEqual({ round: 1, loser: 'B', cards: 3 }); + conserved(s); + // Round 2 after the break + countdown. + s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS)); + expect(s.phase).toBe('countdown'); + expect(s.round).toBe(2); + expect(s.order.every((p) => s.piles[p]!.length === 1 && s.status[p] === 'playing')).toBe(true); + s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS)); + expect(s.phase).toBe('playing'); + s = act(s, 'B', right(s, 'B', 'A'), (now += 300)); + s = act(s, 'C', right(s, 'C', 'A'), (now += 300)); + expect(s.penalty).toEqual({ A: 3, B: 3, C: 0 }); + s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS)); + s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS)); + s = act(s, 'B', right(s, 'B', 'C'), (now += 300)); + s = act(s, 'A', right(s, 'A', 'C'), (now += 300)); + expect(s.phase).toBe('ended'); + expect(s.penalty).toEqual({ A: 3, B: 3, C: 3 }); + expect(groups(dobble.result(s)!)).toEqual([['A', 'B', 'C']]); + expect(dobble.result(s)!.summary).toContain('공동 1등 3명'); + conserved(s); + }); + + test('뜨거운 감자: 설정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등', () => { + let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B'])); + let now = START; + for (const loser of ['A', 'A', 'B']) { + const winner = loser === 'A' ? 'B' : 'A'; + s = act(s, winner, right(s, winner, loser), (now += 400)); + if (s.phase === 'ended') break; + s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS)); + s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS)); + } + expect(s.phase).toBe('ended'); + expect(s.penalty).toEqual({ A: 4, B: 2 }); + const r = dobble.result(s)!; + expect(groups(r)).toEqual([['B'], ['A']]); + expect(s.final!.ranking.find((x) => x.id === 'B')).toMatchObject({ penalty: 2, points: 3 }); + }); + + test('독이 든 선물: 내 카드에는 못 올리고, 더미가 끝나면 카드가 가장 적은 사람이 1등(동점 공동)', () => { + let s = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C'])); + rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5] }); + expect(dobble.validate(s, 'A', { type: 'claim', base: 3, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '내 카드에는 선물을 줄 수 없어요.' }); + expect(pairsFor(s, 'A').map((x) => x.target).sort()).toEqual(['B', 'C']); + let now = START; + s = act(s, 'A', right(s, 'A', 'B'), (now += 300)); + expect(s.piles.B).toEqual([1, 3]); + s = act(s, 'B', right(s, 'B', 'C'), (now += 300)); + s = act(s, 'C', right(s, 'C', 'B'), (now += 300)); + expect(s.phase).toBe('ended'); + const r = dobble.result(s)!; + expect(groups(r)).toEqual([['A'], ['C'], ['B']]); + expect(r.summary).toBe('독이 든 선물 끝! 카드를 1장만 받은 사람이 1등이에요.'); + // Tie: two players with the fewest cards share 1st. + let t = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C'])); + rig(t, { piles: { A: [0], B: [1], C: [2] }, center: [3] }); + t = act(t, 'A', right(t, 'A', 'C'), START + 100); + expect(groups(dobble.result(t)!)).toEqual([['A', 'B'], ['C']]); + expect(dobble.result(t)!.scores).toEqual({ A: 3, B: 3, C: 1 }); + }); +}); + +describe('지연 보정 light (§5.3)', () => { + function duel(delayMs: number) { + let s = started(make({ latencyComp: 'light' }, ['A', 'B'])); + s.rtt = { A: 200, B: 20 }; + const t = START + 1000; + const b = right(s, 'B', undefined, { rtt: 20 }); + const a = right(s, 'A', undefined, { rtt: 200 }); + s = act(s, 'B', b, t); + expect(s.pendingWindow?.claims).toHaveLength(1); + expect(s.center).toHaveLength(1); + const v = dobble.validate(s, 'A', a); + if (v.ok) s = act(s, 'A', a, t + delayMs); + // The server closes the window when its deadline comes. + if (s.pendingWindow) { + expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'resolve' }); + expect(dueAt(s)).toBe(t + WINDOW_MS); + s = act(s, 'A', { type: 'resolve' }, t + WINDOW_MS); + } + return s; + } + + test('상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms): 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리', () => { + const s = duel(40); + expect(s.stats.A!.correct).toBe(1); + expect(s.stats.B!.correct).toBe(0); + expect(s.feedback.B?.kind).toBe('late'); + expect(s.lastHit?.player).toBe('A'); + }); + + test('100ms 늦게 도착하면 60ms 창 밖이라 패배("늦었어요")', () => { + const s = duel(100); + expect(s.stats.B!.correct).toBe(1); + expect(s.stats.A!.correct).toBe(0); + expect(s.feedback.A?.kind).toBe('late'); + }); + + test('RTT는 찾기에 실린 값의 지수이동평균(α=0.2), 보정 상한 80ms', () => { + let s = started(make({ latencyComp: 'light' }, ['A', 'B'])); + s = act(s, 'A', wrong(s, 'A'), START + 10); + s = act(s, 'B', { ...wrong(s, 'B'), rtt: 100 }, START + 20); + s = act(s, 'B', { ...wrong(s, 'B'), rtt: 200 }, START + 3000); + expect(s.rtt.B).toBe(120); + expect(s.rtt.A).toBeNull(); + s = act(s, 'B', { ...right(s, 'B'), rtt: 5000 }, START + 6000); + expect(s.rtt.B).toBe(496); + expect(s.pendingWindow!.claims[0]!.adjTs).toBe(START + 6000 - 80); + }); +}); + +describe('시간 초과·자리 비움·나가기 (§8.5)', () => { + test('onTimeout은 tick(창이 열려 있으면 resolve), 45초 동안 아무도 못 맞히면 공통 그림을 2초 보여 주고 계속', () => { + let s = started(make({}, ['A', 'B'])); + expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'tick' }); + expect(dueAt(s)).toBe(START + STALL_MS); + s = act(s, 'A', { type: 'tick' }, START + STALL_MS); + expect(s.hintUntil).toBe(START + STALL_MS + HINT_MS); + const v = dobble.view(s, 'A'); + expect(v.hint).toEqual([right(s, 'A').symbol]); + expect(dobble.view(s, 'B').hint).toEqual([right(s, 'B').symbol]); + expect(dobble.view(s, null).hint).toEqual([]); + s = act(s, 'B', { type: 'tick' }, START + STALL_MS + HINT_MS); + expect(s.hintUntil).toBe(0); + expect(dobble.view(s, 'A').hint).toEqual([]); + expect(s.stats.A!.correct + s.stats.B!.correct).toBe(0); + expect(dueAt(s)).toBe(START + STALL_MS + HINT_MS + STALL_MS); + }); + + test('우물: 30초 넘게 끊긴 사람은 기권(카드는 상자로) → 꼴찌 그룹, 남은 사람끼리 계속', () => { + let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C'])); + // The room times out a disconnected player before the game deadline only after its grace period: + // such early ticks come at most every AWAY_STEP_MS (timeoutNotBefore) and are strikes. + let now = START + 30_000; + expect(dobble.timeoutNotBefore!(s)).toBeLessThanOrEqual(now); + for (let i = 0; i < 3; i++) { + expect(s.status.C).toBe('playing'); + s = act(s, 'C', { type: 'tick' }, now); + expect(dobble.timeoutNotBefore!(s)).toBe(now + AWAY_STEP_MS); + // Someone keeps playing meanwhile (the stall timer must not swallow the strikes). + s = act(s, 'A', right(s, 'A'), now + 5000); + now += AWAY_STEP_MS; + } + expect(s.status.C).toBe('forfeit'); + expect(s.piles.C).toEqual([]); + expect(s.phase).toBe('playing'); + conserved(s); + expect(dobble.activePlayers(s)).toEqual(s.order.filter((p) => p !== 'C')); + expect(dobble.validate(s, 'C', { type: 'claim', base: s.center.at(-1)!, symbol: 0 }).ok).toBe(false); + // A and B play on; the forfeited C ends in the bottom group. + while (s.phase === 'playing') s = act(s, 'B', right(s, 'B'), (now += 300)); + const r = dobble.result(s)!; + expect(r.ranking.at(-1)).toEqual(['C']); + expect(s.final!.ranking.find((x) => x.id === 'C')).toMatchObject({ status: 'forfeit', points: 0 }); + }); + + test('짧게 끊겼다 돌아오면(찾기를 하면) 기권 카운트가 초기화돼요', () => { + let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C'])); + let now = START + 60_000; + s = act(s, 'C', { type: 'tick' }, now); + s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS)); + s = act(s, 'C', wrong(s, 'C'), (now += 1000)); + s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS)); + expect(s.status.C).toBe('playing'); + expect(s.away.C!.strikes).toBe(1); + }); + + test('탑 쌓기: 끊긴 사람이 있어도 그대로 진행, 모두 자리를 비우면 끝내요', () => { + let s = started(make({}, ['A', 'B'])); + let now = START + 10_000; + for (let i = 0; i < 3; i++) { + s = act(s, 'B', { type: 'tick' }, now); + s = act(s, 'A', right(s, 'A'), now + 1000); + now += AWAY_STEP_MS; + } + expect(s.away.B!.strikes).toBe(3); + expect(s.status.B).toBe('playing'); + expect(s.phase).toBe('playing'); + // Now both are away: the room ticks whoever is first in timeoutPlayers once timeoutNotBefore passes. + for (let i = 0; i < 20 && s.phase === 'playing'; i++) { + now = Math.max(now, dobble.timeoutNotBefore!(s)!); + const who = dobble.timeoutPlayers!(s)[0]!; + s = act(s, who, { type: 'tick' }, now); + } + expect(s.phase).toBe('ended'); + expect(dobble.result(s)!.summary).toBe('모두 자리를 비워서 게임을 끝냈어요.'); + }); + + test('나가기: 탑 쌓기는 탑이 남고 꼴찌, 1명만 남으면 끝', () => { + let s = started(make({}, ['A', 'B', 'C'])); + expect(dobble.onLeave!(s, 'C')).toEqual({ type: 'leave' }); + s = act(s, 'C', { type: 'leave' }, START + 100); + expect(s.status.C).toBe('left'); + expect(s.piles.C).toHaveLength(1); + expect(dobble.onLeave!(s, 'C')).toBeNull(); + s = act(s, 'B', { type: 'leave' }, START + 200); + expect(s.phase).toBe('ended'); + const r = dobble.result(s)!; + expect(groups(r)).toEqual([['A'], ['B', 'C']]); + expect(r.summary).toContain('다른 사람이 모두 나가서'); + }); + + test('나가기: 뜨거운 감자는 대상에서 빠지고 카드는 상자로, 겨룰 사람이 없어지면 그 라운드는 무효', () => { + let s = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C'])); + s = act(s, 'A', right(s, 'A', 'B'), START + 300); + s = act(s, 'C', { type: 'leave' }, START + 400); + expect(s.phase).toBe('roundEnd'); + expect(s.lastRound).toEqual({ round: 1, loser: null, cards: 0 }); + expect(s.penalty).toEqual({ A: 0, B: 0, C: 0 }); + conserved(s); + s = act(s, 'A', { type: 'tick' }, START + 400 + ROUND_BREAK_MS); + expect(s.piles.C).toEqual([]); + expect(s.piles.A).toHaveLength(1); + }); + + test('onTimeout은 모든 상태에서 합법', () => { + let s = make({ miniGame: 'hotPotato', latencyComp: 'light' }, ['A', 'B', 'C']); + for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true); + s = started(s); + s = act(s, 'A', right(s, 'A', 'B'), START + 10); + for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true); + }); +}); + +describe('비공개 정보·봇 의심·결정성 (§8.3, §10)', () => { + test('view에는 뽑기 더미·우물 뒷면·미사용 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => { + for (const miniGame of ['tower', 'well', 'hotPotato', 'poisonedGift'] as const) { + const s = started(make({ miniGame, deckSize: 55 }, ['A', 'B', 'C'])); + const t = structuredClone(s); + // Swap hidden cards around (draw pile ↔ box ↔ well face-down cards). + const hidden = [...t.drawPile, ...t.unused, ...(miniGame === 'well' ? t.order.flatMap((p) => t.piles[p]!.slice(0, -1)) : [])]; + const shuffled = SeededRng.fromSeed([1, 2, 3, 4]).shuffle(hidden); + let k = 0; + t.drawPile = t.drawPile.map(() => shuffled[k++]!); + t.unused = t.unused.map(() => shuffled[k++]!); + if (miniGame === 'well') for (const p of t.order) t.piles[p] = [...t.piles[p]!.slice(0, -1).map(() => shuffled[k++]!), t.piles[p]!.at(-1)!]; + for (const viewer of ['A', 'B', null]) expect(JSON.stringify(dobble.view(t, viewer))).toBe(JSON.stringify(dobble.view(s, viewer))); + for (const p of s.order) checkNoLeak(dobble, s, p, s.order); + // Layouts of hidden cards are never sent. + const v = JSON.stringify(dobble.view(s, 'A')); + for (const id of s.drawPile) expect(v).not.toContain(`"key":"${cardKey(id)}"`); + expect(v).not.toContain('"symbol"'); + expect(v).not.toContain('rng'); + } + }); + + test('카운트다운 중에는 모든 카드가 비밀(덱이 미리 보이지 않음)', () => { + const s = make({ miniGame: 'hotPotato' }, ['A', 'B']); + expect(dobble.secretsOf!(s, 'A')).toContain(cardKey(s.piles.A![0]!)); + for (const p of s.order) checkNoLeak(dobble, s, p, s.order); + }); + + test('봇 의심: 카드가 바뀐 뒤 200ms 안에 맞히기를 5번 연속 → 방장 view에만 표시', () => { + let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B', 'C'])); + let now = START; + for (let i = 0; i < 5; i++) { + expect(s.suspect).toEqual([]); + s = act(s, 'B', right(s, 'B'), (now += FAST_MS - 50)); + } + expect(s.suspect).toEqual(['B']); + expect(dobble.view(s, 'A').suspects).toEqual(['B']); + expect(dobble.view(s, 'A').isHost).toBe(true); + expect(dobble.view(s, 'B').suspects).toEqual([]); + expect(dobble.view(s, 'C').suspects).toEqual([]); + expect(dobble.view(s, null).suspects).toEqual([]); + // A slow answer resets the streak. + let t = started(make({}, ['A', 'B'])); + let n = START; + for (let i = 0; i < 4; i++) t = act(t, 'B', right(t, 'B'), (n += 100)); + t = act(t, 'B', right(t, 'B'), (n += 900)); + expect(t.stats.B!.fastHits).toBe(0); + }); + + test('이벤트에는 공개된 카드만 담긴다', () => { + let s = started(make({}, ['A', 'B'])); + const { state, events } = applyE(s, 'A', right(s, 'A'), START + 300); + s = state; + const secrets = dobble.secretsOf!(s, 'A'); + for (const e of events) for (const k of secrets) expect(JSON.stringify(e)).not.toContain(`"${k}"`); + expect(events.find((e) => e.type === 'reveal')).toEqual({ type: 'reveal', key: cardKey(s.center.at(-1)!) }); + }); + + test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => { + for (const opts of [{}, { miniGame: 'hotPotato', latencyComp: 'light' }, { miniGame: 'well' }] as Partial[]) { + const options = dobble.optionsSchema.parse(opts); + const a = fastPlayout(['A', 'B', 'C', 'D'], options, seedN(42), 20_000); + expect(a.finished).toBe(true); + const replay = (log: FastReport['log']) => { + let s = dobble.setup({ players: ['A', 'B', 'C', 'D'], options, rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 }); + for (const x of log) s = dobble.apply(s, x.p, x.a, { rng: rng(), now: x.now }).state; + return s; + }; + expect(JSON.stringify(replay(a.log))).toBe(JSON.stringify(a.final)); + } + }); +}); + +// ---------------------------------------------------------------- random playouts + +interface FastReport { + steps: number; + finished: boolean; + log: { p: PlayerId; a: DobbleAction; now: number }[]; + final: DobbleState; +} + +function invariants(s: DobbleState): void { + conserved(s); + const o = s.options; + for (const p of s.order) { + const st = s.status[p]!; + if (o.miniGame === 'well' && st === 'finished' && s.piles[p]!.length) throw new Error('finished with cards'); + if ((o.miniGame === 'tower' || o.miniGame === 'poisonedGift') && s.piles[p]!.length < 1) throw new Error(`${p} lost the starting card`); + if (o.miniGame === 'hotPotato' && s.penalty[p] !== s.taken[p]!.length) throw new Error('penalty != taken'); + if (st === 'escaped' && (o.miniGame !== 'hotPotato' || s.piles[p]!.length)) throw new Error('bad escaped'); + } + if (s.phase === 'playing') { + if (s.center.length > 0 && o.miniGame !== 'well' && s.center.length !== 1) throw new Error('centre holds more than one card'); + const live = s.order.filter((p) => s.status[p] !== 'forfeit' && s.status[p] !== 'left'); + if (live.length < 2) throw new Error('game should have ended'); + if (o.miniGame === 'hotPotato' && s.order.filter((p) => s.status[p] === 'playing' && s.piles[p]!.length).length < 2) throw new Error('round should have ended'); + if (o.miniGame !== 'hotPotato' && s.center.length === 0) throw new Error('no centre card'); + } + if (s.phase === 'ended' && !s.final) throw new Error('ended without result'); +} + +/** + * Time moves 20–1500ms per step. Mostly correct claims (some made against an older table → late), + * some wrong ones, server ticks when due, and now and then a disconnected player's early tick or a leave. + */ +function fastPlayout(players: PlayerId[], options: DobbleOptions, seed: RngState, maxSteps: number, away: PlayerId | null = null): FastReport { + const r = SeededRng.fromSeed(seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let now = 1_700_000_000_000; + let s = dobble.setup({ players, options, rng: r, now }); + const log: FastReport['log'] = []; + let stale: { p: PlayerId; a: ClaimAction } | null = null; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + if (step % 20 === 0) checkNoLeak(dobble, s, players[step % players.length]!, players); + for (const p of dobble.timeoutPlayers!(s)) { + const v = dobble.validate(s, p, dobble.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`); + } + if (dobble.result(s)) return { steps: step, finished: true, log, final: s }; + now += 20 + chooser.int(1480); + const due = dueAt(s)!; + let actor: PlayerId; + let action: DobbleAction; + const roll = chooser.int(100); + const claimers = s.order.filter((p) => p !== away && pairsFor(s, p).length > 0); + const notBefore = dobble.timeoutNotBefore!(s); + if (away && notBefore !== null && now >= notBefore && now < due && dobble.timeoutPlayers!(s).includes(away)) { + // The disconnected player is timed out as soon as timeoutNotBefore allows (the room does the same after grace). + actor = away; + action = dobble.onTimeout(s, away); + } else if (now >= due || claimers.length === 0) { + if (now < due) now = due; + const tp = dobble.timeoutPlayers!(s); + actor = tp[chooser.int(tp.length)]!; + action = dobble.onTimeout(s, actor); + } else if (roll < 2 && dobble.timeoutPlayers!(s).length > 0) { + // A disconnected player's early timeout (only fires once per away step, as the room would). + const nb = dobble.timeoutNotBefore!(s)!; + if (now < nb) now = nb; + const tp = dobble.timeoutPlayers!(s); + actor = tp[0]!; + action = dobble.onTimeout(s, actor); + } else if (roll < 3) { + const live = s.order.filter((p) => dobble.onLeave!(s, p)); + actor = live[chooser.int(live.length)]!; + action = { type: 'leave' }; + } else if (roll < 10 && stale) { + actor = stale.p; + action = stale.a; + } else { + actor = claimers[chooser.int(claimers.length)]!; + const pairs = pairsFor(s, actor); + const pair = pairs[chooser.int(pairs.length)]!; + const ans = answerArt(s, actor, pair)!; + const wrongOne = roll < 25; + const cc = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[actor]!.at(-1)!; + const sym = wrongOne ? s.symbolArt[s.cards[cc]![chooser.int(8)]!]! : ans; + action = { + type: 'claim', + base: pair.base, + symbol: sym, + ...(pair.target ? { target: pair.target } : {}), + ...(pair.mine !== null ? { mine: pair.mine } : {}), + rtt: chooser.int(300), + }; + stale = { p: claimers[chooser.int(claimers.length)]!, a: action as ClaimAction }; + } + const v = dobble.validate(s, actor, action); + if (!v.ok) { + if (action.type !== 'claim') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${v.reason}`); + continue; + } + const out = dobble.apply(s, actor, action, { rng: r, now }); + const secrets = dobble.secretsOf!(out.state, actor); + for (const e of out.events) { + const j = JSON.stringify(e); + for (const k of secrets) if (j.includes(`"${k}"`)) throw new Error(`event leaks ${k}`); + } + s = out.state; + log.push({ p: actor, a: action, now }); + } + return { steps: maxSteps, finished: false, log, final: s }; +} + +const PRESETS: Partial[] = [ + {}, + { miniGame: 'well' }, + { miniGame: 'hotPotato' }, + { miniGame: 'poisonedGift' }, + { miniGame: 'tower', latencyComp: 'light', wrongGivesCard: true, deckSize: 55 }, + { miniGame: 'well', wellEndAtFirst: true, wrongLockoutMs: 0, symbolStyle: 'easy' }, + { miniGame: 'hotPotato', hotPotatoRounds: 7, latencyComp: 'light', wrongLockoutMs: 2000 }, + { miniGame: 'poisonedGift', deckSize: 55, latencyComp: 'light' }, + { miniGame: 'hotPotato', hotPotatoRounds: 3, deckSize: 55 }, +]; + +describe('random playouts (§10, docs/09 §7)', () => { + test('1,000 seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic', () => { + const tally: Record = { correct: 0, wrong: 0, forfeit: 0, left: 0 }; + for (let i = 1; i <= 1000; i++) { + const n = 2 + (i % 7); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!); + // Every 5th game one player is disconnected for the whole game (never claims). + const away = i % 5 === 0 ? players[1]! : null; + const a = fastPlayout(players, options, seedN(i), 20_000, away); + if (!a.finished) throw new Error(`seed ${i} (${n} players, ${JSON.stringify(PRESETS[i % PRESETS.length])}) did not finish`); + const res = dobble.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + for (const p of players) { + tally.correct! += a.final.stats[p]!.correct; + tally.wrong! += a.final.stats[p]!.wrong; + if (a.final.status[p] === 'forfeit') tally.forfeit!++; + if (a.final.status[p] === 'left') tally.left!++; + } + if (i % 50 === 0) { + const b = fastPlayout(players, options, seedN(i), 20_000, away); + expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log)); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(tally.correct).toBeGreaterThan(10_000); + expect(tally.wrong).toBeGreaterThan(1000); + expect(tally.forfeit).toBeGreaterThan(0); + expect(tally.left).toBeGreaterThan(0); + }, 60_000); + + test.each([ + [2, {}], + [3, { miniGame: 'well' }], + [4, { miniGame: 'hotPotato', hotPotatoRounds: 3 }], + [5, { miniGame: 'poisonedGift', latencyComp: 'light' }], + [8, { miniGame: 'tower', wrongGivesCard: true }], + ] as const)('engine randomPlayout (per-step leak / round-trip checks): %i players %o', (n, opts) => { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = dobble.optionsSchema.parse(opts); + const inv = (st: unknown) => invariants(st as DobbleState); + for (let i = 1; i <= 4; i++) { + const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, maxApplyMs: 20 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }); +}); + +test('공통 그림 헬퍼', () => { + const d = buildDeck(); + expect(d[0]!).toContain(commonSymbol(d[0]!, d[1]!)); + expect(d[1]!).toContain(commonSymbol(d[0]!, d[1]!)); +}); diff --git a/packages/games/src/dobble/index.ts b/packages/games/src/dobble/index.ts new file mode 100644 index 0000000..618c697 --- /dev/null +++ b/packages/games/src/dobble/index.ts @@ -0,0 +1,856 @@ +/** + * 도블 — 같은 그림 찾기 (docs/games/dobble.md). Pure and deterministic; state is JSON; time only via ctx.now. + * Real time: everyone may claim at any moment, the room's single-threaded queue (arrival order) decides. + */ +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; +import { FULL_DECK, buildDeck, makeLayout } from './deck'; +import { + MINI_GAME_KO, + dobbleActionSchema, + dobbleOptionsSchema, + type CardView, + type ClaimAction, + type ClaimPair, + type DobbleAction, + type DobbleFinal, + type DobbleOptions, + type DobbleState, + type DobbleView, + type Feedback, + type LogEntry, + type PendingClaim, + type RankRow, + type SeatStatus, +} from './types'; + +export * from './types'; +export { FULL_DECK, PER_CARD, SYMBOLS, TEMPLATES, buildDeck, commonSymbol, makeLayout } from './deck'; + +type S = DobbleState; +type A = DobbleAction; + +export const COUNTDOWN_MS = 3000; +/** Hot potato: pause showing the round's loser before the next countdown. */ +export const ROUND_BREAK_MS = 2500; +/** §5.3 `latencyComp: 'light'` window. */ +export const WINDOW_MS = 60; +export const MAX_LATENCY_CREDIT_MS = 80; +/** §8.5 stall: nobody found anything for this long → show the common symbol for HINT_MS. */ +export const STALL_MS = 45_000; +export const HINT_MS = 2000; +/** + * Away detection: the room only times a player out before the game's own deadline when they have been + * disconnected past the grace period. Such early ticks are spaced AWAY_STEP_MS apart; AWAY_STRIKES of them + * (≥ grace + 20s, so always ≥ 30s) forfeit the player in well / hot potato (§8.5). + */ +export const AWAY_STEP_MS = 10_000; +export const AWAY_STRIKES = 3; +/** §5.3 봇 의심: FAST_STREAK correct claims in a row, each < FAST_MS after the cards changed. */ +export const FAST_MS = 200; +export const FAST_STREAK = 5; +const RTT_ALPHA = 0.2; +const LOG_KEEP = 30; +const POINTS = [3, 2, 1]; + +/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */ +export const cardKey = (id: number): string => `dc${id}`; + +export const LATE_TAKEN = '늦었어요! 다른 사람이 먼저 찾았어요.'; +export const LATE_CHANGED = '늦었어요! 그 사이에 카드가 바뀌었어요.'; +export const lockedText = (ms: number): string => `잠깐! ${Math.max(1, Math.ceil(ms / 1000))}초 뒤에 다시 눌러요`; + +// ---------------------------------------------------------------- queries + +const topOf = (s: S, p: PlayerId): number | undefined => s.piles[p]?.[s.piles[p]!.length - 1]; +const centerTop = (s: S): number | undefined => s.center[s.center.length - 1]; +/** Still in the game (not forfeited / left). */ +const inGame = (s: S, p: PlayerId): boolean => s.status[p] !== 'forfeit' && s.status[p] !== 'left'; +const usesTarget = (o: DobbleOptions): boolean => o.miniGame === 'hotPotato' || o.miniGame === 'poisonedGift'; + +/** Every card id, wherever it is (test invariant: each id exactly once). */ +export function allCardIds(s: S): number[] { + return [...s.order.flatMap((p) => [...s.piles[p]!, ...s.taken[p]!]), ...s.center, ...s.drawPile, ...s.unused]; +} + +export function canClaim(s: S, p: PlayerId): boolean { + if (s.phase !== 'playing' || s.status[p] !== 'playing') return false; + return s.options.miniGame === 'poisonedGift' || s.piles[p]!.length > 0; +} + +/** Whether `q` may be the target of `p`'s claim (gift / hot potato). */ +function validTarget(s: S, p: PlayerId, q: PlayerId): boolean { + return q !== p && s.order.includes(q) && s.status[q] === 'playing' && s.piles[q]!.length > 0; +} + +/** Pairs of cards `p` may claim on right now (§5.2). */ +export function pairsFor(s: S, p: PlayerId): ClaimPair[] { + if (!canClaim(s, p)) return []; + const c = centerTop(s); + const mine = topOf(s, p); + switch (s.options.miniGame) { + case 'tower': + case 'well': + return c === undefined || mine === undefined ? [] : [{ target: null, base: c, mine }]; + case 'poisonedGift': + return c === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: c, mine: null })); + case 'hotPotato': + return mine === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: topOf(s, q)!, mine })); + } +} + +/** The two card ids compared by a claim of `p` on `target` (null = the claim cannot be judged). */ +function comparedCards(s: S, p: PlayerId, target: PlayerId | undefined): [number, number] | null { + const c = centerTop(s); + const mine = topOf(s, p); + switch (s.options.miniGame) { + case 'tower': + case 'well': + return c === undefined || mine === undefined ? null : [mine, c]; + case 'poisonedGift': { + const t = target === undefined ? undefined : topOf(s, target); + return c === undefined || t === undefined ? null : [t, c]; + } + case 'hotPotato': { + const t = target === undefined ? undefined : topOf(s, target); + return mine === undefined || t === undefined ? null : [mine, t]; + } + } +} + +/** The single symbol number shared by two cards. */ +function common(s: S, a: number, b: number): number { + const x = s.cards[a]!; + const y = s.cards[b]!; + for (const v of x) if (y.includes(v)) return v; + throw new Error(`dobble: cards ${a} and ${b} share nothing`); +} + +/** The correct icon id for a pair (tests / legalActions / stall hint). */ +export function answerArt(s: S, p: PlayerId, pair: ClaimPair): number | null { + const cc = comparedCards(s, p, pair.target ?? undefined); + return cc ? s.symbolArt[common(s, cc[0], cc[1])]! : null; +} + +/** Reference card id a claim on `target` must carry. */ +function currentBase(s: S, target: PlayerId | undefined): number | undefined { + return s.options.miniGame === 'hotPotato' ? (target === undefined ? undefined : topOf(s, target)) : centerTop(s); +} + +/** Earliest time the server timer has something to do (null when ended). */ +export function dueAt(s: S): number | null { + if (s.phase === 'ended') return null; + if (s.phase === 'countdown' || s.phase === 'roundEnd') return s.phaseEndsAt; + if (s.pendingWindow) return s.pendingWindow.openedAt + WINDOW_MS; + return s.hintUntil > 0 ? s.hintUntil : s.changedAt + STALL_MS; +} + +// ---------------------------------------------------------------- helpers + +function clone(s: S): S { + const rec = (r: Record, f: (v: T) => T) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, f(v)])) as Record; + return { + ...s, // cards, layouts, symbolArt and options are never mutated + order: [...s.order], + status: { ...s.status }, + piles: rec(s.piles, (v) => [...v]), + center: [...s.center], + drawPile: [...s.drawPile], + unused: [...s.unused], + taken: rec(s.taken, (v) => [...v]), + lockedUntil: { ...s.lockedUntil }, + pendingWindow: s.pendingWindow ? { ...s.pendingWindow, claims: s.pendingWindow.claims.map((c) => ({ ...c })) } : null, + penalty: { ...s.penalty }, + outThisRound: [...s.outThisRound], + finishedOrder: [...s.finishedOrder], + away: rec(s.away, (v) => ({ ...v })), + rtt: { ...s.rtt }, + stats: rec(s.stats, (v) => ({ ...v })), + suspect: [...s.suspect], + feedback: { ...s.feedback }, + log: [...s.log], + }; +} + +interface Ctx { + now: number; + events: GameEvent[]; +} + +type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit : never) : never; + +function log(s: S, e: LogBody): void { + s.logNo += 1; + s.log.push({ n: s.logNo, ...e } as LogEntry); + if (s.log.length > LOG_KEEP) s.log.splice(0, s.log.length - LOG_KEEP); +} + +function judge(s: S, p: PlayerId, kind: Feedback['kind'], art: number | null, ctx: Ctx): void { + s.seq += 1; + s.feedback[p] = { seq: s.seq, kind, art, at: ctx.now }; +} + +/** Public cards changed: restart the stall timer and cancel a showing hint. */ +function cardsChanged(s: S, now: number): void { + s.changedAt = now; + s.hintUntil = 0; +} + +/** Turns the next hidden card face up in the centre (tower / gift); false when the draw pile is empty. */ +function revealNext(s: S, ctx: Ctx): boolean { + const id = s.drawPile.pop(); + if (id === undefined) return false; + s.center.push(id); + ctx.events.push({ type: 'reveal', key: cardKey(id) }); + return true; +} + +function dealRound(s: S, ctx: Ctx): boolean { + const players = s.order.filter((p) => inGame(s, p)); + if (s.drawPile.length < players.length) return false; + for (const p of players) { + s.status[p] = 'playing'; + s.piles[p] = [s.drawPile.pop()!]; + } + s.outThisRound = []; + s.phase = 'countdown'; + s.phaseEndsAt = ctx.now + COUNTDOWN_MS; + s.changedAt = s.phaseEndsAt; + s.hintUntil = 0; + ctx.events.push({ type: 'roundStart', round: s.round }); + return true; +} + +// ---------------------------------------------------------------- claims + +function succeed(s: S, c: PendingClaim, ctx: Ctx): void { + const p = c.player; + const st = s.stats[p]!; + st.correct += 1; + if (ctx.now - s.changedAt < FAST_MS) { + st.fastHits += 1; + if (st.fastHits >= FAST_STREAK && !s.suspect.includes(p)) s.suspect.push(p); + } else st.fastHits = 0; + s.away[p] = { strikes: 0, last: 0 }; + const art = s.symbolArt[c.symbol]!; + judge(s, p, 'ok', art, ctx); + s.lastHit = { seq: s.seq, player: p, target: c.target, art, at: ctx.now }; + log(s, { t: 'hit', p, target: c.target, art }); + ctx.events.push({ type: 'hit', player: p, target: c.target, art }); + cardsChanged(s, ctx.now); + switch (s.options.miniGame) { + case 'tower': { + s.piles[p]!.push(s.center.pop()!); + if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx); + return; + } + case 'poisonedGift': { + s.piles[c.target as PlayerId]!.push(s.center.pop()!); + if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx); + return; + } + case 'well': { + s.center.push(s.piles[p]!.pop()!); + if (s.piles[p]!.length === 0) { + s.status[p] = 'finished'; + s.finishedOrder.push(p); + log(s, { t: 'done', p, place: s.finishedOrder.length }); + ctx.events.push({ type: 'done', player: p, place: s.finishedOrder.length }); + } + checkWellEnd(s, ctx); + return; + } + case 'hotPotato': { + const t = c.target as PlayerId; + s.piles[t]!.push(...s.piles[p]!); + s.piles[p] = []; + s.status[p] = 'escaped'; + s.outThisRound.push(p); + const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0); + if (holders.length <= 1) endRound(s, holders[0] ?? null, ctx); + return; + } + } +} + +function checkWellEnd(s: S, ctx: Ctx): void { + if (s.phase === 'ended') return; + const left = s.order.filter((q) => s.status[q] === 'playing'); + if ((s.options.wellEndAtFirst && s.finishedOrder.length > 0) || left.length <= 1) finish(s, ctx); +} + +/** Hot potato: the last holder keeps the bundle as penalty (null = round voided because someone left). */ +function endRound(s: S, loser: PlayerId | null, ctx: Ctx): void { + let cards = 0; + if (loser) { + cards = s.piles[loser]!.length; + s.penalty[loser] = (s.penalty[loser] ?? 0) + cards; + s.taken[loser]!.push(...s.piles[loser]!); + s.piles[loser] = []; + } else { + for (const p of s.order) { + s.unused.push(...s.piles[p]!); + s.piles[p] = []; + } + } + s.pendingWindow = null; + s.lastRound = { round: s.round, loser, cards }; + log(s, { t: 'round', round: s.round, loser, cards }); + ctx.events.push({ type: 'roundEnd', round: s.round, loser, cards }); + for (const p of s.order) if (s.status[p] === 'escaped') s.status[p] = 'playing'; + s.outThisRound = []; + const players = s.order.filter((p) => inGame(s, p)); + if (s.round >= s.options.hotPotatoRounds || players.length < 2) { + finish(s, ctx); + return; + } + s.phase = 'roundEnd'; + s.phaseEndsAt = ctx.now + ROUND_BREAK_MS; + s.hintUntil = 0; +} + +/** Re-checks a claim against the current table (it may have changed since it arrived). */ +function stillValid(s: S, c: PendingClaim): boolean { + if (!canClaim(s, c.player)) return false; + const target = c.target === 'center' ? undefined : c.target; + if (target !== undefined && !validTarget(s, c.player, target)) return false; + if (currentBase(s, target) !== c.base) return false; + if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, c.player) !== c.mine) return false; + const cc = comparedCards(s, c.player, target); + return !!cc && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol); +} + +/** Closes the latency window: earliest adjusted time first (ties: arrival), stale claims become "late". */ +function resolveWindow(s: S, ctx: Ctx): void { + const w = s.pendingWindow; + if (!w) return; + s.pendingWindow = null; + const sorted = [...w.claims].sort((a, b) => a.adjTs - b.adjTs || a.arrived - b.arrived); + for (const c of sorted) { + if (s.phase === 'playing' && stillValid(s, c)) succeed(s, c, ctx); + else if (s.order.includes(c.player)) { + judge(s, c.player, 'late', null, ctx); + ctx.events.push({ type: 'late', player: c.player }); + } + } +} + +function claim(s: S, p: PlayerId, a: ClaimAction, ctx: Ctx): void { + if (s.pendingWindow && ctx.now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx); + if (a.rtt !== undefined) { + const sample = Math.min(2000, Math.max(0, Math.round(a.rtt))); + const old = s.rtt[p]; + s.rtt[p] = old === null || old === undefined ? sample : Math.round(old * (1 - RTT_ALPHA) + sample * RTT_ALPHA); + } + const target = a.target; + const c: PendingClaim = { + player: p, + target: target ?? 'center', + base: a.base, + mine: a.mine ?? null, + symbol: s.symbolArt.indexOf(a.symbol), + adjTs: ctx.now, + arrived: ctx.now, + }; + // The table moved while the window was being resolved → this claim is late (no penalty). + if (s.phase !== 'playing' || !canClaim(s, p) || (target !== undefined && !validTarget(s, p, target)) || currentBase(s, target) !== a.base) { + judge(s, p, 'late', null, ctx); + ctx.events.push({ type: 'late', player: p }); + return; + } + if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, p) !== c.mine) { + judge(s, p, 'late', null, ctx); + ctx.events.push({ type: 'late', player: p }); + return; + } + s.away[p] = { strikes: 0, last: 0 }; + // §5.3 잠금: judged on the server clock. + if (ctx.now < (s.lockedUntil[p] ?? 0)) { + judge(s, p, 'locked', null, ctx); + ctx.events.push({ type: 'locked', player: p }); + return; + } + const cc = comparedCards(s, p, target)!; + const right = c.symbol >= 0 && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol); + if (!right) { + s.stats[p]!.wrong += 1; + s.lockedUntil[p] = ctx.now + s.options.wrongLockoutMs; + judge(s, p, 'wrong', a.symbol, ctx); + ctx.events.push({ type: 'wrong', player: p }); + // §7 wrongGivesCard (tower): my top card goes under the draw pile; my last card always stays. + if (s.options.miniGame === 'tower' && s.options.wrongGivesCard && s.piles[p]!.length >= 2) { + s.drawPile.unshift(s.piles[p]!.pop()!); + ctx.events.push({ type: 'gaveCard', player: p }); + } + return; + } + if (s.options.latencyComp === 'light') { + const rtt = s.rtt[p] ?? 0; + c.adjTs = ctx.now - Math.min(rtt / 2, MAX_LATENCY_CREDIT_MS); + if (!s.pendingWindow) s.pendingWindow = { openedAt: ctx.now, claims: [] }; + s.pendingWindow.claims.push(c); + ctx.events.push({ type: 'windowed', player: p }); + return; + } + succeed(s, c, ctx); +} + +// ---------------------------------------------------------------- timer, away, leaving + +function processDue(s: S, ctx: Ctx): void { + if (s.phase === 'countdown') { + s.phase = 'playing'; + cardsChanged(s, ctx.now); + ctx.events.push({ type: 'start', round: s.round }); + return; + } + if (s.phase === 'roundEnd') { + s.round += 1; + if (!dealRound(s, ctx)) finish(s, ctx); + return; + } + if (s.pendingWindow) { + resolveWindow(s, ctx); + return; + } + if (s.hintUntil > 0) { + // Hint over: keep playing the same cards (no points for anyone), stall timer restarts. + cardsChanged(s, ctx.now); + return; + } + s.hintUntil = ctx.now + HINT_MS; + log(s, { t: 'hint' }); + ctx.events.push({ type: 'hint' }); +} + +/** Removes `p` (left the room, or forfeited after being away in well / hot potato; §8.5). */ +function drop(s: S, p: PlayerId, why: 'forfeit' | 'left', ctx: Ctx): void { + const was = s.status[p]; + s.status[p] = why; + if (s.pendingWindow) s.pendingWindow.claims = s.pendingWindow.claims.filter((c) => c.player !== p); + const mode = s.options.miniGame; + const holding = s.piles[p]!.length > 0; + if (mode === 'well' || mode === 'hotPotato') { + // Cards go back to the box ("가운데로 반환"); tower / gift piles stay as they are. + s.unused.push(...s.piles[p]!); + s.piles[p] = []; + } + s.outThisRound = s.outThisRound.filter((q) => q !== p); + log(s, { t: 'out', p, why }); + ctx.events.push({ type: 'out', player: p, why }); + if (s.order.filter((q) => inGame(s, q)).length < 2) { + finish(s, ctx); + return; + } + if (mode === 'well') checkWellEnd(s, ctx); + if (mode === 'hotPotato' && s.phase === 'playing' && was === 'playing' && holding) { + const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0); + // Nobody left to race against: the round is void (no penalty). + if (holders.length <= 1) endRound(s, null, ctx); + } +} + +/** An early tick: the room timed `p` out while disconnected past the grace period. */ +function awayStrike(s: S, p: PlayerId, ctx: Ctx): void { + const st = s.status[p]; + if (st !== 'playing' && st !== 'escaped') return; + const prev = s.away[p] ?? { strikes: 0, last: 0 }; + // Strikes rotate over every away player, so the gap between one player's strikes is ≤ players × step. + const fresh = prev.last > 0 && ctx.now - prev.last <= AWAY_STEP_MS * (s.order.length + 1); + const strikes = fresh ? prev.strikes + 1 : 1; + s.away[p] = { strikes, last: ctx.now }; + const mode = s.options.miniGame; + if (strikes >= AWAY_STRIKES && (mode === 'well' || mode === 'hotPotato')) { + drop(s, p, 'forfeit', ctx); + return; + } + // Tower / gift keep going without the away player — unless everybody is away. + if (mode === 'tower' || mode === 'poisonedGift') { + const live = s.order.filter((q) => s.status[q] === 'playing'); + const allAway = live.every((q) => { + const a = s.away[q]; + return !!a && a.strikes >= AWAY_STRIKES && ctx.now - a.last <= AWAY_STEP_MS * (s.order.length + 1); + }); + if (allAway) finish(s, ctx, '모두 자리를 비워서 게임을 끝냈어요.'); + } +} + +function tick(s: S, actor: PlayerId, ctx: Ctx): void { + const due = dueAt(s); + if (due !== null && ctx.now >= due) processDue(s, ctx); + else if (ctx.now >= s.awayCheckAt) { + awayStrike(s, actor, ctx); + s.awayRot += 1; + } + s.awayCheckAt = Math.max(s.awayCheckAt, ctx.now + AWAY_STEP_MS); +} + +// ---------------------------------------------------------------- ending + +function rankBy(ids: PlayerId[], key: (p: PlayerId) => number, start: number): { id: PlayerId; rank: number }[] { + const sorted = [...ids].sort((a, b) => key(a) - key(b)); + return sorted.map((id, i) => { + let j = i; + while (j > 0 && key(sorted[j - 1]!) === key(id)) j--; + return { id, rank: start + j }; + }); +} + +function finish(s: S, ctx: Ctx, override?: string): void { + if (s.phase === 'ended') return; + s.phase = 'ended'; + s.pendingWindow = null; + s.hintUntil = 0; + const mode = s.options.miniGame; + const live = s.order.filter((p) => inGame(s, p)); + const gone = s.order.filter((p) => !inGame(s, p)); + const cards = (p: PlayerId) => s.piles[p]!.length; + let ranks: { id: PlayerId; rank: number }[]; + switch (mode) { + case 'tower': + ranks = rankBy(live, (p) => -cards(p), 1); + break; + case 'poisonedGift': + ranks = rankBy(live, cards, 1); + break; + case 'hotPotato': + ranks = rankBy(live, (p) => s.penalty[p] ?? 0, 1); + break; + case 'well': { + const done = s.finishedOrder.filter((p) => live.includes(p)).map((id, i) => ({ id, rank: i + 1 })); + ranks = [...done, ...rankBy(live.filter((p) => !s.finishedOrder.includes(p)), cards, done.length + 1)]; + break; + } + } + // Forfeited / left players share the bottom place (§8.5 "꼴찌 그룹"). + for (const id of gone) ranks.push({ id, rank: live.length + 1 }); + const ranking: RankRow[] = ranks.map(({ id, rank }) => ({ + rank, + id, + ...(mode === 'hotPotato' ? { penalty: s.penalty[id] ?? 0 } : { cards: cards(id) }), + // Forfeited / left players score nothing even when the bottom place is 3rd. + points: inGame(s, id) ? (POINTS[rank - 1] ?? 0) : 0, + status: s.status[id]!, + })); + const winners = ranking.filter((r) => r.rank === 1); + const w = winners[0]!; + let summary: string; + if (override) summary = override; + else if (live.length === 1 && gone.length > 0) summary = `다른 사람이 모두 나가서 ${MINI_GAME_KO[mode]}을(를) 끝냈어요.`; + else if (mode === 'tower') summary = `탑 쌓기 끝! 카드 ${w.cards}장을 모은 사람이 1등이에요.`; + else if (mode === 'poisonedGift') summary = `독이 든 선물 끝! 카드를 ${w.cards}장만 받은 사람이 1등이에요.`; + else if (mode === 'hotPotato') summary = `뜨거운 감자 ${s.lastRound?.round ?? s.round}라운드 끝! 벌점 ${w.penalty}장으로 가장 적은 사람이 1등이에요.`; + else summary = s.finishedOrder.length > 0 ? '우물 끝! 카드를 가장 먼저 다 비운 사람이 1등이에요.' : '우물 끝! 카드가 가장 적게 남은 사람이 1등이에요.'; + if (winners.length > 1 && !override) summary += ` (공동 1등 ${winners.length}명)`; + const stats: DobbleFinal['stats'] = {}; + for (const p of s.order) stats[p] = { correct: s.stats[p]!.correct, wrong: s.stats[p]!.wrong }; + s.final = { miniGame: mode, ranking, stats, summary }; + ctx.events.push({ type: 'gameEnd' }); +} + +// ---------------------------------------------------------------- view + +function cardView(s: S, id: number): CardView { + return { + id, + key: cardKey(id), + items: s.layouts[id]!.items.map((i) => ({ art: s.symbolArt[i.symbol]!, x: i.x, y: i.y, r: i.r, scale: i.scale, rot: i.rot })), + }; +} + +function setup(players: PlayerId[], options: DobbleOptions, rng: Rng, now: number): S { + if (players.length < 2 || players.length > 8) throw new Error(`dobble: ${players.length} players not allowed`); + const err = playerCountError(options, players.length); + if (err) throw new Error(err); + // §8.4: seats, symbol → icon, card looks, deck order and the 55-card removals all come from the seeded RNG. + const order = rng.shuffle(players); + const symbolArt = rng.shuffle(Array.from({ length: FULL_DECK }, (_, i) => i)); + const cards = buildDeck(); + const layouts = cards.map((c) => makeLayout(c, options.symbolStyle, rng)); + const deck = rng.shuffle(cards.map((_, i) => i)); + const unused = options.deckSize === 55 ? deck.splice(0, FULL_DECK - 55) : []; + const per0 = (v: () => T) => Object.fromEntries(order.map((p) => [p, v()])) as Record; + const s: S = { + options, + host: players[0]!, + cards, + layouts, + symbolArt, + order, + status: per0(() => 'playing'), + piles: per0(() => []), + center: [], + drawPile: [], + unused, + taken: per0(() => []), + lockedUntil: per0(() => 0), + pendingWindow: null, + round: 1, + penalty: per0(() => 0), + outThisRound: [], + finishedOrder: [], + phase: 'countdown', + phaseEndsAt: now + COUNTDOWN_MS, + changedAt: now + COUNTDOWN_MS, + hintUntil: 0, + awayCheckAt: now + COUNTDOWN_MS + AWAY_STEP_MS, + awayRot: 0, + away: per0(() => ({ strikes: 0, last: 0 })), + rtt: per0(() => null), + stats: per0(() => ({ correct: 0, wrong: 0, fastHits: 0 })), + suspect: [], + feedback: {}, + lastHit: null, + lastRound: null, + seq: 0, + logNo: 0, + log: [], + final: null, + }; + const ctx: Ctx = { now, events: [] }; + switch (options.miniGame) { + case 'tower': + case 'poisonedGift': + for (const p of order) s.piles[p] = [deck.pop()!]; + s.center = [deck.pop()!]; + s.drawPile = deck; + break; + case 'well': { + s.center = [deck.pop()!]; + const per = Math.floor(deck.length / order.length); + order.forEach((p, i) => (s.piles[p] = deck.slice(i * per, (i + 1) * per))); + s.unused.push(...deck.slice(order.length * per)); + break; + } + case 'hotPotato': + s.drawPile = deck; + dealRound(s, ctx); + break; + } + return s; +} + +function playerCountError(o: DobbleOptions, count: number): string | null { + if (o.miniGame === 'hotPotato' && count * o.hotPotatoRounds > o.deckSize) { + return `뜨거운 감자 ${o.hotPotatoRounds}라운드를 ${count}명이 하기에는 카드가 모자라요. 라운드를 줄이거나 57장 덱을 골라 주세요.`; + } + return null; +} + +// ---------------------------------------------------------------- definition + +type Def = GameDefinition; + +export const dobble: Def = { + id: 'dobble', + nameKo: '도블', + minPlayers: 2, + maxPlayers: 8, + stateVersion: 1, + defaultOptions: dobbleOptionsSchema.parse({}), + optionsSchema: dobbleOptionsSchema as unknown as Def['optionsSchema'], + actionSchema: dobbleActionSchema as unknown as Def['actionSchema'], + + playerCountError, + + setup({ players, options, rng, now }) { + return setup(players, options, rng, now); + }, + + validate(s, actor, a) { + if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'ended') return fail('게임이 이미 끝났어요.'); + switch (a.type) { + case 'tick': + return ok; + case 'resolve': + return s.pendingWindow ? ok : fail('판정할 찾기가 없어요.'); + case 'leave': + return s.status[actor] === 'playing' || s.status[actor] === 'escaped' ? ok : fail('이미 게임에서 빠졌어요.'); + case 'claim': { + if (s.phase === 'countdown') return fail('곧 시작해요. 잠시만 기다려 주세요.'); + if (s.phase === 'roundEnd') return fail('다음 라운드를 준비하고 있어요.'); + const st = s.status[actor]; + if (st === 'forfeit' || st === 'left') return fail('게임에서 빠져서 찾을 수 없어요.'); + if (st === 'finished') return fail('이미 카드를 다 비웠어요.'); + if (st === 'escaped') return fail('이번 라운드는 탈출했어요. 다음 라운드를 기다려요.'); + if (!canClaim(s, actor)) return fail('지금은 찾을 카드가 없어요.'); + if (usesTarget(s.options)) { + if (a.target === undefined) return fail('누구 카드에서 찾을지 먼저 골라 주세요.'); + if (a.target === actor) return fail(s.options.miniGame === 'poisonedGift' ? '내 카드에는 선물을 줄 수 없어요.' : '내 카드끼리는 비교할 수 없어요.'); + if (!s.order.includes(a.target)) return fail('그런 사람은 없어요.'); + const ts = s.status[a.target]; + if (ts === 'escaped') return fail('이미 이번 라운드를 탈출한 사람이에요.'); + if (ts !== 'playing' || !validTarget(s, actor, a.target)) return fail('게임에서 빠진 사람이에요.'); + } else if (a.target !== undefined) return fail('이 미니게임은 대상을 고르지 않아요.'); + // §5.1: the reference card moved on → "늦었어요" (no penalty). + if (currentBase(s, a.target) !== a.base) return fail(s.options.miniGame === 'hotPotato' ? LATE_CHANGED : LATE_TAKEN); + if (a.mine !== undefined && s.options.miniGame !== 'poisonedGift' && topOf(s, actor) !== a.mine) return fail(LATE_CHANGED); + if (s.pendingWindow?.claims.some((c) => c.player === actor)) return fail('이미 눌렀어요. 판정을 기다려 주세요.'); + return ok; + } + } + }, + + apply(prev, actor, a, { now }) { + const s = clone(prev); + const ctx: Ctx = { now, events: [] }; + switch (a.type) { + case 'tick': + tick(s, actor, ctx); + break; + case 'resolve': + if (s.pendingWindow && now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx); + break; + case 'claim': + claim(s, actor, a, ctx); + break; + case 'leave': + drop(s, actor, 'left', ctx); + break; + } + return { state: s, events: ctx.events }; + }, + + view(s, viewer) { + const me = viewer && s.order.includes(viewer) ? viewer : null; + const reveal = s.phase !== 'countdown'; + const pairs = me && s.phase === 'playing' ? pairsFor(s, me) : []; + let hint: number[] = []; + if (s.phase === 'playing' && s.hintUntil > 0) { + const c0 = centerTop(s); + const arts = me + ? pairsFor(s, me).map((pair) => answerArt(s, me, pair)) + : s.options.miniGame === 'poisonedGift' && c0 !== undefined + ? s.order.filter((q) => s.status[q] === 'playing' && topOf(s, q) !== undefined).map((q) => s.symbolArt[common(s, topOf(s, q)!, c0)]!) + : []; + hint = [...new Set(arts.filter((x): x is number => x !== null))]; + } + const c = centerTop(s); + return { + options: s.options, + me, + isHost: viewer === s.host, + phase: s.phase, + phaseEndsAt: s.phaseEndsAt, + round: s.round, + rounds: s.options.miniGame === 'hotPotato' ? s.options.hotPotatoRounds : 1, + seats: s.order.map((p) => { + const t = topOf(s, p); + const place = s.finishedOrder.indexOf(p); + return { + id: p, + status: s.status[p]!, + cards: s.piles[p]!.length, + top: reveal && t !== undefined ? cardView(s, t) : null, + penalty: s.penalty[p] ?? 0, + lockedUntil: s.lockedUntil[p] ?? 0, + correct: s.stats[p]!.correct, + wrong: s.stats[p]!.wrong, + rtt: s.rtt[p] ?? null, + place: place >= 0 ? place + 1 : null, + }; + }), + center: reveal && c !== undefined ? cardView(s, c) : null, + centerCount: s.center.length, + drawCount: s.drawPile.length, + unusedCount: s.unused.length, + pairs, + windowOpen: s.pendingWindow !== null, + hint, + hintUntil: s.hintUntil, + lastHit: s.lastHit, + feedback: me ? (s.feedback[me] ?? null) : null, + lastRound: s.lastRound, + suspects: viewer !== null && viewer === s.host ? [...s.suspect] : [], + log: s.log.slice(-12), + result: s.final, + }; + }, + + /** No turns: everyone still in the game may claim at any time (§8.5). */ + activePlayers(s) { + if (s.phase === 'ended') return []; + return s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped'); + }, + + deadline(s) { + return dueAt(s); + }, + + /** + * Everyone still in drives the timer (countdown, latency window, stall hint), rotated after every away + * strike so that several disconnected players are all noticed in turn. + */ + timeoutPlayers(s) { + if (s.phase === 'ended') return []; + const ps = s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped'); + if (ps.length === 0) return []; + const k = s.awayRot % ps.length; + return [...ps.slice(k), ...ps.slice(0, k)]; + }, + + /** Disconnected players are timed out at most once per AWAY_STEP_MS (no busy loop), never before the window closes. */ + timeoutNotBefore(s) { + const due = dueAt(s); + if (due === null) return null; + if (s.pendingWindow) return due; + return Math.min(due, s.awayCheckAt); + }, + + onTimeout(s) { + return s.pendingWindow ? { type: 'resolve' } : { type: 'tick' }; + }, + + onLeave(s, player) { + if (s.phase === 'ended' || !s.order.includes(player)) return null; + return s.status[player] === 'playing' || s.status[player] === 'escaped' ? { type: 'leave' } : null; + }, + + result(s): GameResult | null { + const f = s.final; + if (!f) return null; + const groups = new Map(); + for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]); + const scores: Record = {}; + for (const r of f.ranking) scores[r.id] = r.points; + return { + ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids), + scores, + summary: f.summary, + reason: 'normal', + }; + }, + + /** Test helper: the right claim on every pair, a wrong one now and then, and the timer tick. */ + legalActions(s, p) { + if (s.phase === 'ended' || !s.order.includes(p)) return []; + const out: A[] = [{ type: 'tick' }]; + if (s.pendingWindow) out.push({ type: 'resolve' }); + const pairs = pairsFor(s, p); + for (const pair of pairs) { + const art = answerArt(s, p, pair)!; + out.push({ type: 'claim', base: pair.base, symbol: art, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) }); + } + const st = s.stats[p]!; + const first = pairs[0]; + if (first && (st.correct + st.wrong) % 4 === 3) { + const cc = comparedCards(s, p, first.target ?? undefined)!; + const right = common(s, cc[0], cc[1]); + const wrong = s.cards[cc[0]]!.find((x) => x !== right)!; + out.push({ type: 'claim', base: first.base, symbol: s.symbolArt[wrong]!, ...(first.target ? { target: first.target } : {}) }); + } + return out.filter((x) => dobble.validate(s, p, x).ok); + }, + + /** Cards nobody may see: draw pile, box, well face-down piles, and everything dealt during a countdown. */ + secretsOf(s) { + const hidden = [...s.drawPile, ...s.unused]; + for (const p of s.order) { + const pile = s.piles[p]!; + if (s.options.miniGame === 'well') hidden.push(...pile.slice(0, -1)); + if (s.phase === 'countdown' && pile.length) hidden.push(pile[pile.length - 1]!); + } + if (s.phase === 'countdown' && s.center.length) hidden.push(s.center[s.center.length - 1]!); + return hidden.map(cardKey); + }, +}; diff --git a/packages/games/src/dobble/types.ts b/packages/games/src/dobble/types.ts new file mode 100644 index 0000000..d159448 --- /dev/null +++ b/packages/games/src/dobble/types.ts @@ -0,0 +1,272 @@ +/** 도블(같은 그림 찾기) options, actions, state and view types (docs/games/dobble.md §2, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; + +/** §3.2: 57 original icons (drawn in apps/web/src/games/dobble/art.ts), index = icon id. */ +export const SYMBOL_NAMES = [ + '사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름', + '나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지', + '물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠', + '시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻', + '자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성', + '선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개', +] as const; + +export type MiniGame = 'tower' | 'well' | 'hotPotato' | 'poisonedGift'; +export const MINI_GAME_KO: Record = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' }; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const dobbleOptionsSchema = z.object({ + miniGame: z + .enum(['tower', 'well', 'hotPotato', 'poisonedGift']) + .default('tower') + .meta({ title: '미니게임', labels: { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' } }), + deckSize: lit<57 | 55>(57, 55) + .default(57) + .meta({ title: '카드 장수', labels: { 57: '57장(수학적으로 꽉 찬 덱)', 55: '55장(원작과 같은 장수)' } }), + hotPotatoRounds: lit<3 | 5 | 7>(3, 5, 7) + .default(5) + .meta({ title: '뜨거운 감자 라운드 수', labels: { 3: '3라운드', 5: '5라운드', 7: '7라운드' } }), + wrongLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000) + .default(1000) + .meta({ title: '틀린 그림을 눌렀을 때 잠금 시간', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }), + latencyComp: z + .enum(['off', 'light']) + .default('off') + .meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }), + symbolStyle: z + .enum(['easy', 'classic']) + .default('classic') + .meta({ title: '그림 크기·회전', labels: { easy: '쉽게(크기 같고 회전 없음)', classic: '원작처럼(크기·회전 무작위)' } }), + showRtt: z.boolean().default(true).meta({ title: '각자 핑(연결 지연) 표시' }), + wellEndAtFirst: z.boolean().default(false).meta({ title: '우물: 처음 다 비운 사람이 나오면 바로 끝내기' }), + wrongGivesCard: z.boolean().default(false).meta({ title: '탑 쌓기: 틀리면 내 탑 맨 위 카드를 가운데 더미 맨 아래로' }), +}); +export type DobbleOptions = z.infer; + +export const dobbleActionSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('claim'), + /** Reference card id the client was looking at: centre top (tower/well/gift) or the target's top (hot potato). */ + base: z.number().int().min(0).max(56), + /** Gift / hot potato: whose card the symbol is matched on. */ + target: z.string().min(1).max(64).optional(), + /** My own top card id the client saw (hot potato: someone may have just dumped their bundle on me). */ + mine: z.number().int().min(0).max(56).optional(), + /** Icon id that was tapped. */ + symbol: z.number().int().min(0).max(56), + /** Client-measured round trip (ms); feeds the per-player RTT average (§5.3), clamped. */ + rtt: z.number().min(0).max(10_000).optional(), + }), +]); +/** Client claims plus system-only actions (server timer, latency window, leaving) that are not in the schema. */ +export type DobbleAction = z.infer | { type: 'tick' } | { type: 'resolve' } | { type: 'leave' }; +export type ClaimAction = Extract; + +export interface LayoutItem { + /** Symbol number 0..56 (projective-plane point); the screen shows `symbolArt[symbol]`. */ + symbol: number; + /** Slot centre in the unit circle (−1..1) and slot radius. */ + x: number; + y: number; + r: number; + /** §3.3 classic: 0.6–1.2, easy: 1. */ + scale: number; + /** Degrees, classic: 0–359, easy: 0. */ + rot: number; +} + +export interface CardLayout { + template: number; + items: LayoutItem[]; +} + +export type SeatStatus = 'playing' | 'finished' | 'escaped' | 'forfeit' | 'left'; +export type Phase = 'countdown' | 'playing' | 'roundEnd' | 'ended'; + +export interface PendingClaim { + player: PlayerId; + target: PlayerId | 'center'; + base: number; + mine: number | null; + symbol: number; + adjTs: number; + arrived: number; +} + +export interface PendingWindow { + openedAt: number; + claims: PendingClaim[]; +} + +export interface Stats { + correct: number; + wrong: number; + /** Correct claims made < 200ms after the cards changed, in a row (§5.3 봇 의심). */ + fastHits: number; +} + +/** Per-player judgement of their latest claim (shown only to that player). */ +export interface Feedback { + seq: number; + kind: 'ok' | 'wrong' | 'late' | 'locked'; + /** Icon id. */ + art: number | null; + at: number; +} + +export interface LastHit { + seq: number; + player: PlayerId; + target: PlayerId | 'center'; + art: number; + at: number; +} + +export type LogEntry = + | { n: number; t: 'hit'; p: PlayerId; target: PlayerId | 'center'; art: number } + | { n: number; t: 'done'; p: PlayerId; place: number } + | { n: number; t: 'round'; round: number; loser: PlayerId | null; cards: number } + | { n: number; t: 'out'; p: PlayerId; why: 'forfeit' | 'left' } + | { n: number; t: 'hint' }; + +export interface RankRow { + rank: number; + id: PlayerId; + cards?: number; + penalty?: number; + points: number; + status: SeatStatus; +} + +export interface DobbleFinal { + miniGame: MiniGame; + ranking: RankRow[]; + stats: Record; + summary: string; +} + +export interface DobbleState { + options: DobbleOptions; + /** Room host at game start (players[0]); only they see the bot-suspicion marks (§5.3). */ + host: PlayerId; + /** Card id → symbol numbers (8 each). Immutable after setup. */ + cards: number[][]; + /** Card id → layout. Immutable after setup. */ + layouts: CardLayout[]; + /** Symbol number → icon id (shuffled per game). Immutable after setup. */ + symbolArt: number[]; + order: PlayerId[]; + status: Record; + /** Player piles, top = last. Well: the whole face-down pile, only the top is face up. */ + piles: Record; + /** Public centre pile, top = last. */ + center: number[]; + /** Hidden draw pile, next card = last. */ + drawPile: number[]; + /** Well leftovers, 55-deck removals, forfeited / left players' returned cards. */ + unused: number[]; + /** Hot potato: cards each round's loser keeps as penalty. */ + taken: Record; + lockedUntil: Record; + pendingWindow: PendingWindow | null; + /** Hot potato round (1-based); other modes stay 1. */ + round: number; + penalty: Record; + outThisRound: PlayerId[]; + finishedOrder: PlayerId[]; + phase: Phase; + phaseEndsAt: number; + /** Last time the public cards changed (stall timer, bot suspicion). */ + changedAt: number; + /** §8.5 stall: the common symbol is shown until this time. */ + hintUntil: number; + /** Throttles away detection (one early tick per away step). */ + awayCheckAt: number; + /** Rotates `timeoutPlayers` after each away strike so several away players are all noticed. */ + awayRot: number; + /** Consecutive early ticks per player (= the room timed them out while disconnected) and the last one. */ + away: Record; + rtt: Record; + stats: Record; + suspect: PlayerId[]; + feedback: Record; + lastHit: LastHit | null; + /** Hot potato: the latest finished round. */ + lastRound: { round: number; loser: PlayerId | null; cards: number } | null; + seq: number; + logNo: number; + log: LogEntry[]; + final: DobbleFinal | null; +} + +// ---------------------------------------------------------------- view + +export interface ItemView { + /** Icon id. */ + art: number; + x: number; + y: number; + r: number; + scale: number; + rot: number; +} + +export interface CardView { + id: number; + /** Opaque public key (`secretsOf` uses the same form for hidden cards). */ + key: string; + items: ItemView[]; +} + +export interface SeatView { + id: PlayerId; + status: SeatStatus; + /** Cards in the pile (tower: tower height, well: cards left, gift: poison, hot potato: bundle). */ + cards: number; + top: CardView | null; + penalty: number; + lockedUntil: number; + correct: number; + wrong: number; + rtt: number | null; + /** Well finishing place (1-based). */ + place: number | null; +} + +/** One pair of cards the viewer may claim on (rules precomputed for the screen). */ +export interface ClaimPair { + target: PlayerId | null; + base: number; + mine: number | null; +} + +export interface DobbleView { + options: DobbleOptions; + me: PlayerId | null; + isHost: boolean; + phase: Phase; + phaseEndsAt: number; + round: number; + rounds: number; + seats: SeatView[]; + center: CardView | null; + centerCount: number; + drawCount: number; + unusedCount: number; + /** Pairs the viewer can claim on right now (empty while they cannot claim). */ + pairs: ClaimPair[]; + windowOpen: boolean; + /** §8.5 stall hint: icon ids common to the viewer's pairs, shown until `hintUntil`. */ + hint: number[]; + hintUntil: number; + lastHit: LastHit | null; + feedback: Feedback | null; + lastRound: DobbleState['lastRound']; + /** Host only (§5.3). */ + suspects: PlayerId[]; + log: LogEntry[]; + result: DobbleFinal | null; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index df1d95c..d647096 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -22,6 +22,7 @@ import { quoridor } from './quoridor'; import { lasVegas } from './las-vegas'; import { cockroachPoker } from './cockroach-poker'; import { ninetyNine } from './ninety-nine'; +import { dobble } from './dobble'; export const GAMES: Record = { [omok.id]: omok, @@ -47,4 +48,5 @@ export const GAMES: Record = { [lasVegas.id]: lasVegas, [cockroachPoker.id]: cockroachPoker, [ninetyNine.id]: ninetyNine, + [dobble.id]: dobble, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index cf2f21a..ba98833 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -34,6 +34,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true }, { id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true }, { id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true }, + { id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);