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:
123
apps/server/src/rooms/halli-galli.room.test.ts
Normal file
123
apps/server/src/rooms/halli-galli.room.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user