M6: 할리갈리 규칙·화면·테스트

- packages/games/src/halli-galli: 56장 과일 카드, 차례 뒤집기(flipGuard 예약), 언제나 칠 수 있는 종
  (tableVersion으로 늦은 종 "늦었어요" 무벌칙, 서버 도착 순서 우선), 오답 벌칙·탈락, official/시간 제한 종료,
  하우스 룰(1대1 오답 패배, 3진 아웃, 벌칙 증가, 벌칙 종 밑), latencyComp light 50ms 창,
  view는 덱 내용을 본인에게도 숨김(장수만), secretsOf/legalActions 제공
- 테스트: §10 체크리스트 전부, 1,000판 시드 랜덤 플레이아웃(카드 보존·누출·onTimeout 합법·결정성) +
  엔진 randomPlayout, 서버 Room 테스트(동시 종 도착 순서, 늦은 종 거절, 자리 비움 자동 뒤집기)
- 화면: 원형 좌석 배치, 큰 종 버튼(스페이스 바), 뒤집기(Enter), SVG 과일 카드, 땡/삐 효과음·진동,
  카드 이동 애니메이션, 어린이 모드 합계 막대, 다크 모드·동작 줄이기 대응
- 공용 변경(하위 호환): GameDefinition.timeoutNotBefore 선택 훅 + Room 타이머 반영(자리 비움 시
  바쁜 반복 방지), feedback.ts에 ding/buzz 소리 추가
- 등록: GAMES, CATALOG, 웹 레지스트리·아이콘, 출시 예정 목록에서 제거, E2E 계획 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:34:43 +09:00
parent 6321ea8c0d
commit 595d3cdf4f
13 changed files with 2720 additions and 1 deletions

View File

@@ -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<string, Conn & { msgs: ServerMessage[] }> = {};
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<string, [string, number]>): 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<ServerMessage, { t: 'state' }>;
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');
});
});

View File

@@ -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);