diff --git a/apps/server/src/rooms/halli-galli.room.test.ts b/apps/server/src/rooms/halli-galli.room.test.ts new file mode 100644 index 0000000..7e93160 --- /dev/null +++ b/apps/server/src/rooms/halli-galli.room.test.ts @@ -0,0 +1,123 @@ +/** 할리갈리 through a real Room: bell arrival order, "늦었어요" rejects, away players' auto flips (docs/games/halli-galli.md §5.4, §8.5). */ +import { describe, expect, test } from 'bun:test'; +import type { ServerMessage } from '@bg/shared'; +import { GAMES } from '@bg/games'; +import { CARDS, type HalliGalliState } from '@bg/games/halli-galli'; +import { openDb } from '../db'; +import { Room, type Conn, type RoomConfig } from './room'; +import { RoomStore } from './store'; + +function harness(players: string[]) { + 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 = { 'halli-galli': GAMES['halli-galli']! }; + const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now }; + const config: RoomConfig = { + gameId: 'halli-galli', + options: games['halli-galli'].defaultOptions, + 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: 'halli-galli', 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 HalliGalliState; + return { room, clock, conns, state }; +} + +/** Moves the named cards (fruit + count) from wherever they are onto the players' face-up piles. */ +function placeTops(s: HalliGalliState, tops: Record): void { + for (const p of s.order) { + s.decks[p]!.push(...s.faceUp[p]!); + s.faceUp[p] = []; + } + for (const [p, [fruit, count]] of Object.entries(tops)) { + const c = CARDS.find((x) => x.fruit === fruit && x.count === count && !Object.values(s.faceUp).flat().includes(x.id))!; + for (const q of s.order) s.decks[q] = s.decks[q]!.filter((id) => id !== c.id); + s.pot = s.pot.filter((id) => id !== c.id); + s.faceUp[p]!.push(c.id); + } +} + +describe('할리갈리 room flow', () => { + test('거의 동시에 친 두 종: 서버 도착 순서로 먼저 온 사람만 수거, 나중 사람은 "늦었어요" 거절(벌칙 없음)', () => { + const { room, conns, state } = harness(['A', 'B', 'C']); + // §5.5: banana 3 + strawberry 2 + banana 2 → banana 5. + const [x, y, z] = state().order as [string, string, string]; + placeTops(state(), { [x]: ['banana', 3], [y]: ['strawberry', 2], [z]: ['banana', 2] }); + const v = state().tableVersion; + const decks = Object.fromEntries(state().order.map((p) => [p, state().decks[p]!.length])); + const pot = state().pot.length; // 3 players: 2 leftover cards under the bell + // Same millisecond: the room's single-threaded queue decides — B's message is handled first. + room.act('B', 1, { type: 'ring', version: v }); + room.act('C', 1, { type: 'ring', version: v }); + room.clearTimer(); + expect(state().decks.B).toHaveLength(decks.B! + 3 + pot); + expect(state().decks.C).toHaveLength(decks.C!); + expect(state().wrongCount.C).toBe(0); + expect(state().turn).toBe('B'); + expect(conns.C!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }]); + // B got the success echo (state with its cs) and everyone sees who rang. + expect(conns.B!.msgs.some((m) => m.t === 'state' && m.cs === 1)).toBe(true); + const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract; + expect((last.view as { lastBell: { player: string; correct: boolean } }).lastBell).toMatchObject({ player: 'B', correct: true }); + }); + + test('틀린 종은 바로 벌칙, 버전이 지난 종은 무시', () => { + const { room, clock, conns, state } = harness(['A', 'B']); + placeTops(state(), { A: ['lime', 1] }); + const v = state().tableVersion; + const before = state().decks.B!.length; + room.act('B', 1, { type: 'ring', version: v }); + room.clearTimer(); + expect(state().decks.B).toHaveLength(before - 1); + // The turn player flips (after the guard) — a bell for the old table is ignored as late. + clock.advance(1000); + room.act(state().turn, 2, { type: 'flip' }); + room.clearTimer(); + expect(state().tableVersion).toBe(v + 1); + room.act('A', 3, { type: 'ring', version: v }); + expect(conns.A!.msgs.find((m) => m.t === 'reject')).toMatchObject({ reason: expect.stringContaining('늦었어요') }); + }); + + test('연결이 끊겨 자리 비움이 된 사람도 자동 뒤집기로 계속 진행(1초 간격, 바쁜 반복 없음)', () => { + const { room, clock, conns, state } = harness(['A', 'B']); + const away = state().turn; + const other = away === 'A' ? 'B' : 'A'; + room.disconnect(conns[away]!); + room.clearTimer(); + clock.advance(61_000); // grace period (60s) over + const gaps: [string, number][] = []; + for (let i = 0; i < 6; i++) { + const s = state(); + const d = room.effectiveDeadline()!; + gaps.push([s.turn, d - s.turnStartedAt]); + if (d > clock.now()) clock.set(d); + const flips = s.faceUp[s.turn]!.length; + room.onTimer(); + room.clearTimer(); + expect(state().faceUp[s.turn]!.length).toBe(flips + 1); + } + // The first (overdue) flip happens at once; afterwards the away player flips 1s into each turn, + // the connected player gets the normal 3 seconds. + for (const [who, gap] of gaps.slice(1)) expect(gap).toBe(who === other ? 3000 : 1000); + expect(room.status).toBe('playing'); + }); +}); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 3825d96..3616f09 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -766,6 +766,7 @@ export class Room { if (!g || g.finished || this.status !== 'playing') return null; const def = this.def; const base = def.deadline?.(g.state) ?? null; + const notBefore = def.timeoutNotBefore?.(g.state) ?? null; const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); let best: number | null = null; for (const u of timed) { @@ -777,6 +778,7 @@ export class Room { } if (d === null) continue; if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor); + if (notBefore !== null) d = Math.max(d, notBefore); best = best === null ? d : Math.min(best, d); } return best; @@ -803,6 +805,7 @@ export class Room { const def = this.def; const now = this.now(); const base = def.deadline?.(g.state) ?? null; + const notBefore = def.timeoutNotBefore?.(g.state) ?? null; const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); for (const u of timed) { let d = base; @@ -812,6 +815,7 @@ export class Room { d = d === null ? grace : Math.min(d, grace); } if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor); + if (notBefore !== null && d !== null) d = Math.max(d, notBefore); if (d === null || d > now) continue; const action = def.onTimeout(g.state, u); const err = this.runAction(u, action, undefined, true); diff --git a/apps/web/src/games/halli-galli/index.tsx b/apps/web/src/games/halli-galli/index.tsx new file mode 100644 index 0000000..98faa89 --- /dev/null +++ b/apps/web/src/games/halli-galli/index.tsx @@ -0,0 +1,960 @@ +/** 할리갈리 화면 (docs/games/halli-galli.md §9). */ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli'; +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'; + +const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum']; +const FRUIT_KO: Record = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' }; +const FRUIT_INK: Record = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' }; + +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 cardName = (c: Pick) => `${FRUIT_KO[c.fruit]} ${c.count}개`; + +const CSS = ` +.hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; } +.hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; } +.hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; } +.hg-pill.warn { background: var(--warning-soft); color: var(--warning); } +.hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } +.hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow); + background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; } +.hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px; + padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; } +.hg-seat[data-me='true'] { background: rgba(0,0,0,.42); } +.hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; } +.hg-seat[data-out='true'] { opacity: .6; } +.hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; } +.hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); } +.hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; } +.hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); } +.hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; } +.hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; } +.hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; } +.hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; } +.hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; } +.hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; } +.hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; } +.hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%); + box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center; + transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; } +.hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; } +.hg-bell .art svg { width: 100%; height: 100%; display: block; } +.hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; } +.hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); } +.hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); } +.hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; } +.hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; } +.hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; } +.hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; } +.hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none; + background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; } +.hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px; + font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; } +.hg-bubble.ok { border: 4px solid #16a34a; } +.hg-bubble.bad { border: 4px solid #dc2626; } +.hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; } +.hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff; + font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; } +.hg-count small { font-size: 1.1rem; } +.hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none; + animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); } +.hg-mine { display: flex; gap: 10px; align-items: stretch; } +.hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; } +.hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; } +.hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; } +.hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; } +.hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; } +.hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } +.hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; } +.hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); } +.hg-bar[data-five='true'] { color: var(--success); } +.hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; } +.hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; } +.hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; } +@keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } } +@keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } } +@keyframes hg-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 hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } } +@keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } } +@media (max-width: 719px) { + .hg-table { height: 450px; border-width: 6px; border-radius: 22px; } + .hg-seat { padding: 3px 3px 4px; gap: 2px; } + .hg-seat .nm { font-size: .78rem; } + .hg-meta { font-size: .76rem; gap: 4px; } + .hg-bubble { font-size: .95rem; padding: 6px 12px; } +} +@media (prefers-reduced-motion: reduce) { + .hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); } + .hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; } + .hg-ghost { display: none; } +} +`; + +// ---------------------------------------------------------------- fruit art + +/** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */ +function FruitShape({ fruit }: { fruit: Fruit }) { + switch (fruit) { + case 'strawberry': + return ( + + + {[ + [14, 17], + [20, 15], + [26, 17], + [12, 23], + [18, 22], + [24, 22], + [28, 23], + [16, 28], + [22, 28], + [19, 33], + ].map(([x, y]) => ( + + ))} + + + ); + case 'banana': + return ( + + + + + + + ); + case 'lime': + return ( + + + + + {Array.from({ length: 8 }, (_, i) => { + const a = (i * Math.PI) / 4; + return ; + })} + + + ); + case 'plum': + return ( + + + + + + + + ); + } +} + +const SPOTS: Record = { + 1: [[50, 70]], + 2: [ + [50, 38], + [50, 102], + ], + 3: [ + [28, 34], + [50, 70], + [72, 106], + ], + 4: [ + [29, 38], + [71, 38], + [29, 102], + [71, 102], + ], + 5: [ + [27, 32], + [73, 32], + [50, 70], + [27, 108], + [73, 108], + ], +}; + +export function FruitCardArt({ card }: { card: Pick | null }) { + if (!card) { + return ( + + + + + + ); + } + const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41; + return ( + + + {SPOTS[card.count].map(([x, y], i) => ( + + + + ))} + + ); +} + +function FruitIcon({ fruit }: { fruit: Fruit }) { + return ( + + + + ); +} + +function BellArt() { + return ( + + + + + + + + ); +} + +// ---------------------------------------------------------------- geometry + +interface Pos { + left: string; + top: string; +} +const CENTER: Pos = { left: '50%', top: '50%' }; + +/** + * Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are + * "half the table minus half a seat", so seats always stay inside the felt whatever its size. + * `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it. + */ +function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] { + const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4))); + return Array.from({ length: n }, (_, k) => { + const t = (2 * Math.PI * k) / n; + const c = f(Math.cos(t)); + const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c; + return { + left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`, + top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`, + }; + }); +} + +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; +} + +// ---------------------------------------------------------------- seat + +function Gauge({ start, end, now }: { start: number; end: number; now: number }) { + const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start))); + const r = 12; + const c = 2 * Math.PI * r; + const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e'; + return ( + + + + + + ); +} + +function seatLabel(s: SeatView, players: Record, me: string | null): string { + const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`]; + parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음'); + parts.push(`덱 ${s.deck}장`); + if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`); + if (s.status === 'out') parts.push('탈락'); + if (s.status === 'left') parts.push('나감'); + if (s.isTurn) parts.push('뒤집을 차례'); + return parts.join(', '); +} + +function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record; me: string | null; now: number }) { + const u = userOf(players, s.id); + const locked = s.lockedUntil > now && s.status === 'playing'; + return ( +
  • +
    + + {s.id === me ? '나' : u.nickname} +
    +
    + {s.pile > 1 && } + {s.top ? ( + + + + ) : ( + + )} + {s.isTurn && v.phase === 'playing' && } +
    +
    + + {s.deck} + + {s.status === 'out' && ( + + 탈락 + + )} + {s.status === 'left' && ( + + 나감 + + )} + {locked && ( + + 잠금 + + )} +
    +
  • + ); +} + +// ---------------------------------------------------------------- bell feedback + +interface Bubble { + key: string; + kind: 'ok' | 'bad' | 'late'; + text: string; +} + +interface Ghost { + key: string; + from: Pos; + to: Pos; + card: Pick | null; + delay: number; +} + +function bellText(v: HalliGalliView, players: Record): Bubble | null { + const b = v.lastBell; + if (!b) return null; + const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`; + if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` }; + const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel); + if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` }; + const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`; + return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` }; +} + +// ---------------------------------------------------------------- board + +function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps) { + const now = useServerNow(100); + const phone = usePhone(); + const rtt = useRoom((s) => s.rttMs); + const [bubble, setBubble] = useState(null); + const [ghosts, setGhosts] = useState([]); + const [hit, setHit] = useState(0); + const [announce, setAnnounce] = useState(''); + const sentFor = useRef(null); + const prev = useRef(null); + + // Seats rotated so that I sit at the bottom (spectators: first seat at the bottom). + const myIdx = v.seats.findIndex((s) => s.id === me); + const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)]; + const many = rot.length >= 5; + const cw = phone ? (many ? 46 : 54) : many ? 70 : 86; + const sw = phone ? (many ? 74 : 86) : many ? 116 : 132; + const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone); + const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER; + const mine = v.seats.find((s) => s.id === me) ?? null; + const playing = v.phase === 'playing'; + const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0; + const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0; + const myTurn = !!mine && playing && v.turn === me; + const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending; + + const ring = () => { + if (!canRing || sentFor.current === v.tableVersion) return; + sentFor.current = v.tableVersion; + setHit((h) => h + 1); + sounds.ding(); + haptic(30); + act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) }); + }; + const flip = () => { + if (!canFlip) return; + act({ type: 'flip' }); + }; + const ringRef = useRef(ring); + const flipRef = useRef(flip); + ringRef.current = ring; + flipRef.current = flip; + + // Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning). + useEffect(() => { + const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); + const down = (e: KeyboardEvent) => { + if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return; + if (e.code === 'Space' || e.key === ' ') { + e.preventDefault(); + if (!e.repeat) ringRef.current(); + } else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) { + if (!e.repeat) flipRef.current(); + } + }; + const up = (e: KeyboardEvent) => { + if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault(); + }; + window.addEventListener('keydown', down); + window.addEventListener('keyup', up); + return () => { + window.removeEventListener('keydown', down); + window.removeEventListener('keyup', up); + }; + }, []); + + // "늦었어요" and other bell 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: flips, bells, card flights. + useEffect(() => { + const p = prev.current; + prev.current = v; + if (!p) return; + const b = v.lastBell; + if (b && b.seq !== p.lastBell?.seq) { + const bub = bellText(v, players); + if (bub) { + setBubble(bub); + setAnnounce(bub.text); + } + const to = posOf(b.player); + const fly: Ghost[] = []; + if (b.correct) { + if (b.player !== me) sounds.ding(); + else haptic([40, 30, 80]); + p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 })); + if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 }); + } else { + sounds.buzz(); + if (b.player === me) haptic([90, 50, 90]); + const from = posOf(b.player); + const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id)); + targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 })); + } + setGhosts(fly); + return; + } + if (v.tableVersion !== p.tableVersion && v.lastFlipBy) { + sounds.stone(); + const s = v.seats.find((x) => x.id === v.lastFlipBy); + if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`); + } + if (v.turn === me && p.turn !== me && playing) haptic(20); + }, [v]); // eslint-disable-line react-hooks/exhaustive-deps + + // A new table (or a judged bell) lets me ring again. + useEffect(() => { + if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null; + }, [v.tableVersion]); + + useEffect(() => { + if (!ghosts.length) return; + const t = setTimeout(() => setGhosts([]), 1000); + return () => clearTimeout(t); + }, [ghosts]); + + useEffect(() => { + if (!bubble) return; + const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900); + return () => clearTimeout(t); + }, [bubble]); + + const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)'; + const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0; + const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) })); + const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null; + const ping = rtt; + + return ( +
    + +
    +
    + {timeLeft !== null && ( + + ⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')} + + )} + {v.officialDuel && playing && 2명 남음 · 다음 종으로 끝나요} +
    +
    + {!phone && 스페이스 바 = 종 · Enter = 뒤집기} + {v.options.showRtt && ping !== null && ( + = 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}> + + {ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`} + + )} +
    +
    + +
    +
      + {rot.map((s, i) => ( + + ))} +
    +
    + + {v.pot > 0 && ( + + 종 밑 {v.pot}장 + + )} +
    + {ghosts.map((g) => ( + + + + ))} + {bubble && ( +
    + {bubble.text} +
    + )} + {countdown > 0 && ( +
    + {countdown} + 과일이 딱 5개가 되면 종을 쳐요! +
    + )} +
    + + {mine && mine.status === 'playing' && v.phase !== 'ended' && ( +
    +
    + 내 덱 + + {mine.deck}장 + +
    + +
    + )} + {mine && mine.status !== 'playing' && v.phase !== 'ended' && ( +
    + {mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'} +
    + )} + {v.options.kidMode && v.phase !== 'ended' && ( +
    + 5개가 되면 종을 쳐요 + {totals.map(({ f, n }) => ( +
    + + {FRUIT_KO[f]} + + + + {n} +
    + ))} +
    + )} +

    + {announce} +

    +
    + ); +} + +// ---------------------------------------------------------------- side + +function logText(e: LogEntry, players: Record, me: string | null): string { + const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); + switch (e.t) { + case 'collect': + return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`; + case 'wrong': + return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`; + case 'duelWrong': + return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`; + case 'out': + return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`; + } +} + +function rulesLine(o: HalliGalliOptions): string { + const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음']; + parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`); + if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`); + if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남'); + if (o.wrongToPot) parts.push('벌칙은 종 밑으로'); + if (o.strikeOut) parts.push('3번 틀리면 탈락'); + if (o.duelWrongLoses) parts.push('1대1 오답은 패배'); + if (o.latencyComp === 'light') parts.push('지연 보정'); + return parts.join(' · '); +} + +function HalliGalliSide({ view: v, me, players }: BoardProps) { + return ( + <> + +
    + 할리갈리{v.phase === 'ended' ? ' · 게임 끝' : ''} + {rulesLine(v.options)} +
      + {v.seats.map((s) => ( +
    1. + + + {s.isTurn ? '▶ ' : ''} + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''} + + + ✓{s.correct} ✗{s.wrong} + + + {s.cards}장 + +
    2. + ))} +
    + {(v.pot > 0 || v.removed > 0) && ( + + {v.pot > 0 ? `종 밑 ${v.pot}장` : ''} + {v.pot > 0 && v.removed > 0 ? ' · ' : ''} + {v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''} + + )} +
    + {v.result && ( +
    + 최종 순위 + {v.result.ranking.map((r) => ( + + {r.rank}등 {nick(players, r.id)} + + {' '} + · {r.cards}장 · 정답 {r.correct} · 오답 {r.wrong} + + + ))} + {v.result.summary} +
    + )} + {v.log.length > 0 && ( +
    + 기록 +
      + {[...v.log].reverse().map((e) => ( +
    1. + {logText(e, players, me)} +
    2. + ))} +
    +
    + )} + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: HalliGalliView, me: string | null): boolean { + return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing'); +} + +function statusText(v: HalliGalliView, me: string | null, players: Record): string { + if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요'; + if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요'; + const mine = v.seats.find((s) => s.id === me); + if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요'; + if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요'; + return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`; +} + +// ---------------------------------------------------------------- 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, '끄기'], +]; + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
    + + set({ endMode })} + /> + + + set({ timeLimitMin })} + /> + + + set({ flipSeconds })} + /> + + + set({ kidMode })} /> + + + {more && ( + <> + + set({ autoFlip })} /> + + + set({ flipGuardMs })} + /> + + + set({ wrongRingLockoutMs })} + /> + + + set({ leftoverCards })} + /> + + + set({ latencyComp })} + /> + + + set({ showRtt })} /> + + + set({ duelWrongLoses })} /> + + + set({ strikeOut })} /> + + + set({ penaltyEscalation })} /> + + + set({ wrongToPot })} /> + + + )} +
    + ); +} + +function optionsSummary(o: HalliGalliOptions): string { + return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : ''); +} + +// ---------------------------------------------------------------- rules + +function Rules({ options }: { options?: HalliGalliOptions }) { + const o = options; + return ( +
    +

    + 과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 한 종류가 딱 5개가 되면 가운데 종을 쳐요. +

    +
    + + + + + + + + + + + + + + = 딸기 5개 → 땡! +
    +

    + 먼저 친 사람(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요 + {o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요. +

    +

    + 차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '} + {o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '} + {o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`} +

    +

    + PC에서는 스페이스 바로 종을, Enter로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요. +

    +
    + ); +} + +const ui: GameUI = { + Board: HalliGalliBoard, + Side: HalliGalliSide, + 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 008384c..bb36dbc 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -17,6 +17,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { mahjong: () => import('./mahjong'), gostop: () => import('./gostop'), bang: () => import('./bang'), + 'halli-galli': () => import('./halli-galli'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 984c7af..ac9ecc6 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -22,3 +22,8 @@ export const UPCOMING = ['뱅', '할리갈리']; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ export const UPCOMING = ['마작', '고스톱', '할리갈리']; + 'halli-galli': '🔔', +}; + +/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ +export const UPCOMING = ['마작', '고스톱', '뱅']; diff --git a/apps/web/src/lib/feedback.ts b/apps/web/src/lib/feedback.ts index b87be37..13e09eb 100644 --- a/apps/web/src/lib/feedback.ts +++ b/apps/web/src/lib/feedback.ts @@ -38,6 +38,13 @@ export const sounds = { warn: () => tone(520, 90, 'square', 0.06), win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)), lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)), + /** Bell "땡!" (할리갈리 correct bell). */ + ding: () => { + tone(1568, 700, 'sine', 0.22); + tone(2350, 450, 'sine', 0.08); + }, + /** Buzzer "삐!" (wrong answer). */ + buzz: () => tone(180, 260, 'square', 0.08), }; export function haptic(pattern: number | number[]): void { diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 78f552e..1e94189 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -254,6 +254,101 @@ const PLANS: Record = { await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') }); if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`); console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`); +/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */ +async function halliTotals(p: Page): Promise> { + const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? '')); + const t: Record = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 }; + for (const l of labels) { + const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l); + if (m) t[m[1]!]! += Number(m[2]); + } + return t; +} + +const PLANS: Record = { + 'halli-galli': { + // PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player + // rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI. + players: 3, + lobby: async (host) => { + await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/뒤집기 5초/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page, Page]; + const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); + const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + await host.locator('.hg-seat').first().waitFor(); + // 3-second countdown first. + await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 }); + let flips = 0; + let rights = 0; + let wrongs = 0; + let lates = 0; + let shots = 0; + let ringer = 0; + const until = Date.now() + 150_000; + const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => ''); + while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) { + const totals = await halliTotals(host); + const five = Object.values(totals).includes(5); + const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0); + if (five || wantWrong) { + const p = pages[ringer++ % pages.length]!; + const bell = p.locator('.hg-bell'); + if (await bell.isEnabled().catch(() => false)) { + if (p === host) { + await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await host.keyboard.press('Space'); + } else await bell.tap(); + const text = await bubble(p); + if (text.includes('땡')) rights++; + else if (text.includes('삐')) wrongs++; + else if (text.includes('늦었어요')) lates++; + if (shots === 0 && text) { + shots++; + await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') }); + } + await host.waitForTimeout(400); + continue; + } + } + let flipped = false; + for (const p of pages) { + if (!(await myTurn(p))) continue; + const btn = p.getByRole('button', { name: '뒤집기', exact: true }); + if (!(await btn.isEnabled().catch(() => false))) break; + const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText(); + if (p === host) { + await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await host.keyboard.press('Enter'); + } else await btn.tap(); + await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {}); + flips++; + flipped = true; + break; + } + if (flips === 12 && flipped && shots < 2) { + shots = 2; + await host.waitForTimeout(300); + await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await guest.waitForTimeout(400); // let the theme transition finish + await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') }); + await guest.emulateMedia({ colorScheme: 'light' }); + await host.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + } + await host.waitForTimeout(flipped ? 650 : 120); + } + console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`); + if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells'); + if (rights < 1 && !(await ended())) throw new Error('no correct bell happened'); }, }, rummikub: { diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index db74f2e..4e8b37a 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -57,6 +57,11 @@ export interface GameDefinition { deadline?(state: S): number | null; /** Players the deadline applies to (default: activePlayers). */ timeoutPlayers?(state: S): PlayerId[]; + /** + * Earliest time any timeout may fire, even for away players (whose deadline the room otherwise + * pulls forward to the end of the grace period). Real-time games use it to keep a minimum pace. + */ + timeoutNotBefore?(state: S): number | null; onTimeout(state: S, player: PlayerId): A; onLeave?(state: S, player: PlayerId): A | null; result(state: S): GameResult | null; diff --git a/packages/games/src/halli-galli/halli-galli.test.ts b/packages/games/src/halli-galli/halli-galli.test.ts new file mode 100644 index 0000000..ca5aff4 --- /dev/null +++ b/packages/games/src/halli-galli/halli-galli.test.ts @@ -0,0 +1,707 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; +import { + AWAY_FLIP_MS, + CARDS, + COUNTDOWN_MS, + DECK_SIZE, + FRUITS, + RING_WINDOW_MS, + allCardIds, + cardOf, + fiveFruit, + halliGalli as hg, + type Fruit, + type HalliGalliAction, + type HalliGalliOptions, + type HalliGalliState, +} from './index'; + +// ---------------------------------------------------------------- helpers + +const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); +const T0 = 1_000_000; +const START = T0 + COUNTDOWN_MS; +const FRUIT_OF: Record = { S: 'strawberry', B: 'banana', L: 'lime', P: 'plum' }; + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], seed = 1): HalliGalliState { + const options = hg.optionsSchema.parse(opts); + return hg.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 }); +} + +function applyE(s: HalliGalliState, p: string, a: HalliGalliAction, now: number): { state: HalliGalliState; events: GameEvent[] } { + const v = hg.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return hg.apply(s, p, a, { rng: rng(), now }); +} +const act = (s: HalliGalliState, p: string, a: HalliGalliAction, now: number) => applyE(s, p, a, now).state; +const ringA = (s: HalliGalliState): HalliGalliAction => ({ type: 'ring', version: s.tableVersion }); + +type Spec = string | number; // "S2" = strawberry ×2, or a count of filler cards + +/** + * Seats in the given order, playing phase, `turn` first player. Decks and piles are listed bottom → top. + * Every card not placed goes to `removed`, so the 56-card invariant still holds. + */ +function rig( + players: string[], + r: { decks?: Record; faceUp?: Record; pot?: Spec[]; turn?: string; now?: number }, + opts: Partial = {}, +): HalliGalliState { + const s = make(opts, players); + const now = r.now ?? START; + const used = new Set(); + const take = (spec: string): number => { + const fruit = FRUIT_OF[spec[0]!]!; + const count = Number(spec.slice(1)); + const c = CARDS.find((x) => !used.has(x.id) && x.fruit === fruit && x.count === count); + if (!c) throw new Error(`no card left for ${spec}`); + used.add(c.id); + return c.id; + }; + const named = (list: Spec[] = []) => list.filter((x): x is string => typeof x === 'string').map(take); + // Named cards first so fillers never steal them. + const decks: Record = {}; + const piles: Record = {}; + const pre = new Map(); + for (const list of [...Object.values(r.decks ?? {}), ...Object.values(r.faceUp ?? {}), r.pot ?? []]) pre.set(list, named(list)); + // Fillers: plums ×1..2 never form a five with the rigged cards unless intended (tests keep piles explicit). + const filler = (): number => { + const c = CARDS.find((x) => !used.has(x.id)); + if (!c) throw new Error('out of cards'); + used.add(c.id); + return c.id; + }; + const build = (list: Spec[] = []): number[] => { + const ids = [...(pre.get(list) ?? [])]; + const out: number[] = []; + for (const x of list) { + if (typeof x === 'string') out.push(ids.shift()!); + else for (let i = 0; i < x; i++) out.push(filler()); + } + return out; + }; + for (const p of players) { + decks[p] = build(r.decks?.[p]); + piles[p] = build(r.faceUp?.[p]); + } + s.order = [...players]; + s.active = [...players]; + s.decks = decks; + s.faceUp = piles; + s.pot = build(r.pot); + s.removed = CARDS.map((c) => c.id).filter((id) => !used.has(id)); + s.phase = 'playing'; + s.turn = r.turn ?? players[0]!; + s.turnStartedAt = now; + s.turnDeadline = now + s.options.flipSeconds * 1000 * (s.options.kidMode ? 8 / s.options.flipSeconds : 1); + s.flipAllowedAt = now; + s.pendingFlip = null; + s.tableVersion = 5; + s.endsAt = s.options.timeLimitMin === null ? null : now + s.options.timeLimitMin * 60_000; + return s; +} + +const ids = (s: HalliGalliState, p: string) => s.decks[p]!; +const top = (s: HalliGalliState, p: string) => { + const pile = s.faceUp[p]!; + const c = pile.length ? cardOf(pile[pile.length - 1]!) : null; + return c ? `${c.fruit[0]!.toUpperCase()}${c.count}` : null; +}; + +function conserved(s: HalliGalliState): void { + const all = allCardIds(s); + if (all.length !== DECK_SIZE) throw new Error(`card count ${all.length}`); + if (new Set(all).size !== DECK_SIZE) throw new Error('duplicate card'); +} + +// ---------------------------------------------------------------- §3, §4 + +describe('덱과 분배 (§3, §4)', () => { + test('56장: 과일별 14장, 개수별 5/3/3/2/1장, 과일 개수 총합 33', () => { + expect(CARDS).toHaveLength(56); + expect(new Set(CARDS.map((c) => c.id)).size).toBe(56); + for (const f of FRUITS) { + const mine = CARDS.filter((c) => c.fruit === f); + expect(mine).toHaveLength(14); + expect([1, 2, 3, 4, 5].map((n) => mine.filter((c) => c.count === n).length)).toEqual([5, 3, 3, 2, 1]); + expect(mine.reduce((a, c) => a + c.count, 0)).toBe(33); + } + }); + + test.each([ + [2, 28, 0], + [3, 18, 2], + [4, 14, 0], + [5, 11, 1], + [6, 9, 2], + ])('%i명: 1인당 %i장, 남는 카드 %i장 (pot / remove)', (n, per, rest) => { + const players = Array.from({ length: n }, (_, i) => `P${i}`); + const s = make({}, players); + for (const p of players) expect(s.decks[p]).toHaveLength(per); + expect(s.pot).toHaveLength(rest); + expect(s.removed).toHaveLength(0); + conserved(s); + const r = make({ leftoverCards: 'remove' }, players); + expect(r.pot).toHaveLength(0); + expect(r.removed).toHaveLength(rest); + conserved(r); + }); + + test('좌석·첫 플레이어·셔플은 시드로 결정되고 3초 카운트다운 후 시작', () => { + const a = make({}, ['A', 'B', 'C', 'D'], 3); + const b = make({}, ['A', 'B', 'C', 'D'], 3); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const orders = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).order.join(''))); + expect(orders.size).toBeGreaterThan(5); + expect(a.phase).toBe('countdown'); + expect(hg.validate(a, a.turn, { type: 'flip' }).ok).toBe(false); + expect(hg.validate(a, a.turn, { type: 'ring', version: 0 }).ok).toBe(false); + expect(hg.deadline!(a)).toBe(START); + const early = act(a, a.turn, { type: 'tick' }, START - 1); + expect(early.phase).toBe('countdown'); + const s = act(a, a.turn, { type: 'tick' }, START); + expect(s.phase).toBe('playing'); + expect(s.turnDeadline).toBe(START + 3000); + expect(s.endsAt).toBe(START + 10 * 60_000); + }); +}); + +// ---------------------------------------------------------------- §5.2 judgment + +describe('종 판정 (§5.2)', () => { + const table = (piles: Record, players = ['A', 'B', 'C']) => rig(players, { decks: { A: [3], B: [3], C: [3] }, faceUp: piles }); + + test('딸기 2+3 → 정답, 딸기 2+3+1 → 오답(6개)', () => { + expect(fiveFruit(table({ A: ['S2'], B: ['S3'] }))).toBe('strawberry'); + expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['S1'] }))).toBeNull(); + expect(fiveFruit(table({ A: ['S4'] }))).toBeNull(); + }); + + test('다른 과일이 섞여도 한 과일이 정확히 5면 정답', () => { + expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['B4'] }))).toBe('strawberry'); + expect(fiveFruit(table({ A: ['L1'], B: ['P5'], C: ['L3'] }))).toBe('plum'); + expect(fiveFruit(table({ A: ['B5'] }))).toBe('banana'); + }); + + test('맨 위 카드만 센다(아래 카드 무시)', () => { + expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['L2'] }))).toBeNull(); + expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['B4'] }))).toBe('banana'); + }); + + test('탈락자의 공개 더미 맨 위도 합산', () => { + const s = table({ A: ['L2'], C: ['L3'] }); + s.active = ['A', 'B']; + s.eliminated = ['C']; + s.outReason = { C: 'empty' }; + expect(fiveFruit(s)).toBe('lime'); + const out = act(s, 'B', ringA(s), START + 100); + expect(out.decks.B).toHaveLength(3 + 2); + expect(out.faceUp.C).toEqual([]); + }); +}); + +// ---------------------------------------------------------------- collecting + +describe('정답 수거 (§5.2)', () => { + test('좌석 순으로 각 공개 더미를 아래부터, 종 밑 카드는 마지막 → 덱 맨 아래, 정답자가 다음 차례', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [2], C: [2] }, faceUp: { A: ['P1', 'B3'], B: ['S2'], C: ['L1', 'L4', 'B2'] }, pot: ['P2', 'P3'], turn: 'C' }); + const before = { A: [...s.faceUp.A!], B: [...s.faceUp.B!], C: [...s.faceUp.C!], pot: [...s.pot], deck: [...s.decks.B!] }; + const { state: n, events } = applyE(s, 'B', ringA(s), START + 500); + expect(n.decks.B).toEqual([...before.A, ...before.B, ...before.C, ...before.pot, ...before.deck]); + expect(n.pot).toEqual([]); + for (const p of ['A', 'B', 'C']) expect(n.faceUp[p]).toEqual([]); + expect(n.turn).toBe('B'); + expect(n.turnStartedAt).toBe(START + 500); + expect(n.flipAllowedAt).toBe(START + 500 + 600); + expect(n.tableVersion).toBe(s.tableVersion + 1); + expect(n.correctCount.B).toBe(1); + expect(n.lastBell).toMatchObject({ player: 'B', correct: true, fruit: 'banana', cards: 8 }); + expect(events).toContainEqual({ type: 'ring', player: 'B', correct: true, fruit: 'banana', cards: 8 }); + conserved(n); + }); + + test('동시 종: 같은 버전 정답 종 2개 → 먼저 도착한 사람만 수거, 두 번째는 "늦었어요" 무벌칙', () => { + // §5.5: A 바나나 3, B 딸기 2, C 바나나 2 → 바나나 5. + const s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['B3'], B: ['S2'], C: ['B2'] }, pot: [2], turn: 'A' }); + const v = s.tableVersion; + const afterB = act(s, 'B', { type: 'ring', version: v }, START + 1000); + expect(afterB.decks.B).toHaveLength(5 + 3 + 2); + const late = hg.validate(afterB, 'A', { type: 'ring', version: v }); + expect(late).toEqual({ ok: false, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }); + expect(afterB.decks.A).toHaveLength(5); + expect(afterB.wrongCount.A).toBe(0); + // A ring for a table that changed by a flip instead. + const flipped = act(afterB, 'B', { type: 'flip' }, START + 1000 + 600); + expect(hg.validate(flipped, 'A', { type: 'ring', version: v + 1 })).toEqual({ ok: false, reason: '늦었어요! 그 사이에 카드가 바뀌었어요.' }); + }); + + test('§5.5 예시: 이후 A가 바나나 4 상태에서 착각해 종 → 오답 → B와 C에게 1장씩', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['B4'] }, turn: 'B' }); + const n = act(s, 'A', ringA(s), START + 100); + expect(n.decks.A).toHaveLength(4); + expect(n.decks.B).toHaveLength(7); + expect(n.decks.C).toHaveLength(7); + expect(n.turn).toBe('B'); // turn unchanged + expect(n.tableVersion).toBe(s.tableVersion); // table unchanged + // the given cards go to the bottom of the receivers' decks (from the top of the giver's deck) + expect(n.decks.B![0]).toBe(s.decks.A![5]!); + expect(n.decks.C![0]).toBe(s.decks.A![4]!); + conserved(n); + }); +}); + +// ---------------------------------------------------------------- wrong bells and elimination + +describe('오답 벌칙과 탈락 (§5.2, §5.3)', () => { + test('4인에서 덱 2장인 사람이 오답 → 시계 방향 2명에게만 1장씩 주고 즉시 탈락', () => { + const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [2], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' }); + const n = act(s, 'B', ringA(s), START + 100); + expect(n.decks.B).toEqual([]); + expect(n.decks.C).toHaveLength(6); + expect(n.decks.D).toHaveLength(6); + expect(n.decks.A).toHaveLength(5); + expect(n.active).toEqual(['A', 'C', 'D']); + expect(n.eliminated).toEqual(['B']); + expect(n.outReason.B).toBe('penalty'); + conserved(n); + }); + + test('정확히 다 줘서 0장 → 차례가 올 때까지 참가, 차례가 오면 탈락', () => { + const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [3], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' }); + let n = act(s, 'B', ringA(s), START + 100); + expect(n.decks.B).toEqual([]); + expect(n.active).toContain('B'); + n = act(n, 'A', { type: 'flip' }, START + 1000); // A → B's turn with an empty deck + expect(n.active).not.toContain('B'); + expect(n.outReason.B).toBe('empty'); + expect(n.turn).toBe('C'); + }); + + test('덱 0장이어도 차례 전에 정답 종 → 카드를 얻고 계속 참가', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'], B: ['L3'] }, turn: 'A' }); + expect(hg.legalActions!(s, 'B').some((a) => a.type === 'ring')).toBe(true); + const n = act(s, 'B', ringA(s), START + 100); + expect(n.decks.B).toHaveLength(2); + expect(n.turn).toBe('B'); + expect(n.active).toContain('B'); + }); + + test('덱 0장인 사람의 오답 → 줄 카드가 없어 즉시 탈락', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'] }, turn: 'A' }); + const n = act(s, 'B', ringA(s), START + 100); + expect(n.active).toEqual(['A', 'C']); + expect(n.outReason.B).toBe('penalty'); + }); + + test('차례인 사람이 오답으로 덱이 0장이 되면 탈락하고 다음 사람 차례', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [4], C: [4] }, faceUp: { B: ['L2'] }, turn: 'A' }); + const n = act(s, 'A', ringA(s), START + 100); + expect(n.active).toEqual(['B', 'C']); + expect(n.turn).toBe('B'); + }); + + test('한 명만 남으면 lastStanding 종료: 순위는 탈락 역순, 점수 3·2·1', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [1], B: [5], C: [] }, faceUp: { A: ['S1'] }, turn: 'A' }); + let n = act(s, 'A', { type: 'flip' }, START + 100); // A: 0 cards, B's turn + expect(n.turn).toBe('B'); + n = act(n, 'C', ringA(n), START + 200); // C wrong with 0 cards → out + n = act(n, 'A', ringA(n), START + 300); // A wrong with 0 cards → out → B wins + expect(n.phase).toBe('ended'); + const r = hg.result(n)!; + expect(r.ranking).toEqual([['B'], ['A'], ['C']]); + expect(r.scores).toEqual({ B: 3, A: 2, C: 1 }); + expect(n.final!.endReason).toBe('lastStanding'); + expect(r.summary).toContain('이겼어요'); + }); + + test('틀린 종 잠금: 잠금 중 종은 무시(벌칙 없음), 잠금이 끝나면 다시 칠 수 있어요', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['S1'] }, turn: 'A' }); + let n = act(s, 'B', ringA(s), START + 100); + expect(n.lockedUntil.B).toBe(START + 1100); + const { state: locked, events } = applyE(n, 'B', ringA(n), START + 600); + expect(events).toEqual([{ type: 'locked', player: 'B' }]); + expect(locked.decks.B).toHaveLength(4); + n = act(locked, 'B', ringA(locked), START + 1100); + expect(n.decks.B).toHaveLength(2); + }); +}); + +// ---------------------------------------------------------------- flipping and timers + +describe('뒤집기와 시간 (§5.1, §8.5)', () => { + test('flipGuardMs: 가드 중 뒤집기는 예약 → 가드 끝에 실행, 테이블 버전은 1번만 증가', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, turn: 'A' }); + let n = act(s, 'A', { type: 'flip' }, START + 100); + expect(n.tableVersion).toBe(s.tableVersion + 1); + expect(n.turn).toBe('B'); + expect(n.flipAllowedAt).toBe(START + 700); + const { state: q, events } = applyE(n, 'B', { type: 'flip' }, START + 300); + expect(events).toEqual([{ type: 'flipQueued', player: 'B' }]); + expect(q.tableVersion).toBe(n.tableVersion); + expect(q.pendingFlip).toBe('B'); + expect(hg.validate(q, 'B', { type: 'flip' }).ok).toBe(false); // no double queue + expect(hg.deadline!(q)).toBe(START + 700); + // A bell for the table everyone still sees is judged on that table. + expect(hg.validate(q, 'C', ringA(q)).ok).toBe(true); + n = act(q, 'B', hg.onTimeout(q, 'B'), START + 700); + expect(n.tableVersion).toBe(q.tableVersion + 1); + expect(n.faceUp.B).toHaveLength(1); + expect(n.turn).toBe('C'); + expect(n.pendingFlip).toBeNull(); + }); + + test('뒤집기 시간 초과 → 서버가 자동으로 뒤집기 (kidMode는 8초)', () => { + const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' }); + expect(hg.deadline!(s)).toBe(START + 3000); + expect(hg.timeoutPlayers!(s)).toEqual(['A']); + expect(hg.onTimeout(s, 'A')).toEqual({ type: 'tick' }); + const n = act(s, 'A', { type: 'tick' }, START + 3000); + expect(n.faceUp.A).toHaveLength(1); + expect(n.turn).toBe('B'); + expect(hg.deadline!(n)).toBe(START + 6000); + const kid = make({ kidMode: true }); + const k = act(kid, kid.turn, { type: 'tick' }, START); + expect(k.turnDeadline).toBe(START + 8000); + }); + + test('자리 비움(연결 끊김): timeoutNotBefore로 가드·1초는 지키며 계속 자동 진행', () => { + const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' }); + expect(hg.timeoutNotBefore!(s)).toBe(START + AWAY_FLIP_MS); + // Room decided A is away and fires at the not-before time: A's card is flipped. + let n = act(s, 'A', { type: 'tick' }, START + AWAY_FLIP_MS); + expect(n.faceUp.A).toHaveLength(1); + expect(hg.timeoutNotBefore!(n)).toBe(START + AWAY_FLIP_MS + AWAY_FLIP_MS); + n = act(n, 'B', { type: 'tick' }, START + 2 * AWAY_FLIP_MS); + expect(n.faceUp.B).toHaveLength(1); + // A queued flip only waits for the guard. + const q = act(n, 'A', { type: 'flip' }, START + 2 * AWAY_FLIP_MS + 10); + expect(hg.timeoutNotBefore!(q)).toBe(q.flipAllowedAt); + }); + + test('autoFlip: flip 액션은 거부, 서버가 flipSeconds 간격으로 뒤집기', () => { + const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' }, { autoFlip: true, flipSeconds: 2 }); + expect(hg.validate(s, 'A', { type: 'flip' })).toEqual({ ok: false, reason: '이 방은 카드가 자동으로 뒤집혀요.' }); + expect(hg.legalActions!(s, 'A').some((a) => a.type === 'flip')).toBe(false); + const n = act(s, 'A', { type: 'tick' }, START + 2000); + expect(n.faceUp.A).toHaveLength(1); + expect(n.turnDeadline).toBe(START + 4000); + }); + + test('시간 제한 만료: 보유 카드(덱 + 자기 공개 더미) 순, 동점은 정답 횟수, 그래도 같으면 공동', () => { + const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [3], B: [5], C: [4], D: [4] }, faceUp: { A: ['S1', 'S1'], C: ['P1'], D: ['P1'] }, turn: 'A' }, { timeLimitMin: 5 }); + s.correctCount = { A: 0, B: 0, C: 2, D: 2 }; + // A 5 (3 + 2), B 5, C 5, D 5 → A,B (0 correct) below C,D (2 correct) + expect(hg.deadline!(s)).toBe(Math.min(s.endsAt!, s.turnDeadline)); + const n = act(s, 'B', ringA(s), s.endsAt!); // any action at/after the end time ends the game first + expect(n.phase).toBe('ended'); + expect(n.final!.endReason).toBe('timeLimit'); + expect(n.decks.B).toHaveLength(5); // the bell was not judged + const r = hg.result(n)!; + expect(r.ranking).toEqual([['C', 'D'], ['A', 'B']]); + expect(n.final!.ranking.map((x) => [x.id, x.rank, x.cards])).toEqual([ + ['C', 1, 5], + ['D', 1, 5], + ['A', 3, 5], + ['B', 3, 5], + ]); + expect(r.summary).toContain('시간이 다 됐어요'); + }); +}); + +// ---------------------------------------------------------------- endings and house rules + +describe('official 종료와 하우스 룰 (§6, §7)', () => { + test('official 3인 → 2인 남은 뒤 첫 정답 종에서 종료', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [1, 'L1'], B: [], C: [2, 'S2'] }, faceUp: { A: ['S1'], B: ['S3'] }, turn: 'A' }, { endMode: 'official' }); + let n = act(s, 'A', { type: 'flip' }, START + 100); // A: L1 → B's turn with 0 cards → out → 2 left + expect(n.active).toEqual(['A', 'C']); + expect(n.officialDuel).toBe(true); + expect(n.turn).toBe('C'); + n = act(n, 'C', { type: 'flip' }, START + 800); // C: S2 + B's S3 = strawberry 5 + expect(n.phase).toBe('playing'); + expect(fiveFruit(n)).toBe('strawberry'); + const end = act(n, 'A', ringA(n), START + 900); + expect(end.phase).toBe('ended'); + expect(end.final!.endReason).toBe('official'); + expect(end.final!.ranking.map((r) => [r.id, r.rank, r.cards])).toEqual([ + ['A', 1, 5], + ['C', 2, 2], + ['B', 3, 0], + ]); + expect(hg.result(end)!.scores).toEqual({ A: 3, C: 2, B: 1 }); + }); + + test('official 2인 상태 오답 → 상대가 펼쳐진 카드를 모두 가져가고 종료', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [4], C: [] }, faceUp: { A: ['L1'], C: ['P2'] }, pot: [1], turn: 'A' }, { endMode: 'official' }); + s.active = ['A', 'B']; + s.eliminated = ['C']; + s.outReason = { C: 'empty' }; + s.officialDuel = true; + const n = act(s, 'A', ringA(s), START + 100); + expect(n.phase).toBe('ended'); + expect(n.final!.endReason).toBe('official'); + expect(n.decks.B).toHaveLength(4 + 3); + expect(n.faceUp.A).toEqual([]); + expect(hg.result(n)!.ranking).toEqual([['B'], ['A'], ['C']]); + }); + + test('처음부터 2인이면 official도 lastStanding처럼 진행(오답으로 끝나지 않음)', () => { + const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { endMode: 'official' }); + const n = act(s, 'A', ringA(s), START + 100); + expect(n.phase).toBe('playing'); + expect(n.officialDuel).toBe(false); + expect(n.decks.B).toHaveLength(5); + }); + + test('duelWrongLoses: 1대1 오답은 즉시 패배', () => { + const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { duelWrongLoses: true }); + const n = act(s, 'B', ringA(s), START + 100); + expect(n.phase).toBe('ended'); + expect(hg.result(n)!.ranking).toEqual([['A'], ['B']]); + expect(n.outReason.B).toBe('duel'); + conserved(n); + }); + + test('strikeOut: 오답 3번이면 탈락(남은 덱은 종 밑으로)', () => { + let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { strikeOut: true, wrongRingLockoutMs: 0 }); + s = act(s, 'B', ringA(s), START + 1); + s = act(s, 'B', ringA(s), START + 2); + expect(s.active).toContain('B'); + s = act(s, 'B', ringA(s), START + 3); + expect(s.active).not.toContain('B'); + expect(s.outReason.B).toBe('strikeOut'); + expect(s.pot).toHaveLength(4); + conserved(s); + }); + + test('penaltyEscalation: 두 번째 오답은 1인당 2장', () => { + let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { penaltyEscalation: true, wrongRingLockoutMs: 0 }); + s = act(s, 'B', ringA(s), START + 1); + expect(s.decks.B).toHaveLength(8); + s = act(s, 'B', ringA(s), START + 2); + expect(s.decks.B).toHaveLength(4); + expect(s.decks.C).toHaveLength(13); + expect(s.decks.A).toHaveLength(13); + }); + + test('wrongToPot: 벌칙 카드는 종 밑으로, 다음 정답자가 가져감', () => { + let s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['L1'] }, turn: 'A' }, { wrongToPot: true }); + s = act(s, 'B', ringA(s), START + 1); + expect(s.pot).toHaveLength(2); + expect(s.decks.A).toHaveLength(5); + s.faceUp.C = [CARDS.find((c) => c.fruit === 'lime' && c.count === 4 && s.removed.includes(c.id))!.id]; + s.removed = s.removed.filter((id) => id !== s.faceUp.C![0]); + s = act(s, 'C', ringA(s), START + 2); + expect(s.decks.C).toHaveLength(5 + 2 + 2); + }); + + test('latencyComp light: 50ms 창 안의 정답 종을 RTT/2(최대 80ms) 보정 시각으로 비교', () => { + let s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, faceUp: { A: ['B5'] }, turn: 'A' }, { latencyComp: 'light' }); + s = act(s, 'B', { type: 'ring', version: s.tableVersion, rtt: 20 }, START + 100); // adj 90 + expect(s.ringWindow).not.toBeNull(); + expect(s.tableVersion).toBe(5); + expect(hg.deadline!(s)).toBe(START + 100 + RING_WINDOW_MS); + expect(hg.validate(s, 'B', ringA(s)).ok).toBe(false); // already rang + // A flip during the window is queued, not applied. + s = act(s, 'A', { type: 'flip' }, START + 110); + expect(s.pendingFlip).toBe('A'); + s = act(s, 'C', { type: 'ring', version: s.tableVersion, rtt: 400 }, START + 130); // adj 130 - 80 = 50 → wins + const { state: n, events } = applyE(s, 'A', { type: 'tick' }, START + 150); + expect(n.ringWindow).toBeNull(); + expect(n.decks.C).toHaveLength(4); + expect(n.turn).toBe('C'); + expect(n.pendingFlip).toBeNull(); + expect(events).toContainEqual({ type: 'late', player: 'B' }); + }); +}); + +// ---------------------------------------------------------------- leaving, views, determinism + +describe('나가기, 비공개 정보, 결정성 (§8.3, §10)', () => { + test('나가면 덱은 종 밑으로, 공개 더미는 테이블에 남고 차례가 넘어가요', () => { + const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [4], C: [3] }, faceUp: { A: ['S2'] }, turn: 'A' }); + expect(hg.onLeave!(s, 'A')).toEqual({ type: 'leave' }); + const n = act(s, 'A', { type: 'leave' }, START + 10); + expect(n.pot).toHaveLength(3); + expect(n.faceUp.A).toHaveLength(1); + expect(n.outReason.A).toBe('left'); + expect(n.turn).toBe('B'); + expect(hg.onLeave!(n, 'A')).toBeNull(); + const end = act(n, 'B', { type: 'leave' }, START + 20); + expect(end.phase).toBe('ended'); + expect(hg.result(end)!.ranking).toEqual([['C'], ['B'], ['A']]); + expect(hg.result(end)!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요'); + }); + + test('view에는 덱 내용(본인 포함)·종 밑·가려진 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => { + const r = new SeededRng([1, 2, 3, 4]); + for (let seed = 1; seed <= 30; seed++) { + let s = make({}, ['A', 'B', 'C', 'D', 'E'], seed); + s = act(s, s.turn, { type: 'tick' }, START); + for (let i = 0; i < 12 && s.phase === 'playing'; i++) s = act(s, s.turn, { type: 'flip' }, START + 1000 * (i + 1)); + const shuffled = structuredClone(s); + for (const p of s.order) { + shuffled.decks[p] = r.shuffle(s.decks[p]!); + const pile = s.faceUp[p]!; + shuffled.faceUp[p] = [...r.shuffle(pile.slice(0, -1)), ...pile.slice(-1)]; + } + shuffled.pot = r.shuffle(s.pot); + // Swap hidden cards between players too (same counts). + const a = shuffled.decks[s.order[0]!]!; + const b = shuffled.decks[s.order[1]!]!; + if (a.length && b.length) [a[0], b[0]] = [b[0]!, a[0]!]; + for (const viewer of [null, ...s.order]) expect(JSON.stringify(hg.view(shuffled, viewer))).toBe(JSON.stringify(hg.view(s, viewer))); + for (const viewer of s.order) checkNoLeak(hg, s, viewer, s.order); + } + }); + + test('이벤트에는 공개 카드(방금 뒤집은 카드)만 담긴다', () => { + let s = make({}, ['A', 'B', 'C'], 5); + s = act(s, s.turn, { type: 'tick' }, START); + const p = s.turn; + const { state: n, events } = applyE(s, p, { type: 'flip' }, START + 100); + const c = cardOf(n.faceUp[p]!.at(-1)!); + expect(events).toEqual([{ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: n.tableVersion }]); + }); + + test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => { + const a = fastPlayout(['A', 'B', 'C', 'D'], hg.optionsSchema.parse({}), seedN(42), 5000); + const replay = (log: { p: PlayerId; a: HalliGalliAction; now: number }[]) => { + let s = hg.setup({ players: ['A', 'B', 'C', 'D'], options: hg.optionsSchema.parse({}), rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 }); + for (const x of log) s = hg.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: HalliGalliAction; now: number }[]; + final: HalliGalliState; +} + +function invariants(s: HalliGalliState): void { + conserved(s); + const all = [...s.active, ...s.eliminated].sort(); + if (JSON.stringify(all) !== JSON.stringify([...s.order].sort())) throw new Error('active + eliminated != players'); + if (s.phase !== 'ended') { + if (!s.active.includes(s.turn)) throw new Error(`turn ${s.turn} is not active`); + if (s.phase === 'playing' && s.decks[s.turn]!.length === 0) throw new Error(`turn ${s.turn} has no cards`); + if (s.active.length < 2) throw new Error('game should have ended'); + } +} + +/** + * Same idea as `randomPlayout`, with time moving 0.1–1.6s per step and bells biased towards real fives + * (so collections, guards, lockouts and late bells all happen). Checks invariants and onTimeout legality every step. + */ +function fastPlayout(players: PlayerId[], options: HalliGalliOptions, seed: RngState, maxSteps: number): FastReport { + const r = SeededRng.fromSeed(seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let now = 1_700_000_000_000; + let s = hg.setup({ players, options, rng: r, now }); + const log: FastReport['log'] = []; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + if (step % 25 === 0) for (const p of [players[step % players.length]!]) checkNoLeak(hg, s, p, players); + for (const p of hg.timeoutPlayers!(s)) { + const v = hg.validate(s, p, hg.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`); + } + if (hg.result(s)) return { steps: step, finished: true, log, final: s }; + now += 100 + chooser.int(1500); + let actor: PlayerId; + let action: HalliGalliAction; + const five = s.phase === 'playing' && fiveFruit(s) !== null; + const roll = chooser.int(10); + if (five && roll < 7) { + actor = s.active[chooser.int(s.active.length)]!; + action = { type: 'ring', version: s.tableVersion, rtt: chooser.int(300) }; + // A stale bell sometimes (rejected by validate, as the server would). + if (roll === 0 && s.tableVersion > 0) action = { type: 'ring', version: s.tableVersion - 1 }; + } else if (roll < 8) { + actor = s.turn; + action = hg.onTimeout(s, actor); + } else { + const actives = hg.activePlayers(s); + actor = actives[chooser.int(actives.length)]!; + const legal = hg.legalActions!(s, actor); + action = legal.length > 0 ? legal[chooser.int(legal.length)]! : hg.onTimeout(s, actor); + } + if (chooser.int(400) === 0 && s.active.length > 2) { + actor = s.active[chooser.int(s.active.length)]!; + action = { type: 'leave' }; + } + if (!hg.validate(s, actor, action).ok) { + if (action.type !== 'ring') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${hg.validate(s, actor, action).ok}`); + continue; + } + const out = hg.apply(s, actor, action, { rng: r, now }); + for (const e of out.events) if (JSON.stringify(e).includes('"hg')) throw new Error('event carries a card key'); + s = out.state; + log.push({ p: actor, a: action, now }); + } + return { steps: maxSteps, finished: false, log, final: s }; +} + +const PRESETS: Partial[] = [ + {}, + { timeLimitMin: null }, + { endMode: 'official', flipGuardMs: 1000, wrongRingLockoutMs: 2000 }, + { latencyComp: 'light', leftoverCards: 'remove', flipSeconds: 2 }, + { penaltyEscalation: true, strikeOut: true, timeLimitMin: 5 }, + { wrongToPot: true, autoFlip: true, flipGuardMs: 400, wrongRingLockoutMs: 0 }, + { duelWrongLoses: true, kidMode: true, timeLimitMin: 15 }, +]; + +describe('random playouts (§10, docs/09 §7)', () => { + test('1,000 seeded games: finish, 56 cards conserved, no leaks, onTimeout always legal, deterministic', () => { + let finished = 0; + let collects = 0; + let wrongs = 0; + for (let i = 1; i <= 1000; i++) { + const n = 2 + (i % 5); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = hg.optionsSchema.parse(PRESETS[i % PRESETS.length]!); + const a = fastPlayout(players, options, seedN(i), 20_000); + if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`); + finished++; + const res = hg.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + collects += Object.values(a.final.correctCount).reduce((x, y) => x + y, 0); + wrongs += Object.values(a.final.wrongCount).reduce((x, y) => x + y, 0); + if (i % 50 === 0) { + const b = fastPlayout(players, options, seedN(i), 20_000); + expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log)); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(finished).toBe(1000); + expect(collects).toBeGreaterThan(1000); + expect(wrongs).toBeGreaterThan(1000); + }, 60_000); + + test.each([ + [2, {}], + [3, { endMode: 'official' }], + [4, { latencyComp: 'light' }], + [6, { timeLimitMin: null, wrongToPot: 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 = hg.optionsSchema.parse(opts); + const conserve = (st: unknown) => invariants(st as HalliGalliState); + for (let i = 1; i <= 5; i++) { + const a = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 20 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, maxApplyMs: 20 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }); +}); diff --git a/packages/games/src/halli-galli/index.ts b/packages/games/src/halli-galli/index.ts new file mode 100644 index 0000000..b3579ff --- /dev/null +++ b/packages/games/src/halli-galli/index.ts @@ -0,0 +1,609 @@ +/** 할리갈리 (docs/games/halli-galli.md). Pure and deterministic; state is JSON; time only via ctx.now. */ +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; +import { + CARDS, + DECK_SIZE, + FRUITS, + cardOf, + halliGalliActionSchema, + halliGalliOptionsSchema, + type EndReason, + type Fruit, + type HalliGalliAction, + type HalliGalliFinal, + type HalliGalliOptions, + type HalliGalliState, + type HalliGalliView, + type LogEntry, + type OutReason, + type RankRow, +} from './types'; + +export * from './types'; + +type S = HalliGalliState; +type A = HalliGalliAction; + +export const COUNTDOWN_MS = 3000; +/** `latencyComp: 'light'` bell window (§5.4). */ +export const RING_WINDOW_MS = 50; +/** Max credit `latencyComp: 'light'` gives for half the RTT (§5.4). */ +export const MAX_LATENCY_CREDIT_MS = 80; +/** Away players (room grace period over) flip after this much of their turn (§8.5 "1초로 줄여"). */ +export const AWAY_FLIP_MS = 1000; +const LOG_KEEP = 30; +const POINTS = [3, 2, 1]; + +export const flipMs = (o: HalliGalliOptions): number => (o.kidMode ? 8 : o.flipSeconds) * 1000; +/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */ +export const cardKey = (id: number): string => `hg${id}`; + +// ---------------------------------------------------------------- table math + +/** Sum of fruits over every face-up pile's top card (eliminated players' piles included, §5.2). */ +export function fruitTotals(s: Pick): Record { + const t: Record = { strawberry: 0, banana: 0, lime: 0, plum: 0 }; + for (const p of s.order) { + const pile = s.faceUp[p]!; + if (pile.length === 0) continue; + const c = cardOf(pile[pile.length - 1]!); + t[c.fruit] += c.count; + } + return t; +} + +/** A fruit totalling exactly five, if any (6+ and 4- do not count). */ +export function fiveFruit(s: Pick): Fruit | null { + const t = fruitTotals(s); + for (const f of FRUITS) if (t[f] === 5) return f; + return null; +} + +export const cardsHeld = (s: S, p: PlayerId): number => (s.decks[p]?.length ?? 0) + (s.faceUp[p]?.length ?? 0); + +/** All 56 cards, wherever they are (test invariant). */ +export function allCardIds(s: S): number[] { + return [...s.order.flatMap((p) => [...s.decks[p]!, ...s.faceUp[p]!]), ...s.pot, ...s.removed]; +} + +// ---------------------------------------------------------------- helpers + +function clone(s: S): S { + return { + ...s, + order: [...s.order], + decks: Object.fromEntries(Object.entries(s.decks).map(([k, v]) => [k, [...v]])), + faceUp: Object.fromEntries(Object.entries(s.faceUp).map(([k, v]) => [k, [...v]])), + pot: [...s.pot], + removed: [...s.removed], + active: [...s.active], + eliminated: [...s.eliminated], + outReason: { ...s.outReason }, + lockedUntil: { ...s.lockedUntil }, + wrongCount: { ...s.wrongCount }, + correctCount: { ...s.correctCount }, + ringWindow: s.ringWindow ? { ...s.ringWindow, rings: s.ringWindow.rings.map((r) => ({ ...r })) } : null, + lastBell: s.lastBell ? { ...s.lastBell } : null, + log: [...s.log], + final: s.final, + }; +} + +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); +} + +/** Other active players clockwise, starting after `p`. */ +function othersClockwise(s: S, p: PlayerId): PlayerId[] { + const i = s.order.indexOf(p); + const out: PlayerId[] = []; + for (let k = 1; k < s.order.length; k++) { + const q = s.order[(i + k) % s.order.length]!; + if (s.active.includes(q)) out.push(q); + } + return out; +} + +function startTurn(s: S, p: PlayerId, now: number): void { + s.turn = p; + s.turnStartedAt = now; + s.turnDeadline = now + flipMs(s.options); + s.pendingFlip = null; +} + +/** Removes `p` from play; returns true if the game ended. */ +function eliminate(s: S, p: PlayerId, why: OutReason, ctx: Ctx): boolean { + if (!s.active.includes(p)) return s.phase === 'ended'; + s.active = s.active.filter((q) => q !== p); + s.eliminated.push(p); + s.outReason[p] = why; + if (s.ringWindow) s.ringWindow.rings = s.ringWindow.rings.filter((r) => r.player !== p); + ctx.events.push({ type: 'out', player: p, why }); + log(s, { t: 'out', p, why }); + if (s.active.length <= 1) { + finish(s, 'lastStanding', ctx); + return true; + } + if (s.options.endMode === 'official' && s.order.length >= 3 && s.active.length === 2) s.officialDuel = true; + return false; +} + +/** Passes the turn clockwise from `from`; players whose turn comes with an empty deck are out (§5.3). */ +function advanceTurn(s: S, from: PlayerId, ctx: Ctx): void { + const n = s.order.length; + const i = s.order.indexOf(from); + for (let k = 1; k <= n; k++) { + const q = s.order[(i + k) % n]!; + if (!s.active.includes(q)) continue; + if (s.decks[q]!.length > 0) { + startTurn(s, q, ctx.now); + return; + } + if (eliminate(s, q, 'empty', ctx)) return; + } + // Unreachable while ≥ 2 players are active (someone holds cards); keep the game well-formed anyway. + finish(s, 'lastStanding', ctx); +} + +function doFlip(s: S, p: PlayerId, ctx: Ctx): void { + const id = s.decks[p]!.pop(); + if (id === undefined) throw new Error(`halli-galli: ${p} has no card to flip`); + s.faceUp[p]!.push(id); + s.tableVersion += 1; + s.lastChange = 'flip'; + s.lastFlipBy = p; + s.pendingFlip = null; + s.flipAllowedAt = ctx.now + s.options.flipGuardMs; + const c = cardOf(id); + ctx.events.push({ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: s.tableVersion }); + advanceTurn(s, p, ctx); +} + +/** Queues or performs the turn player's flip (guard / bell window aware). */ +function flipOrQueue(s: S, p: PlayerId, ctx: Ctx): void { + if (s.ringWindow || ctx.now < s.flipAllowedAt) { + if (s.pendingFlip !== p) { + s.pendingFlip = p; + ctx.events.push({ type: 'flipQueued', player: p }); + } + return; + } + doFlip(s, p, ctx); +} + +/** Takes every face-up pile (seat order, bottom first) and then the pot to the bottom of `p`'s deck. */ +function sweepTable(s: S, p: PlayerId): number { + const taken: number[] = []; + for (const q of s.order) { + taken.push(...s.faceUp[q]!); + s.faceUp[q] = []; + } + taken.push(...s.pot); + s.pot = []; + s.decks[p] = [...taken, ...s.decks[p]!]; + s.tableVersion += 1; + s.lastChange = 'collect'; + return taken.length; +} + +function collect(s: S, p: PlayerId, fruit: Fruit, ctx: Ctx): void { + const cards = sweepTable(s, p); + s.correctCount[p] = (s.correctCount[p] ?? 0) + 1; + s.bellSeq += 1; + s.lastBell = { seq: s.bellSeq, player: p, correct: true, fruit, cards, at: ctx.now }; + s.ringWindow = null; + ctx.events.push({ type: 'ring', player: p, correct: true, fruit, cards }); + log(s, { t: 'collect', p, fruit, cards }); + if (s.officialDuel) { + finish(s, 'official', ctx); + return; + } + // The winner flips next; the table must stay still for the guard time first. + startTurn(s, p, ctx.now); + s.flipAllowedAt = ctx.now + s.options.flipGuardMs; +} + +function wrongBell(s: S, p: PlayerId, ctx: Ctx): void { + const o = s.options; + s.wrongCount[p] = (s.wrongCount[p] ?? 0) + 1; + s.lockedUntil[p] = ctx.now + o.wrongRingLockoutMs; + s.bellSeq += 1; + const bell = { seq: s.bellSeq, player: p, correct: false, cards: 0, at: ctx.now }; + s.lastBell = bell; + + // Two left: official ending / 1-on-1 house rule (§6, §7). + if (s.active.length === 2 && (s.officialDuel || o.duelWrongLoses)) { + const opp = s.active.find((q) => q !== p)!; + let cards: number; + if (s.officialDuel) { + cards = sweepTable(s, opp); + } else { + cards = s.decks[p]!.length; + s.decks[opp] = [...s.decks[p]!, ...s.decks[opp]!]; + s.decks[p] = []; + } + bell.cards = cards; + ctx.events.push({ type: 'ring', player: p, correct: false, gave: 0, duel: true, by: opp }); + log(s, { t: 'duelWrong', p, by: opp, cards }); + if (s.officialDuel) finish(s, 'official', ctx); + else eliminate(s, p, 'duel', ctx); + return; + } + + // Give one card (more with penaltyEscalation) to every other active player clockwise; short → out (§5.2). + const per = o.penaltyEscalation ? s.wrongCount[p]! : 1; + const to = othersClockwise(s, p); + let gave = 0; + let short = false; + outer: for (let r = 0; r < per; r++) { + for (const q of to) { + const id = s.decks[p]!.pop(); + if (id === undefined) { + short = true; + break outer; + } + if (o.wrongToPot) s.pot.push(id); + else s.decks[q]!.unshift(id); + gave++; + } + } + bell.cards = gave; + ctx.events.push({ type: 'ring', player: p, correct: false, gave, toPot: o.wrongToPot }); + log(s, { t: 'wrong', p, gave, toPot: o.wrongToPot }); + let out: OutReason | null = null; + if (short) out = 'penalty'; + else if (o.strikeOut && s.wrongCount[p]! >= 3) { + s.pot.push(...s.decks[p]!); + s.decks[p] = []; + out = 'strikeOut'; + } else if (s.decks[p]!.length === 0 && s.turn === p) out = 'empty'; // it is already their turn and nothing is left to flip + if (!out) return; + const wasTurn = s.turn === p; + if (eliminate(s, p, out, ctx)) return; + if (wasTurn) advanceTurn(s, p, ctx); +} + +/** Closes the `latencyComp` bell window: earliest adjusted time wins, ties by arrival (§5.4). */ +function resolveWindow(s: S, ctx: Ctx): void { + const w = s.ringWindow!; + s.ringWindow = null; + const fruit = fiveFruit(s); + if (!fruit || w.rings.length === 0) return; // everyone in the window left + let best = w.rings[0]!; + for (const r of w.rings) if (r.adjTs < best.adjTs) best = r; + for (const r of w.rings) if (r !== best) ctx.events.push({ type: 'late', player: r.player }); + collect(s, best.player, fruit, ctx); +} + +function ring(s: S, p: PlayerId, version: number, rtt: number | undefined, ctx: Ctx): void { + if (s.ringWindow && ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx); + if (s.phase !== 'playing') return; + if (version !== s.tableVersion) { + ctx.events.push({ type: 'late', player: p }); + return; + } + if (ctx.now < (s.lockedUntil[p] ?? 0)) { + ctx.events.push({ type: 'locked', player: p }); + return; + } + const fruit = fiveFruit(s); + if (!fruit) { + wrongBell(s, p, ctx); + return; + } + if (s.options.latencyComp === 'light') { + const adjTs = ctx.now - Math.min((rtt ?? 0) / 2, MAX_LATENCY_CREDIT_MS); + if (!s.ringWindow) s.ringWindow = { version, openedAt: ctx.now, rings: [] }; + if (!s.ringWindow.rings.some((r) => r.player === p)) s.ringWindow.rings.push({ player: p, adjTs }); + ctx.events.push({ type: 'ringWindow', player: p }); + return; + } + collect(s, p, fruit, ctx); +} + +/** + * Server timer. Runs whatever is due; for the turn player (whose clock the room drives) a tick that finds + * nothing else due means "time's up / away" → flip (queued behind the guard if needed). + */ +function tick(s: S, actor: PlayerId, ctx: Ctx): void { + if (s.phase === 'countdown') { + if (ctx.now < s.countdownEndsAt) return; + s.phase = 'playing'; + startTurn(s, s.turn, ctx.now); + s.flipAllowedAt = ctx.now; + ctx.events.push({ type: 'start' }); + return; + } + if (s.ringWindow) { + if (ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx); + return; + } + const due = (s.pendingFlip !== null && ctx.now >= s.flipAllowedAt) || ctx.now >= Math.max(s.turnDeadline, s.flipAllowedAt); + if (actor === s.turn || due) flipOrQueue(s, s.turn, ctx); +} + +function leave(s: S, p: PlayerId, ctx: Ctx): void { + // The leaver's face-down deck goes under the bell; their face-up pile stays on the table. + s.pot.push(...s.decks[p]!); + s.decks[p] = []; + const wasTurn = s.turn === p; + if (eliminate(s, p, 'left', ctx)) return; + if (wasTurn) advanceTurn(s, p, ctx); +} + +// ---------------------------------------------------------------- ending + +function finish(s: S, reason: EndReason, ctx: Ctx): void { + if (s.phase === 'ended') return; + s.phase = 'ended'; + s.pendingFlip = null; + s.ringWindow = null; + const row = (id: PlayerId, rank: number): RankRow => ({ rank, id, cards: cardsHeld(s, id), correct: s.correctCount[id] ?? 0, wrong: s.wrongCount[id] ?? 0 }); + const ranking: RankRow[] = []; + // Still-active players: by cards held, then correct bells; equal → shared rank (§6). + const alive = [...s.active].sort((a, b) => cardsHeld(s, b) - cardsHeld(s, a) || (s.correctCount[b] ?? 0) - (s.correctCount[a] ?? 0)); + alive.forEach((id, i) => { + const prev = ranking[i - 1]; + const tie = prev && cardsHeld(s, prev.id) === cardsHeld(s, id) && (s.correctCount[prev.id] ?? 0) === (s.correctCount[id] ?? 0); + ranking.push(row(id, tie ? prev.rank : i + 1)); + }); + // Eliminated players: last out ranks highest. + for (const id of [...s.eliminated].reverse()) ranking.push(row(id, ranking.length + 1)); + const winners = ranking.filter((r) => r.rank === 1).length; + let summary: string; + if (reason === 'lastStanding') { + const allLeft = s.eliminated.every((p) => s.outReason[p] === 'left'); + summary = allLeft ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '끝까지 남아 카드를 지킨 사람이 이겼어요'; + } else if (reason === 'official') { + summary = '두 사람이 남은 뒤 종으로 승부가 났어요. 카드가 가장 많은 사람이 이겼어요'; + } else { + summary = '시간이 다 됐어요. 카드가 가장 많은 사람이 이겼어요'; + } + if (winners > 1) summary += ` (공동 1등 ${winners}명)`; + s.final = { endReason: reason, ranking, summary } satisfies HalliGalliFinal; + ctx.events.push({ type: 'gameEnd', reason }); +} + +// ---------------------------------------------------------------- definition + +const LATE_COLLECT = '늦었어요! 다른 사람이 먼저 종을 쳤어요.'; +const LATE_FLIP = '늦었어요! 그 사이에 카드가 바뀌었어요.'; + +export const halliGalli: GameDefinition = { + id: 'halli-galli', + nameKo: '할리갈리', + minPlayers: 2, + maxPlayers: 6, + stateVersion: 1, + defaultOptions: halliGalliOptionsSchema.parse({}), + optionsSchema: halliGalliOptionsSchema as unknown as GameDefinition['optionsSchema'], + actionSchema: halliGalliActionSchema as unknown as GameDefinition['actionSchema'], + + setup({ players, options, rng, now }) { + if (players.length < 2 || players.length > 6) throw new Error(`halli-galli: ${players.length} players not allowed`); + // §4 / §8.4: seats, first player and the deck all come from the seeded RNG. + const order = rng.shuffle(players); + const first = order[rng.int(order.length)]!; + const deck = rng.shuffle(CARDS.map((c) => c.id)); + const per = Math.floor(DECK_SIZE / order.length); + const decks: Record = {}; + order.forEach((p, i) => (decks[p] = deck.slice(i * per, (i + 1) * per))); + const rest = deck.slice(order.length * per); + const per0 = (v: T) => Object.fromEntries(order.map((p) => [p, v])) as Record; + const countdownEndsAt = now + COUNTDOWN_MS; + return { + options, + order, + decks, + faceUp: Object.fromEntries(order.map((p) => [p, [] as number[]])), + pot: options.leftoverCards === 'pot' ? rest : [], + removed: options.leftoverCards === 'remove' ? rest : [], + active: [...order], + eliminated: [], + outReason: {}, + officialDuel: false, + turn: first, + turnStartedAt: now, + turnDeadline: countdownEndsAt + flipMs(options), + flipAllowedAt: countdownEndsAt, + pendingFlip: null, + tableVersion: 0, + lastChange: null, + lastFlipBy: null, + lockedUntil: per0(0), + wrongCount: per0(0), + correctCount: per0(0), + ringWindow: null, + phase: 'countdown', + countdownEndsAt, + startedAt: now, + endsAt: options.timeLimitMin === null ? null : countdownEndsAt + options.timeLimitMin * 60_000, + lastBell: null, + bellSeq: 0, + logNo: 0, + log: [], + final: null, + }; + }, + + validate(s, actor, a) { + if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'ended') return fail('게임이 이미 끝났어요.'); + switch (a.type) { + case 'tick': + return actor === s.turn || s.active.includes(actor) ? ok : fail('시간 초과 대상이 아니에요.'); + case 'leave': + return s.active.includes(actor) ? ok : fail('이미 게임에서 빠졌어요.'); + case 'flip': + if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.'); + if (s.options.autoFlip) return fail('이 방은 카드가 자동으로 뒤집혀요.'); + if (s.turn !== actor) return fail('지금은 내 차례가 아니에요.'); + if (s.pendingFlip === actor) return fail('곧 뒤집혀요. 잠시만 기다려 주세요.'); + if (s.decks[actor]!.length === 0) return fail('뒤집을 카드가 없어요.'); + return ok; + case 'ring': + if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.'); + if (!s.active.includes(actor)) return fail('게임에서 빠져서 종을 칠 수 없어요.'); + // §5.2 늦은 종: the client saw an older table → ignored without penalty. + if (a.version !== s.tableVersion) return fail(a.version < s.tableVersion && s.lastChange === 'collect' && a.version === s.tableVersion - 1 ? LATE_COLLECT : LATE_FLIP); + if (s.ringWindow?.rings.some((r) => r.player === actor)) return fail('이미 종을 쳤어요.'); + return ok; + } + }, + + apply(prev, actor, a, { now }) { + const s = clone(prev); + const ctx: Ctx = { now, events: [] }; + // §2 timeLimitMin: once time is up nothing else happens. + if (s.phase === 'playing' && s.endsAt !== null && now >= s.endsAt) { + finish(s, 'timeLimit', ctx); + return { state: s, events: ctx.events }; + } + switch (a.type) { + case 'tick': + tick(s, actor, ctx); + break; + case 'flip': + flipOrQueue(s, actor, ctx); + break; + case 'ring': + ring(s, actor, a.version, a.rtt, ctx); + break; + case 'leave': + leave(s, actor, ctx); + break; + } + return { state: s, events: ctx.events }; + }, + + view(s, viewer) { + const me = viewer && s.order.includes(viewer) ? viewer : null; + return { + options: s.options, + me, + phase: s.phase, + seats: s.order.map((p) => { + const pile = s.faceUp[p]!; + const topId = pile[pile.length - 1]; + const c = topId === undefined ? null : cardOf(topId); + const why = s.outReason[p]; + return { + id: p, + deck: s.decks[p]!.length, + pile: pile.length, + top: c ? { key: cardKey(c.id), fruit: c.fruit, count: c.count } : null, + cards: cardsHeld(s, p), + status: s.active.includes(p) ? 'playing' : why === 'left' ? 'left' : 'out', + isTurn: s.phase !== 'ended' && s.turn === p, + lockedUntil: s.lockedUntil[p] ?? 0, + correct: s.correctCount[p] ?? 0, + wrong: s.wrongCount[p] ?? 0, + }; + }), + turn: s.phase === 'ended' ? null : s.turn, + tableVersion: s.tableVersion, + pot: s.pot.length, + removed: s.removed.length, + countdownEndsAt: s.countdownEndsAt, + turnStartedAt: s.turnStartedAt, + turnDeadline: s.turnDeadline, + flipTotalMs: flipMs(s.options), + flipAllowedAt: s.flipAllowedAt, + pendingFlip: s.pendingFlip !== null, + endsAt: s.endsAt, + windowOpen: s.ringWindow !== null, + officialDuel: s.officialDuel, + lastFlipBy: s.lastFlipBy, + lastBell: s.lastBell, + log: s.log.slice(-12), + result: s.final, + }; + }, + + /** Everyone still in can ring at any time; the turn player also flips (§8.5). */ + activePlayers(s) { + if (s.phase === 'ended') return []; + if (s.phase === 'countdown') return [s.turn]; + return [...s.active]; + }, + + deadline(s) { + if (s.phase === 'ended') return null; + if (s.phase === 'countdown') return s.countdownEndsAt; + const c: number[] = []; + if (s.endsAt !== null) c.push(s.endsAt); + if (s.ringWindow) c.push(s.ringWindow.openedAt + RING_WINDOW_MS); + else { + if (s.pendingFlip) c.push(s.flipAllowedAt); + c.push(Math.max(s.turnDeadline, s.flipAllowedAt)); + } + return Math.min(...c); + }, + + /** Only the turn player's clock drives the server tick (flips, guard, bell window, time limit). */ + timeoutPlayers(s) { + return s.phase === 'ended' ? [] : [s.turn]; + }, + + /** + * Away players are timed out right after the room grace period; never earlier than the flip guard + * (or 1s into their turn), so an away player's flips keep a steady pace instead of a busy loop. + */ + timeoutNotBefore(s) { + if (s.phase === 'ended') return null; + if (s.phase === 'countdown') return s.countdownEndsAt; + if (s.ringWindow) return s.ringWindow.openedAt + RING_WINDOW_MS; + const flipAt = s.pendingFlip ? s.flipAllowedAt : Math.max(s.flipAllowedAt, s.turnStartedAt + AWAY_FLIP_MS); + return s.endsAt !== null ? Math.min(s.endsAt, flipAt) : flipAt; + }, + + onTimeout() { + return { type: 'tick' }; + }, + + onLeave(s, player) { + if (s.phase === 'ended' || !s.active.includes(player)) return null; + return { type: 'leave' }; + }, + + 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] = POINTS[r.rank - 1] ?? 0; + return { + ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids), + scores, + summary: f.summary, + reason: 'normal', + }; + }, + + legalActions(s, p) { + if (s.phase === 'ended' || !s.order.includes(p)) return []; + const out: A[] = []; + if (p === s.turn) out.push({ type: 'tick' }); + if (s.phase === 'playing' && s.active.includes(p)) { + out.push({ type: 'ring', version: s.tableVersion }); + if (p === s.turn) out.push({ type: 'flip' }); + } + return out.filter((a) => halliGalli.validate(s, p, a).ok); + }, + + /** Every face-down or covered card (nobody may see them, including the owner — §8.3), as view keys. */ + secretsOf(s, player) { + const pile = s.faceUp[player] ?? []; + return [...(s.decks[player] ?? []), ...pile.slice(0, -1), ...s.pot, ...s.removed].map(cardKey); + }, +}; diff --git a/packages/games/src/halli-galli/types.ts b/packages/games/src/halli-galli/types.ts new file mode 100644 index 0000000..2e79000 --- /dev/null +++ b/packages/games/src/halli-galli/types.ts @@ -0,0 +1,203 @@ +/** 할리갈리 options, cards, actions, state and view types (docs/games/halli-galli.md §2, §3, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; + +export const FRUITS = ['strawberry', 'banana', 'lime', 'plum'] as const; +export type Fruit = (typeof FRUITS)[number]; +export type FruitCount = 1 | 2 | 3 | 4 | 5; + +export interface Card { + id: number; + fruit: Fruit; + count: FruitCount; +} + +/** Per fruit: 1×5, 2×3, 3×3, 4×2, 5×1 = 14 cards (§3). */ +const COUNTS: FruitCount[] = [1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5]; + +/** The 56 cards, id 0..55 (fruit-major). */ +export const CARDS: readonly Card[] = FRUITS.flatMap((fruit, f) => COUNTS.map((count, i) => ({ id: f * 14 + i, fruit, count }))); +export const DECK_SIZE = CARDS.length; +export const cardOf = (id: number): Card => { + const c = CARDS[id]; + if (!c) throw new Error(`halli-galli: no card ${id}`); + return c; +}; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const halliGalliOptionsSchema = z.object({ + endMode: z + .enum(['lastStanding', 'official']) + .default('lastStanding') + .meta({ title: '끝나는 방식', labels: { lastStanding: '한 명이 남을 때까지', official: '2명 남으면 다음 종에서 끝(원작 규칙)' } }), + timeLimitMin: lit<5 | 10 | 15 | null>(5, 10, 15, null) + .default(10) + .meta({ title: '전체 제한 시간', labels: { 5: '5분', 10: '10분', 15: '15분', null: '없음' } }), + flipSeconds: lit<2 | 3 | 5 | 8>(2, 3, 5, 8) + .default(3) + .meta({ title: '뒤집기 제한 시간', labels: { 2: '2초', 3: '3초', 5: '5초', 8: '8초' } }), + autoFlip: z.boolean().default(false).meta({ title: '카드를 자동으로 뒤집기(뒤집기 버튼 없음)' }), + flipGuardMs: lit<400 | 600 | 1000>(400, 600, 1000) + .default(600) + .meta({ title: '뒤집은 뒤 다음 뒤집기까지 최소 간격', labels: { 400: '0.4초', 600: '0.6초', 1000: '1초' } }), + wrongRingLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000) + .default(1000) + .meta({ title: '틀린 종 뒤 종 잠금', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }), + leftoverCards: z + .enum(['pot', 'remove']) + .default('pot') + .meta({ title: '나누고 남은 카드', labels: { pot: '종 밑에 두기(다음 정답자가 가져감)', remove: '게임에서 빼기' } }), + latencyComp: z + .enum(['off', 'light']) + .default('off') + .meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }), + showRtt: z.boolean().default(true).meta({ title: '내 핑(연결 지연) 표시' }), + kidMode: z.boolean().default(false).meta({ title: '어린이 모드(8초 뒤집기, 과일 합계 보조 표시)' }), + duelWrongLoses: z.boolean().default(false).meta({ title: '1대1에서 틀린 종을 치면 바로 패배' }), + strikeOut: z.boolean().default(false).meta({ title: '틀린 종 3번이면 탈락' }), + penaltyEscalation: z.boolean().default(false).meta({ title: '틀릴 때마다 벌칙 장수 1장씩 늘리기' }), + wrongToPot: z.boolean().default(false).meta({ title: '벌칙 카드를 종 밑으로(다른 사람에게 주지 않음)' }), +}); +export type HalliGalliOptions = z.infer; + +export const halliGalliActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('flip') }), + z.object({ + type: z.literal('ring'), + /** Table version the client was looking at (§5.2 "늦은 종"). */ + version: z.number().int().min(0).max(1_000_000), + /** Client-measured round trip, used only by `latencyComp: 'light'` (clamped, max 80ms of credit). */ + rtt: z.number().min(0).max(10_000).optional(), + }), +]); +/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */ +export type HalliGalliAction = z.infer | { type: 'tick' } | { type: 'leave' }; + +export type EndReason = 'lastStanding' | 'official' | 'timeLimit'; +export type OutReason = 'empty' | 'penalty' | 'strikeOut' | 'duel' | 'left'; + +/** Public log line (structured; the screen turns it into Korean with nicknames). */ +export type LogEntry = + | { n: number; t: 'collect'; p: PlayerId; fruit: Fruit; cards: number } + | { n: number; t: 'wrong'; p: PlayerId; gave: number; toPot: boolean } + | { n: number; t: 'duelWrong'; p: PlayerId; by: PlayerId; cards: number } + | { n: number; t: 'out'; p: PlayerId; why: OutReason }; + +export interface RankRow { + rank: number; + id: PlayerId; + cards: number; + correct: number; + wrong: number; +} + +export interface HalliGalliFinal { + endReason: EndReason; + ranking: RankRow[]; + summary: string; +} + +export interface RingWindow { + version: number; + openedAt: number; + rings: { player: PlayerId; adjTs: number }[]; +} + +export interface LastBell { + /** Increments on every judged bell (for client feedback). */ + seq: number; + player: PlayerId; + correct: boolean; + fruit?: Fruit; + /** Correct: cards collected. Wrong: cards given away. */ + cards: number; + at: number; +} + +export interface HalliGalliState { + options: HalliGalliOptions; + order: PlayerId[]; + /** Face-down decks; top = last element. Hidden from everyone (including the owner). */ + decks: Record; + /** Face-up piles; top = last element. Only the top card is public. */ + faceUp: Record; + /** Cards under the bell (leftovers, leavers' decks, `wrongToPot`); next correct bell takes them. */ + pot: number[]; + /** `leftoverCards: 'remove'` — out of the game for good. */ + removed: number[]; + active: PlayerId[]; + /** Elimination order (first out first). */ + eliminated: PlayerId[]; + outReason: Record; + /** Players with 3+ starting seats get `official` endings; 2-player games always play to the last one (§6). */ + officialDuel: boolean; + turn: PlayerId; + turnStartedAt: number; + turnDeadline: number; + flipAllowedAt: number; + pendingFlip: PlayerId | null; + tableVersion: number; + lastChange: 'flip' | 'collect' | null; + lastFlipBy: PlayerId | null; + lockedUntil: Record; + wrongCount: Record; + correctCount: Record; + ringWindow: RingWindow | null; + phase: 'countdown' | 'playing' | 'ended'; + countdownEndsAt: number; + startedAt: number; + endsAt: number | null; + lastBell: LastBell | null; + bellSeq: number; + logNo: number; + log: LogEntry[]; + final: HalliGalliFinal | null; +} + +export interface TopCard { + /** Opaque public key of the visible card (never used for face-down cards). */ + key: string; + fruit: Fruit; + count: FruitCount; +} + +export interface SeatView { + id: PlayerId; + deck: number; + pile: number; + top: TopCard | null; + /** deck + own face-up pile (§6 "보유 카드 수"). */ + cards: number; + status: 'playing' | 'out' | 'left'; + isTurn: boolean; + lockedUntil: number; + correct: number; + wrong: number; +} + +export interface HalliGalliView { + options: HalliGalliOptions; + me: PlayerId | null; + phase: HalliGalliState['phase']; + seats: SeatView[]; + turn: PlayerId | null; + tableVersion: number; + pot: number; + removed: number; + countdownEndsAt: number; + turnStartedAt: number; + turnDeadline: number; + flipTotalMs: number; + flipAllowedAt: number; + /** The turn player's flip is queued behind the flip guard / bell window. */ + pendingFlip: boolean; + endsAt: number | null; + windowOpen: boolean; + officialDuel: boolean; + lastFlipBy: PlayerId | null; + lastBell: LastBell | null; + log: LogEntry[]; + result: HalliGalliFinal | null; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index d62bc18..fe14f4c 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -14,6 +14,7 @@ import { davinci } from './davinci-code'; import { mahjong } from './mahjong'; import { gostop } from './gostop'; import { bang } from './bang'; +import { halliGalli } from './halli-galli'; export const GAMES: Record = { [omok.id]: omok, @@ -31,4 +32,5 @@ export const GAMES: Record = { [mahjong.id]: mahjong, [gostop.id]: gostop, [bang.id]: bang, + [halliGalli.id]: halliGalli, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index 3acac69..0ffd8b7 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -26,6 +26,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true }, { id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true }, { id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true }, + { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);