Compare commits
7 Commits
bc199de10b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27cb0a11a | ||
|
|
6f462b8cb1 | ||
|
|
9bfb30a5e7 | ||
|
|
f99784331f | ||
|
|
16d3c86486 | ||
|
|
953ae90546 | ||
|
|
c682725968 |
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
type Msgs = ServerMessage[];
|
||||
type StateMsg = Extract<ServerMessage, { t: 'state' }>;
|
||||
|
||||
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) };
|
||||
const games = { catchmind: GAMES.catchmind! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'catchmind',
|
||||
options: games.catchmind.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: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conn = (p: string) => {
|
||||
const msgs: Msgs = [];
|
||||
const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
const conns: Record<string, Conn & { msgs: Msgs }> = {};
|
||||
for (const p of players) {
|
||||
conns[p] = conn(p);
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
const state = () => room.game!.state as CatchmindState;
|
||||
let cs = 100;
|
||||
const act = (p: string, a: unknown) => {
|
||||
room.act(p, ++cs, a);
|
||||
room.clearTimer();
|
||||
return cs;
|
||||
};
|
||||
return { room, db, clock, conns, conn, state, act };
|
||||
}
|
||||
|
||||
const drawOps = (stroke: number): CanvasOp[] => [
|
||||
{ k: 'b', s: stroke, c: 3, w: 1, e: 0 },
|
||||
{ k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] },
|
||||
{ k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] },
|
||||
{ k: 'e', s: stroke },
|
||||
];
|
||||
const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!;
|
||||
const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0);
|
||||
|
||||
describe('그림 맞히기 room flow', () => {
|
||||
test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => {
|
||||
const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const [g1, g2] = state().order.filter((p) => p !== d) as [string, string];
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const before = conns[g1]!.msgs.length;
|
||||
const ops = [...drawOps(1), ...drawOps(2)];
|
||||
for (const op of ops) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const got = conns[g1]!.msgs.slice(before);
|
||||
expect(got.every((m) => m.t === 'stream')).toBe(true);
|
||||
expect(got).toHaveLength(ops.length);
|
||||
const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[];
|
||||
expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i));
|
||||
expect(relayed.map((e) => e.op)).toEqual(ops);
|
||||
// The drawer's echo carries its cs (ack).
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) });
|
||||
// Regular views carry only the count.
|
||||
act(g1, { type: 'guess', text: '모르겠어요' });
|
||||
const v = lastState(conns[g2]!.msgs).view as CatchmindView;
|
||||
expect(v.canvas).toBeNull();
|
||||
expect(v.canvasCount).toBe(ops.length);
|
||||
|
||||
// g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing.
|
||||
room.disconnect(conns[g2]!);
|
||||
room.clearTimer();
|
||||
for (const op of drawOps(3)) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const back = conn(g2);
|
||||
room.join(back, 'player');
|
||||
room.clearTimer();
|
||||
const sv = lastState(back.msgs).view as CatchmindView;
|
||||
expect(sv.canvas).toEqual(state().turn.canvas);
|
||||
expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3);
|
||||
expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas));
|
||||
expect(sv.canvasCount).toBe(12);
|
||||
// Explicit resync too (client gap detection).
|
||||
room.sendSync(back);
|
||||
expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12);
|
||||
|
||||
// Streamed actions are logged in batches with the next snapshot.
|
||||
const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n;
|
||||
expect(drawLogs()).toBe(12); // the join wrote the pending ones
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } });
|
||||
expect(drawLogs()).toBe(12); // not yet written
|
||||
room.flushStream();
|
||||
expect(drawLogs()).toBe(13);
|
||||
const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq);
|
||||
expect(new Set(seqs).size).toBe(seqs.length);
|
||||
const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!;
|
||||
expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13);
|
||||
});
|
||||
|
||||
test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => {
|
||||
const { clock, conns, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
act(d, { type: 'chooseWord', index: 1 });
|
||||
const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' });
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
clock.advance(10);
|
||||
act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } });
|
||||
}
|
||||
expect(state().turn.canvas).toHaveLength(30);
|
||||
const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') });
|
||||
clock.advance(1000);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(state().turn.canvas).toHaveLength(31);
|
||||
});
|
||||
|
||||
test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => {
|
||||
const { room, clock, conns, state } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
room.disconnect(conns[d]!);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now());
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const word = state().turn.word!.word;
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||
clock.advance(9_999);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(1);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('reveal');
|
||||
expect(state().turn.endReason).toBe('drawerAway');
|
||||
expect(state().scores[d]).toBe(0);
|
||||
expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word);
|
||||
clock.advance(5_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('choosing');
|
||||
expect(state().turn.drawer).not.toBe(d);
|
||||
expect(state().away).toEqual([d]);
|
||||
});
|
||||
|
||||
test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => {
|
||||
const { room, clock, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
const t0 = state().turn.startedAt;
|
||||
const ends = state().turn.endsAt;
|
||||
expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2);
|
||||
clock.advance(20_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(ends - t0);
|
||||
for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) {
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
}
|
||||
expect(state().turn.endReason).toBe('time');
|
||||
});
|
||||
});
|
||||
120
apps/server/src/rooms/dobble.room.test.ts
Normal file
120
apps/server/src/rooms/dobble.room.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
|
||||
const games = { dobble: GAMES.dobble! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'dobble',
|
||||
options: games.dobble.optionsSchema.parse(opts),
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<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 DobbleState;
|
||||
/** The correct claim of `p` (first pair), as the client would send it. */
|
||||
const right = (p: string) => {
|
||||
const pair = pairsFor(state(), p)[0]!;
|
||||
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
|
||||
};
|
||||
return { room, clock, conns, state, right };
|
||||
}
|
||||
|
||||
describe('도블 room flow', () => {
|
||||
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
|
||||
const { room, conns, state, right } = harness(['A', 'B', 'C']);
|
||||
const a = right('A');
|
||||
const b = right('B');
|
||||
const towerB = state().piles.B!.length;
|
||||
room.act('B', 1, b);
|
||||
room.act('A', 1, a);
|
||||
room.clearTimer();
|
||||
expect(state().piles.B).toHaveLength(towerB + 1);
|
||||
expect(state().piles.A).toHaveLength(1);
|
||||
expect(state().lockedUntil.A).toBe(0);
|
||||
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
|
||||
// Nobody's view carries the answer or hidden cards; the hit is public.
|
||||
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
|
||||
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
|
||||
});
|
||||
|
||||
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
|
||||
const { room, conns } = harness(['A', 'B']);
|
||||
room.act('A', 1, { type: 'tick' });
|
||||
room.act('A', 2, { type: 'leave' });
|
||||
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
|
||||
});
|
||||
|
||||
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
|
||||
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
|
||||
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
|
||||
const s = state();
|
||||
s.rtt.A = 20;
|
||||
s.rtt.B = 200;
|
||||
const a = right('A');
|
||||
const b = right('B');
|
||||
room.act('A', 1, a);
|
||||
clock.advance(40);
|
||||
room.act('B', 1, b);
|
||||
expect(state().pendingWindow?.claims).toHaveLength(2);
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
|
||||
clock.set(room.effectiveDeadline()!);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().pendingWindow).toBeNull();
|
||||
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
|
||||
expect(state().lastHit).toMatchObject({ player: 'B' });
|
||||
});
|
||||
|
||||
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
|
||||
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
|
||||
const t0 = clock.now();
|
||||
room.disconnect(conns.C!);
|
||||
room.clearTimer();
|
||||
let fired = 0;
|
||||
while (state().status.C === 'playing' && fired < 50) {
|
||||
const d = room.effectiveDeadline()!;
|
||||
if (d > clock.now()) clock.set(d);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
fired++;
|
||||
// The others keep finding pictures now and then.
|
||||
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
|
||||
room.clearTimer();
|
||||
}
|
||||
expect(state().status.C).toBe('forfeit');
|
||||
const took = clock.now() - t0;
|
||||
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
|
||||
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
|
||||
expect(fired).toBeLessThan(20);
|
||||
expect(room.status).toBe('playing');
|
||||
expect(state().phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { MafiaState, MafiaView } from '@bg/games/mafia';
|
||||
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) };
|
||||
const games = { mafia: GAMES.mafia! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'mafia',
|
||||
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
|
||||
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: 'mafia', 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');
|
||||
}
|
||||
const state = () => room.game!.state as MafiaState;
|
||||
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
|
||||
return { room, clock, conns, state, lastView };
|
||||
}
|
||||
|
||||
describe('마피아 room flow', () => {
|
||||
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
|
||||
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
|
||||
expect(room.chat('A', '대기실 인사')).toBeNull();
|
||||
expect(room.start('A')).toBeNull();
|
||||
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
|
||||
// Role reveal → night on the server clock.
|
||||
clock.advance(8_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('night');
|
||||
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
|
||||
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
|
||||
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
|
||||
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
|
||||
for (const t of town) {
|
||||
const all = JSON.stringify(conns[t]!.msgs);
|
||||
expect(all).not.toContain('ROOM-MAFIA-SECRET');
|
||||
expect(all).not.toContain(`"role":"mafia"`);
|
||||
}
|
||||
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
|
||||
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
|
||||
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
|
||||
room.act(town[0]!, 3, { type: 'phaseTimeout' });
|
||||
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
|
||||
expect(state().phase.kind).toBe('night');
|
||||
clock.advance(30_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('discussion');
|
||||
expect(state().day).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import type { RoomStore } from './store';
|
||||
import type { LogEntry, RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
export interface Conn {
|
||||
@@ -83,6 +83,8 @@ export interface RoomDeps {
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
const ERROR_LIMIT = 3;
|
||||
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
|
||||
const STREAM_FLUSH_MS = 1000;
|
||||
|
||||
export class Room {
|
||||
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
|
||||
@@ -104,6 +106,9 @@ export class Room {
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||
private pendingLogs: LogEntry[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private recentCs = new Map<string, number[]>();
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
@@ -217,17 +222,39 @@ export class Room {
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||
this.deps.store.save(
|
||||
this.id,
|
||||
nextSeq,
|
||||
JSON.stringify(this.snapshot()),
|
||||
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
|
||||
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null,
|
||||
logs,
|
||||
);
|
||||
this.pendingLogs = [];
|
||||
if (this.flushTimer) clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
this.seq = nextSeq;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||
*/
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
||||
this.seq += 1;
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
||||
this.lastActiveAt = this.now();
|
||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
|
||||
flushStream(): void {
|
||||
this.flushTimer = null;
|
||||
if (this.pendingLogs.length) this.persist(null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
roomView(): RoomView {
|
||||
@@ -264,13 +291,14 @@ export class Room {
|
||||
};
|
||||
}
|
||||
|
||||
private stateMessage(viewer: string, cs?: number): ServerMessage | null {
|
||||
/** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
|
||||
private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null {
|
||||
if (!this.game) return null;
|
||||
const def = this.def;
|
||||
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
|
||||
let view: unknown;
|
||||
try {
|
||||
view = def.view(this.game.state, asPlayer);
|
||||
view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
|
||||
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
|
||||
@@ -345,7 +373,7 @@ export class Room {
|
||||
}
|
||||
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
|
||||
this.broadcastRoom();
|
||||
const st = this.stateMessage(u);
|
||||
const st = this.stateMessage(u, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
|
||||
for (const m of this.chatLog) conn.send(m);
|
||||
@@ -536,6 +564,7 @@ export class Room {
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
|
||||
this.lastChatAt.set(userId, now);
|
||||
@@ -553,8 +582,13 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
|
||||
private gameChatOnly(): boolean {
|
||||
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
|
||||
}
|
||||
|
||||
emote(userId: string, id: string): void {
|
||||
if (!this.isParticipant(userId)) return;
|
||||
if (!this.isParticipant(userId) || this.gameChatOnly()) return;
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
|
||||
this.lastChatAt.set(`e:${userId}`, now);
|
||||
@@ -670,10 +704,11 @@ export class Room {
|
||||
const def = this.def;
|
||||
let next: { state: unknown; events: unknown[] };
|
||||
const rng = new SeededRng(g.rng);
|
||||
const now = this.now();
|
||||
try {
|
||||
const v = def.validate(g.state, userId, action);
|
||||
const v = def.validate(g.state, userId, action, { now });
|
||||
if (!v.ok) return v.reason;
|
||||
next = def.apply(g.state, userId, action, { rng, now: this.now() });
|
||||
next = def.apply(g.state, userId, action, { rng, now });
|
||||
} catch (err) {
|
||||
this.consecutiveErrors++;
|
||||
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
|
||||
@@ -690,8 +725,15 @@ export class Room {
|
||||
g.state = next.state;
|
||||
g.rng = rng.state();
|
||||
g.deadlineFloor = null;
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
if (!system && def.isStreamAction?.(action)) {
|
||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||
} else {
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
}
|
||||
const result = def.result(g.state);
|
||||
if (result) this.finishGame(result);
|
||||
else this.scheduleTimer();
|
||||
@@ -768,12 +810,13 @@ export class Room {
|
||||
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);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
let best: number | null = null;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (d === null) continue;
|
||||
@@ -807,11 +850,12 @@ export class Room {
|
||||
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);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
|
||||
@@ -833,7 +877,7 @@ export class Room {
|
||||
|
||||
sendSync(conn: Conn): void {
|
||||
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
|
||||
const st = this.stateMessage(conn.userId);
|
||||
const st = this.stateMessage(conn.userId, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
}
|
||||
|
||||
@@ -852,6 +896,7 @@ export class Room {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.flushStream();
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'restart' });
|
||||
|
||||
@@ -44,20 +44,20 @@ export class RoomStore {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */
|
||||
/** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
|
||||
save(
|
||||
roomId: string,
|
||||
seq: number,
|
||||
stateJson: string,
|
||||
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
|
||||
log: LogEntry | null,
|
||||
log: LogEntry | LogEntry[] | null,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
this.db.transaction(() => {
|
||||
if (log) {
|
||||
for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
|
||||
this.db
|
||||
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at);
|
||||
.run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at);
|
||||
}
|
||||
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
|
||||
this.db
|
||||
|
||||
1245
apps/web/src/games/catchmind/index.tsx
Normal file
1245
apps/web/src/games/catchmind/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
8
apps/web/src/games/dobble/art.test.ts
Normal file
8
apps/web/src/games/dobble/art.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { SYMBOL_NAMES } from '@bg/games/dobble';
|
||||
import { ICONS, ICON_NAMES } from './art';
|
||||
|
||||
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
|
||||
expect(ICONS).toHaveLength(57);
|
||||
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
|
||||
});
|
||||
556
apps/web/src/games/dobble/art.tsx
Normal file
556
apps/web/src/games/dobble/art.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
|
||||
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
|
||||
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const O = '#1f2937';
|
||||
/** Outline props for filled shapes. */
|
||||
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
|
||||
/** Thick stroked lines (no fill). */
|
||||
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
|
||||
|
||||
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
const a = a0 + (i * 2 * Math.PI) / n;
|
||||
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
|
||||
Array.from({ length: n * 2 }, (_, i) => {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / n;
|
||||
const r = i % 2 ? ri : ro;
|
||||
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
|
||||
export const ICON_NAMES = [
|
||||
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
|
||||
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
|
||||
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
|
||||
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
|
||||
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
|
||||
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
|
||||
];
|
||||
|
||||
export const ICONS: (() => ReactNode)[] = [
|
||||
// 0 사과
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
|
||||
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
|
||||
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
|
||||
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
|
||||
</>
|
||||
),
|
||||
// 1 해
|
||||
() => (
|
||||
<>
|
||||
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
|
||||
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
|
||||
</>
|
||||
),
|
||||
// 2 달
|
||||
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
|
||||
// 3 별
|
||||
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
|
||||
// 4 하트
|
||||
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
|
||||
// 5 물방울
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
|
||||
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
|
||||
</>
|
||||
),
|
||||
// 6 불꽃
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
|
||||
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
|
||||
</>
|
||||
),
|
||||
// 7 눈송이
|
||||
() => (
|
||||
<>
|
||||
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
|
||||
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
|
||||
const a = Math.PI / 2 + (i * Math.PI) / 3;
|
||||
const bx = 50 + Math.cos(a) * 26;
|
||||
const by = 50 + Math.sin(a) * 26;
|
||||
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
|
||||
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
|
||||
}).join(' ')} {...W('#0284c7', 5)} />
|
||||
</>
|
||||
),
|
||||
// 8 번개
|
||||
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
|
||||
// 9 구름
|
||||
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
|
||||
// 10 나무 (pine)
|
||||
() => (
|
||||
<>
|
||||
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
|
||||
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
|
||||
</>
|
||||
),
|
||||
// 11 꽃
|
||||
() => (
|
||||
<>
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
||||
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
|
||||
})}
|
||||
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
|
||||
</>
|
||||
),
|
||||
// 12 나뭇잎
|
||||
() => (
|
||||
<>
|
||||
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
|
||||
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 13 선인장
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
|
||||
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
|
||||
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
|
||||
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
|
||||
</>
|
||||
),
|
||||
// 14 버섯
|
||||
() => (
|
||||
<>
|
||||
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
|
||||
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
|
||||
<circle cx="32" cy="34" r="6" fill="#fff" />
|
||||
<circle cx="56" cy="24" r="5" fill="#fff" />
|
||||
<circle cx="70" cy="42" r="6" fill="#fff" />
|
||||
<circle cx="46" cy="44" r="4" fill="#fff" />
|
||||
</>
|
||||
),
|
||||
// 15 당근
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
|
||||
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
|
||||
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
|
||||
</>
|
||||
),
|
||||
// 16 레몬
|
||||
() => (
|
||||
<>
|
||||
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
|
||||
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
|
||||
</>
|
||||
),
|
||||
// 17 체리
|
||||
() => (
|
||||
<>
|
||||
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
|
||||
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
|
||||
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="22" cy="66" r="4" fill="#fecaca" />
|
||||
<circle cx="66" cy="64" r="4" fill="#fecaca" />
|
||||
</>
|
||||
),
|
||||
// 18 고양이
|
||||
() => (
|
||||
<>
|
||||
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
|
||||
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
|
||||
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
|
||||
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
|
||||
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
|
||||
</>
|
||||
),
|
||||
// 19 강아지
|
||||
() => (
|
||||
<>
|
||||
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
|
||||
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
|
||||
<circle cx="40" cy="50" r="4.5" fill={O} />
|
||||
<circle cx="60" cy="50" r="4.5" fill={O} />
|
||||
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
|
||||
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
|
||||
</>
|
||||
),
|
||||
// 20 물고기
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
|
||||
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
|
||||
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
|
||||
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 21 새
|
||||
() => (
|
||||
<>
|
||||
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
|
||||
<circle cx="74" cy="30" r="3.5" fill={O} />
|
||||
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
// 22 거북이
|
||||
() => (
|
||||
<>
|
||||
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
|
||||
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
|
||||
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
|
||||
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
|
||||
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
|
||||
</>
|
||||
),
|
||||
// 23 무당벌레
|
||||
() => (
|
||||
<>
|
||||
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
|
||||
<circle cx="50" cy="28" r="16" fill={O} />
|
||||
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
|
||||
<path d="M50 26 L50 92" {...W(O, 3.5)} />
|
||||
<circle cx="34" cy="48" r="6" fill={O} />
|
||||
<circle cx="66" cy="48" r="6" fill={O} />
|
||||
<circle cx="30" cy="70" r="5" fill={O} />
|
||||
<circle cx="70" cy="70" r="5" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 24 거미
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
|
||||
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
|
||||
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
|
||||
<circle cx="46" cy="36" r="2.5" fill="#fff" />
|
||||
<circle cx="54" cy="36" r="2.5" fill="#fff" />
|
||||
</>
|
||||
),
|
||||
// 25 공룡
|
||||
() => (
|
||||
<path
|
||||
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
|
||||
fill="#65a30d"
|
||||
{...L}
|
||||
/>
|
||||
),
|
||||
// 26 토끼
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
|
||||
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
|
||||
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="40" cy="60" r="4" fill={O} />
|
||||
<circle cx="60" cy="60" r="4" fill={O} />
|
||||
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
|
||||
</>
|
||||
),
|
||||
// 27 펭귄
|
||||
() => (
|
||||
<>
|
||||
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
|
||||
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
|
||||
<circle cx="42" cy="34" r="4" fill="#fff" />
|
||||
<circle cx="58" cy="34" r="4" fill="#fff" />
|
||||
<circle cx="42" cy="34" r="2" fill={O} />
|
||||
<circle cx="58" cy="34" r="2" fill={O} />
|
||||
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
|
||||
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
|
||||
</>
|
||||
),
|
||||
// 28 열쇠
|
||||
() => (
|
||||
<>
|
||||
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
|
||||
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
|
||||
</>
|
||||
),
|
||||
// 29 자물쇠
|
||||
() => (
|
||||
<>
|
||||
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
|
||||
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
|
||||
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
|
||||
<circle cx="50" cy="62" r="7" fill={O} />
|
||||
<path d="M50 64 L50 80" {...W(O, 6)} />
|
||||
</>
|
||||
),
|
||||
// 30 시계
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
|
||||
<path d={rays(12, 27, 31)} {...W(O, 3)} />
|
||||
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
|
||||
<circle cx="50" cy="50" r="4" fill="#dc2626" />
|
||||
</>
|
||||
),
|
||||
// 31 전구
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
|
||||
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
|
||||
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
|
||||
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
|
||||
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 32 연필
|
||||
() => (
|
||||
<g transform="rotate(-45 50 50)">
|
||||
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
|
||||
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
|
||||
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
|
||||
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
|
||||
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
|
||||
</g>
|
||||
),
|
||||
// 33 가위
|
||||
() => (
|
||||
<>
|
||||
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
|
||||
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
|
||||
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
|
||||
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
|
||||
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
|
||||
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
|
||||
<circle cx="50" cy="45" r="4" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 34 안경
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
|
||||
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
|
||||
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
|
||||
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 35 우산
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
|
||||
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
|
||||
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
|
||||
</>
|
||||
),
|
||||
// 36 모자 (top hat)
|
||||
() => (
|
||||
<>
|
||||
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
|
||||
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
|
||||
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
|
||||
</>
|
||||
),
|
||||
// 37 왕관
|
||||
() => (
|
||||
<>
|
||||
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
|
||||
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
|
||||
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
|
||||
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
|
||||
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
|
||||
</>
|
||||
),
|
||||
// 38 종
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
|
||||
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
|
||||
<path d="M50 14 L50 6" {...W(O, 6)} />
|
||||
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
|
||||
</>
|
||||
),
|
||||
// 39 닻
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
|
||||
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
|
||||
</>
|
||||
),
|
||||
// 40 자전거
|
||||
() => (
|
||||
<>
|
||||
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
|
||||
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
|
||||
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
|
||||
<circle cx="50" cy="66" r="4" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 41 자동차
|
||||
() => (
|
||||
<>
|
||||
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
|
||||
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 34 L50 46" {...W(O, 2.5)} />
|
||||
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
|
||||
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
|
||||
</>
|
||||
),
|
||||
// 42 로켓
|
||||
() => (
|
||||
<>
|
||||
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
|
||||
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
|
||||
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
|
||||
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
|
||||
</>
|
||||
),
|
||||
// 43 배 (sailboat)
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 L50 66" {...W(O, 4)} />
|
||||
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
|
||||
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
|
||||
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
|
||||
</>
|
||||
),
|
||||
// 44 공 (soccer ball)
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
|
||||
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
|
||||
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
||||
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
|
||||
})}
|
||||
</>
|
||||
),
|
||||
// 45 주사위
|
||||
() => (
|
||||
<>
|
||||
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
|
||||
{[
|
||||
[30, 30],
|
||||
[70, 30],
|
||||
[50, 50],
|
||||
[30, 70],
|
||||
[70, 70],
|
||||
].map(([x, y]) => (
|
||||
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
// 46 음표
|
||||
() => (
|
||||
<>
|
||||
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
|
||||
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 47 카메라
|
||||
() => (
|
||||
<>
|
||||
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
|
||||
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
|
||||
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
|
||||
<circle cx="46" cy="49" r="3" fill="#fff" />
|
||||
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
|
||||
</>
|
||||
),
|
||||
// 48 집
|
||||
() => (
|
||||
<>
|
||||
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
|
||||
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
|
||||
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
|
||||
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
|
||||
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
|
||||
</>
|
||||
),
|
||||
// 49 성
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
|
||||
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
|
||||
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
|
||||
<rect x="18" y="44" width="8" height="12" fill={O} />
|
||||
<rect x="74" y="44" width="8" height="12" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 50 선물상자
|
||||
() => (
|
||||
<>
|
||||
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
|
||||
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
|
||||
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
|
||||
</>
|
||||
),
|
||||
// 51 촛불
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 34 L50 40" {...W(O, 3)} />
|
||||
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
|
||||
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
|
||||
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
|
||||
</>
|
||||
),
|
||||
// 52 망치
|
||||
() => (
|
||||
<g transform="rotate(-35 50 50)">
|
||||
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
|
||||
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
|
||||
</g>
|
||||
),
|
||||
// 53 느낌표
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
|
||||
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
|
||||
</>
|
||||
),
|
||||
// 54 물음표
|
||||
() => (
|
||||
<>
|
||||
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
|
||||
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
|
||||
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
|
||||
</>
|
||||
),
|
||||
// 55 눈사람
|
||||
() => (
|
||||
<>
|
||||
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
|
||||
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="44" cy="26" r="2.6" fill={O} />
|
||||
<circle cx="56" cy="26" r="2.6" fill={O} />
|
||||
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
|
||||
<circle cx="50" cy="62" r="3" fill={O} />
|
||||
<circle cx="50" cy="74" r="3" fill={O} />
|
||||
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
|
||||
</>
|
||||
),
|
||||
// 56 무지개
|
||||
() => (
|
||||
<>
|
||||
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
|
||||
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
|
||||
))}
|
||||
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
|
||||
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
];
|
||||
|
||||
/** One icon in its own 100×100 SVG. */
|
||||
export function SymbolArt({ art, className }: { art: number; className?: string }) {
|
||||
const draw = ICONS[art];
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
|
||||
{draw ? draw() : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
854
apps/web/src/games/dobble/index.tsx
Normal file
854
apps/web/src/games/dobble/index.tsx
Normal file
@@ -0,0 +1,854 @@
|
||||
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { socket, useRoom } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { ICON_NAMES, SymbolArt } from './art';
|
||||
|
||||
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
|
||||
const TAP_GRACE_MS = 250;
|
||||
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
|
||||
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
|
||||
|
||||
const CSS = `
|
||||
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
|
||||
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
|
||||
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
|
||||
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
|
||||
button.db-seat { cursor: pointer; }
|
||||
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.db-seat[data-out='true'] { opacity: .55; }
|
||||
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
|
||||
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
|
||||
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
|
||||
.db-tag.ok { background: #15803d; }
|
||||
.db-tag.warn { background: #f59e0b; color: #111827; }
|
||||
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
|
||||
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
|
||||
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
|
||||
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
|
||||
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
|
||||
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
.db-sym:disabled { cursor: default; }
|
||||
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
|
||||
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
|
||||
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
|
||||
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
|
||||
.db-sym .ic.in { animation: db-in .25s ease-out; }
|
||||
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
|
||||
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
|
||||
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
|
||||
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
|
||||
.db-thumb .db-sym { cursor: inherit; }
|
||||
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
|
||||
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
|
||||
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
|
||||
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
|
||||
.db-count small { font-size: 1.1rem; padding: 0 12px; }
|
||||
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
|
||||
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
|
||||
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
|
||||
.db-bubble.ok { border: 4px solid #16a34a; }
|
||||
.db-bubble.bad { border: 4px solid #dc2626; }
|
||||
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
|
||||
.db-bubble.info { border: 4px solid #2563eb; }
|
||||
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
|
||||
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
|
||||
.db-sus { color: var(--warning); font-weight: 800; }
|
||||
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
|
||||
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
|
||||
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
|
||||
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
|
||||
@media (max-width: 719px) {
|
||||
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
|
||||
.db-slot { position: relative; }
|
||||
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
|
||||
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
|
||||
.db-pill { font-size: .8rem; padding: 2px 8px; }
|
||||
.db-strip { justify-content: flex-start; }
|
||||
.db-seat { padding: 3px 8px; gap: 6px; }
|
||||
.db-seat .meta.rtt, .db-pill.mode { display: none; }
|
||||
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
|
||||
.db-seat .nm { max-width: 72px; font-size: .8rem; }
|
||||
.db-thumb { width: 38px; height: 38px; }
|
||||
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
|
||||
.db-bubble .bi { width: 26px; height: 26px; }
|
||||
.db-count { font-size: 4rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
|
||||
.db-sym[data-hit='true'] .ic { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- card
|
||||
|
||||
function usePhone(): boolean {
|
||||
const q = '(max-width: 719px)';
|
||||
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(q);
|
||||
const on = () => setM(mq.matches);
|
||||
mq.addEventListener('change', on);
|
||||
return () => mq.removeEventListener('change', on);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
|
||||
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
|
||||
*/
|
||||
function DobbleCard({
|
||||
card,
|
||||
label,
|
||||
side,
|
||||
onPick,
|
||||
hint,
|
||||
hit,
|
||||
shake,
|
||||
children,
|
||||
}: {
|
||||
card: CardView;
|
||||
label: string;
|
||||
side: string;
|
||||
onPick: ((art: number) => void) | null;
|
||||
hint?: number[];
|
||||
hit?: number | null;
|
||||
shake?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
|
||||
{card.items.map((it, i) => {
|
||||
const d = it.r * 100;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="db-sym"
|
||||
data-art={it.art}
|
||||
data-hint={!!hint?.includes(it.art)}
|
||||
data-hit={hit === it.art}
|
||||
disabled={!onPick}
|
||||
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
|
||||
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
|
||||
onPointerDown={(e) => {
|
||||
// Fire on press, not release: every millisecond counts.
|
||||
if (!onPick || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPick(it.art);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Keyboard activation; pointer presses were handled on pointerdown.
|
||||
if (onPick && e.detail === 0) onPick(it.art);
|
||||
}}
|
||||
>
|
||||
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
|
||||
<SymbolArt art={it.art} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small look-only card (target picker, rules). */
|
||||
function Thumb({ card }: { card: CardView | null }) {
|
||||
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
|
||||
return (
|
||||
<span className="db-thumb" aria-hidden>
|
||||
{card.items.map((it, i) => {
|
||||
const d = it.r * 100;
|
||||
return (
|
||||
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
|
||||
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
|
||||
<SymbolArt art={it.art} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function countText(mode: MiniGame, s: SeatView): string {
|
||||
switch (mode) {
|
||||
case 'tower':
|
||||
return `탑 ${s.cards}장`;
|
||||
case 'well':
|
||||
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
|
||||
case 'poisonedGift':
|
||||
return `받은 카드 ${s.cards}장`;
|
||||
case 'hotPotato':
|
||||
return `${s.cards}장 · 벌점 ${s.penalty}`;
|
||||
}
|
||||
}
|
||||
|
||||
function statusTag(s: SeatView): { text: string; cls: string } | null {
|
||||
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
|
||||
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
|
||||
if (s.status === 'forfeit') return { text: '기권', cls: '' };
|
||||
if (s.status === 'left') return { text: '나감', cls: '' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function Rtt({ ms }: { ms: number | null }) {
|
||||
if (ms === null) return null;
|
||||
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
|
||||
return (
|
||||
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
|
||||
<i className="db-dot" style={{ background: color }} aria-hidden />
|
||||
{ms}ms
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
|
||||
const tag = statusTag(s);
|
||||
if (tag) parts.push(tag.text);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface Bubble {
|
||||
key: string;
|
||||
kind: 'ok' | 'bad' | 'late' | 'info';
|
||||
text: string;
|
||||
art?: number | null;
|
||||
}
|
||||
|
||||
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
|
||||
const now = useServerNow(100);
|
||||
const phone = usePhone();
|
||||
const rtt = useRoom((s) => s.rttMs);
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [hitArt, setHitArt] = useState<number | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prev = useRef<DobbleView | null>(null);
|
||||
const changed = useRef({ sig: '', at: 0 });
|
||||
|
||||
const mode = v.options.miniGame;
|
||||
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
|
||||
const mine = v.seats.find((s) => s.id === me) ?? null;
|
||||
const others = v.seats.filter((s) => s.id !== me);
|
||||
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
|
||||
// The opponent whose card is shown big (also for spectators / while waiting).
|
||||
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
|
||||
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
|
||||
// Clamped to the lock length: the client clock estimate may be a little off.
|
||||
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
|
||||
const playing = v.phase === 'playing';
|
||||
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
|
||||
|
||||
const claim = (art: number) => {
|
||||
if (!pair) return;
|
||||
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
|
||||
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
|
||||
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
|
||||
return;
|
||||
}
|
||||
if (!canTap) {
|
||||
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
|
||||
return;
|
||||
}
|
||||
haptic(15);
|
||||
act({
|
||||
type: 'claim',
|
||||
base: pair.base,
|
||||
symbol: art,
|
||||
...(pair.target ? { target: pair.target } : {}),
|
||||
...(pair.mine !== null ? { mine: pair.mine } : {}),
|
||||
...(rtt !== null ? { rtt } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// "늦었어요" rejects come back as `reject` messages.
|
||||
useEffect(
|
||||
() =>
|
||||
socket.subscribe((m) => {
|
||||
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
|
||||
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
|
||||
setAnnounce(m.reason);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Diff against the previous view: my judgement, other people's finds, rounds.
|
||||
useEffect(() => {
|
||||
const p = prev.current;
|
||||
prev.current = v;
|
||||
if (!p) return;
|
||||
const f = v.feedback;
|
||||
if (f && f.seq !== p.feedback?.seq) {
|
||||
if (f.kind === 'ok') {
|
||||
sounds.ding();
|
||||
haptic([30, 30, 60]);
|
||||
setHitArt(f.art);
|
||||
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
|
||||
setBubble(b);
|
||||
setAnnounce(`찾았어요! ${artName(f.art)}`);
|
||||
} else if (f.kind === 'wrong') {
|
||||
sounds.buzz();
|
||||
haptic([90, 50, 90]);
|
||||
setShake(true);
|
||||
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
|
||||
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
|
||||
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
|
||||
setBubble(b);
|
||||
} else if (f.kind === 'late') {
|
||||
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
|
||||
setAnnounce('늦었어요');
|
||||
} else if (f.kind === 'locked') {
|
||||
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const h = v.lastHit;
|
||||
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
|
||||
sounds.stone();
|
||||
let text = `${nick(players, h.player)}님이 찾았어요`;
|
||||
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
|
||||
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
|
||||
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
|
||||
setAnnounce(`${text} · ${artName(h.art)}`);
|
||||
}
|
||||
const r = v.lastRound;
|
||||
if (r && r.round !== p.lastRound?.round) {
|
||||
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
|
||||
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
|
||||
setAnnounce(text);
|
||||
}
|
||||
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
useEffect(() => {
|
||||
if (!shake) return;
|
||||
const t = setTimeout(() => setShake(false), 450);
|
||||
return () => clearTimeout(t);
|
||||
}, [shake]);
|
||||
useEffect(() => {
|
||||
if (hitArt === null) return;
|
||||
const t = setTimeout(() => setHitArt(null), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [hitArt]);
|
||||
|
||||
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
|
||||
const hint = v.hintUntil > now ? v.hint : [];
|
||||
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
|
||||
|
||||
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
|
||||
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
|
||||
let a: { card: CardView | null; cap: string; side: string };
|
||||
let b: { card: CardView | null; cap: string; side: string };
|
||||
if (mode === 'hotPotato') {
|
||||
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
|
||||
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
|
||||
} else if (mode === 'poisonedGift') {
|
||||
a = { card: v.center, cap: '가운데 카드', side: 'center' };
|
||||
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
|
||||
} else {
|
||||
a = { card: v.center, cap: '가운데 카드', side: 'center' };
|
||||
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
|
||||
}
|
||||
const emptyText = (side: string): string => {
|
||||
if (v.phase === 'countdown') return '곧 공개돼요';
|
||||
if (side === 'mine') {
|
||||
if (!mine) return '관전 중이에요';
|
||||
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
|
||||
if (mine.status === 'escaped') return '이번 라운드 탈출!';
|
||||
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
|
||||
}
|
||||
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
|
||||
return '카드가 없어요';
|
||||
};
|
||||
const slot = (x: typeof a, withLock: boolean) => (
|
||||
<div className="db-slot">
|
||||
<span className="cap">{x.cap}</span>
|
||||
{x.card ? (
|
||||
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
|
||||
{withLock && lockedLeft > 0 && (
|
||||
<span
|
||||
className="db-lock"
|
||||
aria-hidden
|
||||
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
|
||||
>
|
||||
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
|
||||
</span>
|
||||
)}
|
||||
</DobbleCard>
|
||||
) : (
|
||||
<div className="db-card empty" data-card={x.side}>
|
||||
{emptyText(x.side)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
|
||||
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
|
||||
|
||||
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
|
||||
const pickable = new Set(v.pairs.map((p) => p.target));
|
||||
|
||||
return (
|
||||
<div className="db">
|
||||
<style>{CSS}</style>
|
||||
<div className="db-top">
|
||||
<div className="grp">
|
||||
<span className="db-pill mode">{MINI_KO[mode]}</span>
|
||||
{remaining && <span className="db-pill">{remaining}</span>}
|
||||
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
|
||||
</div>
|
||||
<div className="grp">
|
||||
{v.options.showRtt && rtt !== null && (
|
||||
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
|
||||
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
|
||||
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{others.length > 0 && (
|
||||
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
|
||||
{others.map((s) => {
|
||||
const tag = statusTag(s);
|
||||
const canPick = targetMode && pickable.has(s.id);
|
||||
const inner = (
|
||||
<>
|
||||
{targetMode && <Thumb card={s.top} />}
|
||||
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
|
||||
<span className="who">
|
||||
<span className="nm">
|
||||
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
|
||||
</span>
|
||||
<span className="meta">
|
||||
{countText(mode, s)}
|
||||
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
|
||||
</span>
|
||||
{v.options.showRtt && <Rtt ms={s.rtt} />}
|
||||
</span>
|
||||
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={s.id}>
|
||||
{canPick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="db-seat"
|
||||
data-seat={s.id}
|
||||
aria-pressed={targetId === s.id}
|
||||
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
|
||||
onClick={() => setSel(s.id)}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="db-table" role="region" aria-label="카드 테이블">
|
||||
{slot(a, false)}
|
||||
{slot(b, true)}
|
||||
{bubble && (
|
||||
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
|
||||
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
|
||||
<span>{bubble.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{countdown > 0 && (
|
||||
<div className="db-count" aria-hidden>
|
||||
{countdown}
|
||||
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{targetMode && v.pairs.length > 1 && playing && (
|
||||
<div className="db-panel small" style={{ fontWeight: 600 }}>
|
||||
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
|
||||
</div>
|
||||
)}
|
||||
{!phone && pair && playing && (
|
||||
<span className="small muted" style={{ textAlign: 'center' }}>
|
||||
Tab 키로 그림을 고르고 Enter 키로 눌러요
|
||||
</span>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
|
||||
switch (e.t) {
|
||||
case 'hit': {
|
||||
const n = who(players, me, e.p);
|
||||
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
|
||||
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
|
||||
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
|
||||
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
|
||||
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
|
||||
}
|
||||
case 'done':
|
||||
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
|
||||
case 'round':
|
||||
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
|
||||
case 'hint':
|
||||
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
|
||||
}
|
||||
}
|
||||
|
||||
function rulesLine(o: DobbleOptions): string {
|
||||
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
|
||||
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
|
||||
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
|
||||
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
|
||||
if (o.latencyComp === 'light') parts.push('지연 보정');
|
||||
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
|
||||
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
|
||||
const mode = v.options.miniGame;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
|
||||
<span className="small muted">{rulesLine(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{v.seats.map((s) => {
|
||||
const sus = v.suspects.includes(s.id);
|
||||
const tag = statusTag(s);
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="row"
|
||||
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
|
||||
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
|
||||
>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.id === me ? ' (나)' : ''}
|
||||
{tag ? ` · ${tag.text}` : ''}
|
||||
</span>
|
||||
{sus && (
|
||||
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
|
||||
⚠ 의심
|
||||
</span>
|
||||
)}
|
||||
{v.options.showRtt && s.rtt !== null && (
|
||||
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
|
||||
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
|
||||
{s.rtt}
|
||||
</span>
|
||||
)}
|
||||
<span className="small" aria-hidden>
|
||||
✓{s.correct} ✗{s.wrong}
|
||||
</span>
|
||||
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
|
||||
</strong>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((r) => (
|
||||
<span key={r.id}>
|
||||
{r.rank}등 {nick(players, r.id)}
|
||||
<span className="muted small">
|
||||
{' '}
|
||||
· {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">{v.result.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol className="db-log">
|
||||
{[...v.log].reverse().map((e) => (
|
||||
<li key={e.n} className="small">
|
||||
{logText(e, mode, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: DobbleView, me: string | null): boolean {
|
||||
return !!me && v.phase === 'playing' && v.pairs.length > 0;
|
||||
}
|
||||
|
||||
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
|
||||
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
|
||||
const mine = v.seats.find((s) => s.id === me);
|
||||
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
|
||||
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
|
||||
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
|
||||
if (isMyTurn(v, me)) {
|
||||
return '지금 찾아요! 같은 그림을 눌러요';
|
||||
}
|
||||
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
|
||||
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onOff: [boolean, string][] = [
|
||||
[true, '켜기'],
|
||||
[false, '끄기'],
|
||||
];
|
||||
|
||||
const MINI_HELP: Record<MiniGame, string> = {
|
||||
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
|
||||
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
|
||||
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
|
||||
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
|
||||
};
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
|
||||
<Seg
|
||||
label="미니게임"
|
||||
value={o.miniGame}
|
||||
options={[
|
||||
['tower', '탑 쌓기'],
|
||||
['well', '우물'],
|
||||
['hotPotato', '뜨거운 감자'],
|
||||
['poisonedGift', '독이 든 선물'],
|
||||
]}
|
||||
onChange={(miniGame) => set({ miniGame })}
|
||||
/>
|
||||
</Field>
|
||||
{o.miniGame === 'hotPotato' && (
|
||||
<Field label="뜨거운 감자 라운드 수">
|
||||
<Seg
|
||||
label="뜨거운 감자 라운드 수"
|
||||
value={o.hotPotatoRounds}
|
||||
options={[
|
||||
[3, '3라운드'],
|
||||
[5, '5라운드'],
|
||||
[7, '7라운드'],
|
||||
]}
|
||||
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
|
||||
<Seg
|
||||
label="틀린 그림을 눌렀을 때 잠금"
|
||||
value={o.wrongLockoutMs}
|
||||
options={[
|
||||
[0, '없음'],
|
||||
[1000, '1초'],
|
||||
[2000, '2초'],
|
||||
]}
|
||||
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
|
||||
<Seg
|
||||
label="그림 크기·회전"
|
||||
value={o.symbolStyle}
|
||||
options={[
|
||||
['classic', '원작처럼'],
|
||||
['easy', '쉽게'],
|
||||
]}
|
||||
onChange={(symbolStyle) => set({ symbolStyle })}
|
||||
/>
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="카드 장수">
|
||||
<Seg
|
||||
label="카드 장수"
|
||||
value={o.deckSize}
|
||||
options={[
|
||||
[57, '57장'],
|
||||
[55, '55장'],
|
||||
]}
|
||||
onChange={(deckSize) => set({ deckSize })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
|
||||
<Seg
|
||||
label="지연 보정"
|
||||
value={o.latencyComp}
|
||||
options={[
|
||||
['off', '끄기'],
|
||||
['light', '가볍게'],
|
||||
]}
|
||||
onChange={(latencyComp) => set({ latencyComp })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="각자 핑 표시">
|
||||
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
|
||||
</Field>
|
||||
{o.miniGame === 'well' && (
|
||||
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
|
||||
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.miniGame === 'tower' && (
|
||||
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
|
||||
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: DobbleOptions): string {
|
||||
return rulesLine(o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
|
||||
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
|
||||
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
|
||||
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
|
||||
|
||||
function Rules({ options }: { options?: DobbleOptions }) {
|
||||
const lock = options?.wrongLockoutMs ?? 1000;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
|
||||
</p>
|
||||
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 96, display: 'inline-block' }}>
|
||||
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
|
||||
</span>
|
||||
<span style={{ width: 96, display: 'inline-block' }}>
|
||||
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
|
||||
</span>
|
||||
<span>→ 같은 그림은 고양이!</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
|
||||
</li>
|
||||
<li>
|
||||
<strong>우물</strong>: {MINI_HELP.well}
|
||||
</li>
|
||||
<li>
|
||||
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
|
||||
</p>
|
||||
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<DobbleView, DobbleOptions> = {
|
||||
Board: DobbleBoard,
|
||||
Side: DobbleSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1030
apps/web/src/games/mafia/index.tsx
Normal file
1030
apps/web/src/games/mafia/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
'las-vegas': () => import('./las-vegas'),
|
||||
'cockroach-poker': () => import('./cockroach-poker'),
|
||||
'ninety-nine': () => import('./ninety-nine'),
|
||||
dobble: () => import('./dobble'),
|
||||
catchmind: () => import('./catchmind'),
|
||||
mafia: () => import('./mafia'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -27,4 +27,6 @@ export interface GameUI<V = any, O = any> {
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
optionsSummary(options: O): string;
|
||||
Rules: ComponentType<{ options?: O }>;
|
||||
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
||||
ownChat?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ export const GAME_ICON: Record<string, string> = {
|
||||
'las-vegas': '⚅',
|
||||
'cockroach-poker': '🐞',
|
||||
'ninety-nine': '💯',
|
||||
dobble: '👀',
|
||||
catchmind: '🖍️',
|
||||
mafia: '🌙',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['도블', '그림 맞히기', '마피아'];
|
||||
export const UPCOMING: string[] = [];
|
||||
|
||||
@@ -9,10 +9,12 @@ export default function GamesPage() {
|
||||
<main className="page">
|
||||
<h1>게임 목록</h1>
|
||||
<GameGrid games={catalog.filter((g) => g.available)} />
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
{UPCOMING.length > 0 && (
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Home() {
|
||||
<section className="stack" style={{ gap: 16 }}>
|
||||
<h2>게임 고르기</h2>
|
||||
<GameGrid games={available} />
|
||||
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>
|
||||
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
|
||||
</section>
|
||||
<aside className="stack" style={{ gap: 12 }}>
|
||||
<h2>지금 열린 방</h2>
|
||||
|
||||
@@ -136,6 +136,8 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
|
||||
const g = { nameKo: gameName(room.gameId) };
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
|
||||
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
socket.leave();
|
||||
@@ -157,9 +159,11 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
</strong>
|
||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
</span>
|
||||
{!roomChatClosed && (
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
</span>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙
|
||||
</button>
|
||||
@@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
)}
|
||||
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
|
||||
<EmoteBubbles room={room} />
|
||||
{chatOpen && (
|
||||
{chatOpen && !roomChatClosed && (
|
||||
<Sheet onClose={() => setChatOpen(false)} label="채팅">
|
||||
<ChatPanel room={room} me={me} />
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
|
||||
@@ -549,7 +553,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
|
||||
<aside className="game-side" aria-label="게임 정보">
|
||||
{wide && status}
|
||||
<ui.Side {...props} />
|
||||
{wide && <ChatPanel room={room} me={me} title={false} />}
|
||||
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,12 +34,16 @@ export interface GameDefinition<S, A, V, O> {
|
||||
actionSchema: ZodType<A>; // 서버 입력 검증(모양만), 규칙 검증은 validate
|
||||
playersFor?(options: O): { min: number; max: number }; // 모드별 인원이 다를 때
|
||||
setup(ctx: { players: PlayerId[]; options: O; rng: Rng; now: number; gameNo?: number }): S; // players는 방장 자리부터 시계 방향
|
||||
validate(state: S, actor: PlayerId, action: A): { ok: true } | { ok: false; reason: string };
|
||||
validate(state: S, actor: PlayerId, action: A, ctx?: { now: number }): { ok: true } | { ok: false; reason: string }; // now: 서버가 넘김(레이트 리밋용)
|
||||
apply(state: S, actor: PlayerId, action: A, ctx: { rng: Rng; now: number }): { state: S; events: GameEvent[] };
|
||||
view(state: S, viewer: PlayerId | null): V;
|
||||
activePlayers(state: S): PlayerId[];
|
||||
deadline?(state: S): number | null;
|
||||
timeoutPlayers?(state: S): PlayerId[]; // 마감이 적용되는 사람(기본: activePlayers)
|
||||
timeoutNotBefore?(state: S): number | null; // 자리 비움이어도 이 시각 전에는 시간 초과 처리 안 함(실시간 게임)
|
||||
disconnectGraceMs?(state: S): number | null; // 연결 끊긴 마감 대상의 유예 시간(ms), null이면 방의 graceSec
|
||||
isStreamAction?(action: A): boolean; // 그림 획처럼 잦은 행동: 이벤트만 'stream' 메시지로 중계, 저장은 묶어서
|
||||
syncView?(state: S, viewer: PlayerId | null): V; // (재)접속·sync 때 보내는 전체 view(기본: view)
|
||||
onTimeout(state: S, player: PlayerId): A;
|
||||
onLeave?(state: S, player: PlayerId): A | null; // 기권·자리 비움 처리
|
||||
result(state: S): GameResult | null;
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
|
||||
## M8. 파티 게임
|
||||
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).
|
||||
- **진행 결과(2026-10-06)**: 세 게임 규칙 + 화면 완료. 규칙 테스트 도블 47(+방 단위 4·그림 1)·그림 맞히기 34(+방 단위 4)·마피아 43(+방 단위 1),
|
||||
게임마다 무작위 플레이 1,000판(구성물·점수 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법).
|
||||
엔진 선택 훅 추가(기존 게임 영향 없음): `validate`의 `ctx.now`(채팅·추측 속도 제한), 붓질 스트리밍용 `isStreamAction`·`syncView`
|
||||
(그림 획은 view 재계산 없이 `stream` 메시지로 중계, 1초마다 묶어 저장, 재접속 시 그림 전체 전송), `disconnectGraceMs`,
|
||||
`ownChat`(게임 진행 중 방 채팅·빠른 반응을 닫음 — 마피아의 밤 대화, 그림 맞히기의 정답이 방 채팅으로 새지 않게).
|
||||
E2E: 도블 탑 쌓기 한 판(정답 53번·오답 잠금), 그림 맞히기 두 턴(실제 마우스 획 전달·오답·아까워요·정답) → 결과,
|
||||
마피아 4인(PC 방장 + 휴대폰 3명) 밤 행동 → 아침 발표 → 투표·반론·찬반 → 시민 팀 승리(마피아·죽은 사람 채팅이 다른 화면에 안 보임).
|
||||
|
||||
## M9. 다듬기
|
||||
- PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화.
|
||||
|
||||
379
e2e/games.e2e.ts
379
e2e/games.e2e.ts
@@ -641,6 +641,194 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도블: one real tap on `p`'s own screen. Reads the pictures of the two compared cards from the DOM (data-art), taps the shared one
|
||||
* (or, when `wrong`, a picture of my card that is not on the other). PC: focus + Enter, phone: tap. Returns the judgement shown.
|
||||
*/
|
||||
async function dobbleTap(p: Page, wrong: boolean): Promise<string | null> {
|
||||
const arts = (side: string) => p.locator(`[data-card="${side}"] .db-sym`).evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-art'))));
|
||||
const mine = p.locator('[data-card="mine"] .db-sym:not([disabled])');
|
||||
if ((await mine.count()) === 0) return null;
|
||||
const [a, b] = await Promise.all([arts('center'), arts('mine')]);
|
||||
const shared = b.filter((x) => a.includes(x));
|
||||
if (shared.length !== 1) return null; // the cards changed between the two reads
|
||||
const art = wrong ? b.find((x) => x !== shared[0])! : shared[0]!;
|
||||
const key = await p.locator('[data-card="center"]').getAttribute('data-key');
|
||||
const old = (await p.locator('.db-bubble').getAttribute('data-bubble', { timeout: 100 }).catch(() => null)) ?? '';
|
||||
const btn = p.locator(`[data-card="mine"] .db-sym[data-art="${art}"]`);
|
||||
if (tag(p) === 'pc') {
|
||||
await btn.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
} else await btn.tap();
|
||||
// My own judgement is a new bubble (another one may still be showing).
|
||||
const bubble = await p
|
||||
.locator(`.db-bubble:not([data-bubble="${old}"])`)
|
||||
.first()
|
||||
.innerText({ timeout: 1500 })
|
||||
.then((t) => t.trim())
|
||||
.catch(() => '');
|
||||
if (!wrong) await p.waitForFunction((k) => document.querySelector('[data-card="center"]')?.getAttribute('data-key') !== k, key, { timeout: 1500 }).catch(() => {});
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/** 그림 맞히기: non-white pixels on a page's drawing canvas (proves streamed strokes were rendered). */
|
||||
async function cmInk(p: Page): Promise<number> {
|
||||
return p.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>('.cm-paper canvas');
|
||||
if (!c || !c.width) return 0;
|
||||
const data = c.getContext('2d')!.getImageData(0, 0, c.width, c.height).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += 16) if (data[i]! < 200 || data[i + 1]! < 200 || data[i + 2]! < 200) n++;
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
/** 그림 맞히기: one full turn — the drawer picks a word and draws with the mouse, the other sees it, guesses wrong, close, then right. */
|
||||
async function catchmindTurn(pages: Page[], turn: number, shots: boolean): Promise<string> {
|
||||
const tag = turn === 1 ? '' : `${turn}`;
|
||||
const status = (p: Page) => p.locator('.turn-status').innerText();
|
||||
let drawer: Page | null = null;
|
||||
for (let t = 0; t < 150 && !drawer; t++) {
|
||||
for (const p of pages) if ((await status(p)).includes('그릴 단어를 골라 주세요')) drawer = p;
|
||||
if (!drawer) await pages[0]!.waitForTimeout(100);
|
||||
}
|
||||
if (!drawer) throw new Error(`turn ${turn}: nobody is choosing a word`);
|
||||
const guesser = pages.find((p) => p !== drawer)!;
|
||||
await guesser.getByText(/단어를 고르고 있어요/).first().waitFor();
|
||||
const pick = drawer.locator('.cm-choices button').nth(1);
|
||||
const picked = ((await pick.getAttribute('aria-label')) ?? '').split(' (')[0]!;
|
||||
await pick.click();
|
||||
// The plan reads the answer from the drawer's screen.
|
||||
const answer = drawer.locator('.cm-hint .answer');
|
||||
await answer.waitFor();
|
||||
const word = (await answer.innerText()).replace(/^정답:\s*/, '').trim();
|
||||
if (word !== picked) throw new Error(`picked ${picked} but the drawer sees ${word}`);
|
||||
const hidden = await guesser.locator('.cm-hint').innerText();
|
||||
if (hidden.includes(word)) throw new Error('the guesser sees the answer');
|
||||
// Real mouse strokes on the canvas: a red zig-zag, a blue circle-ish, a thick black line.
|
||||
const canvas = drawer.locator('.cm-paper canvas');
|
||||
const stroke = async (pts: (readonly [number, number])[]) => {
|
||||
// Tool buttons may have scrolled the page (phone): measure the canvas right before each stroke.
|
||||
await canvas.scrollIntoViewIfNeeded();
|
||||
const box = (await canvas.boundingBox())!;
|
||||
const at = (fx: number, fy: number) => [box.x + box.width * fx, box.y + box.height * fy] as const;
|
||||
await drawer!.mouse.move(...at(...pts[0]!));
|
||||
await drawer!.mouse.down();
|
||||
for (const pt of pts.slice(1)) await drawer!.mouse.move(...at(...pt), { steps: 6 });
|
||||
await drawer!.mouse.up();
|
||||
};
|
||||
await drawer.getByRole('button', { name: '빨강' }).click();
|
||||
await stroke([[0.2, 0.2], [0.35, 0.4], [0.5, 0.2], [0.65, 0.4], [0.8, 0.2]]);
|
||||
await drawer.getByRole('button', { name: '파랑' }).click();
|
||||
await stroke(Array.from({ length: 13 }, (_, i) => [0.5 + 0.2 * Math.cos((i / 12) * Math.PI * 2), 0.6 + 0.2 * Math.sin((i / 12) * Math.PI * 2)] as const));
|
||||
await drawer.getByRole('button', { name: '검정' }).click();
|
||||
await drawer.getByRole('button', { name: '굵기 아주 굵게' }).click();
|
||||
await stroke([[0.15, 0.9], [0.85, 0.9]]);
|
||||
// The guesser's canvas shows the same strokes (stroke count + ink).
|
||||
await guesser.waitForFunction(() => Number(document.querySelector('.cm-paper canvas')?.getAttribute('data-strokes')) >= 3, null, { timeout: 8000 });
|
||||
const inkD = await cmInk(drawer);
|
||||
let inkG = 0;
|
||||
for (let t = 0; t < 30 && inkG < 300; t++) {
|
||||
inkG = await cmInk(guesser);
|
||||
if (inkG < 300) await guesser.waitForTimeout(100);
|
||||
}
|
||||
if (inkD < 300 || inkG < 300) throw new Error(`turn ${turn}: no ink (drawer ${inkD}, guesser ${inkG})`);
|
||||
if (shots) {
|
||||
await drawer.waitForTimeout(300);
|
||||
for (const [scheme, suffix] of (turn === 1 ? [['light', ''], ['dark', '-dark']] : [['light', '']]) as [string, string][]) {
|
||||
await drawer.emulateMedia({ colorScheme: scheme as 'light' | 'dark' });
|
||||
await guesser.emulateMedia({ colorScheme: scheme as 'light' | 'dark' });
|
||||
await drawer.waitForTimeout(250);
|
||||
const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer];
|
||||
await pc.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-pc${suffix}.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone${suffix}.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone-full${suffix}.png`), fullPage: true });
|
||||
}
|
||||
await drawer.emulateMedia({ colorScheme: 'light' });
|
||||
await guesser.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
const input = guesser.locator('.cm-form input');
|
||||
await guesser.getByLabel('정답', { exact: true }).waitFor();
|
||||
const send = async (text: string) => {
|
||||
await input.fill(text);
|
||||
await input.press('Enter');
|
||||
};
|
||||
// Wrong guess: visible to the drawer too.
|
||||
await send('모르겠어요');
|
||||
await drawer.locator('.cm-chat-list').getByText('모르겠어요').first().waitFor();
|
||||
// Close guess (last syllable's final consonant changed): "아까워요" for the guesser only.
|
||||
const close = await guesser.evaluate((w) => {
|
||||
const c = w.charCodeAt(w.length - 1) - 0xac00;
|
||||
if (c < 0 || c > 11171) return null;
|
||||
const jong = c % 28;
|
||||
if ([3, 5, 6, 9, 10, 11, 12, 13, 14, 15, 18].includes(jong)) return null; // double final: distance 2
|
||||
return w.slice(0, -1) + String.fromCharCode(0xac00 + (jong === 0 ? c + 4 : c - jong));
|
||||
}, word);
|
||||
if (close) {
|
||||
await guesser.waitForTimeout(400); // per-player limit: 3 guesses a second
|
||||
await send(close);
|
||||
await guesser.locator('.cm-close').waitFor({ timeout: 5000 });
|
||||
if (await drawer.locator('.cm-close').count()) throw new Error('drawer sees the close hint');
|
||||
}
|
||||
await guesser.waitForTimeout(400);
|
||||
await send(`${word}!`);
|
||||
await guesser.locator('.cm-pop').waitFor({ timeout: 5000 });
|
||||
if ((await input.inputValue()) !== '') throw new Error('the answer stayed in the input box');
|
||||
await drawer.locator('.cm-over .word', { hasText: word }).waitFor();
|
||||
await guesser.locator('.cm-over .word', { hasText: word }).waitFor();
|
||||
if (shots) {
|
||||
const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer];
|
||||
await pc.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-pc.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-phone.png`) });
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/** 마피아: nicknames of the E2E pages (host first). */
|
||||
const MAFIA_NICKS = ['방장', '친구', '친구2', '친구3'];
|
||||
|
||||
/** 마피아: the night prompt tells each page its role without holding the role card. */
|
||||
async function mafiaRoleOf(p: Page): Promise<'mafia' | 'police' | 'doctor' | 'citizen'> {
|
||||
const text = await p.locator('.mf-actions .mf-prompt').first().innerText();
|
||||
if (text.includes('마피아가 노릴')) return 'mafia';
|
||||
if (text.includes('조사할')) return 'police';
|
||||
if (text.includes('지켜 줄')) return 'doctor';
|
||||
if (text.includes('기다려')) return 'citizen';
|
||||
throw new Error(`unknown night prompt: ${text}`);
|
||||
}
|
||||
|
||||
/** 마피아: picks the highlighted player tile of `nick` (vote / night action). */
|
||||
async function mafiaPick(p: Page, nick: string) {
|
||||
const tile = p.locator('button.mf-tile[data-target="ok"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) });
|
||||
await tile.click();
|
||||
await p.locator('button.mf-tile[aria-pressed="true"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) }).waitFor({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** 마피아: writes in the selected channel tab and waits until the line shows up. */
|
||||
async function mafiaSay(p: Page, channel: '전체' | '마피아' | '죽은 사람', text: string) {
|
||||
await p.getByRole('tab', { name: new RegExp(`^${channel} 채팅`) }).click();
|
||||
await p.getByLabel(`${channel} 채팅 메시지`).fill(text);
|
||||
await p.getByLabel(`${channel} 채팅 메시지`).press('Enter');
|
||||
await p.locator('.mf-msgs .mf-msg', { hasText: text }).waitFor({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function mafiaBanner(p: Page, phase: string, timeout = 40_000) {
|
||||
await p.locator('.mf-banner .mf-ph strong', { hasText: phase }).waitFor({ timeout });
|
||||
}
|
||||
|
||||
async function mafiaShots(pages: Page[], name: string) {
|
||||
const [host, phone] = pages as [Page, Page];
|
||||
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone.png`) });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await phone.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(250);
|
||||
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc-dark.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone-dark.png`), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await phone.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
@@ -1127,7 +1315,7 @@ const PLANS: Record<string, Plan> = {
|
||||
// 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('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초', exact: true }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/뒤집기 5초/).first().waitFor();
|
||||
},
|
||||
@@ -1606,6 +1794,195 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
|
||||
},
|
||||
},
|
||||
dobble: {
|
||||
// PC host + phone guest, 탑 쌓기 with the 55-card deck and a 1-second lock. Both players find the shared picture of their own card and the centre
|
||||
// card on their own screen (PC: keyboard focus + Enter, phone: tap), one deliberate wrong tap (shake + lock), until the draw pile runs out.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
|
||||
await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
await host.locator('[data-card="mine"] .db-sym:not([disabled])').first().waitFor({ timeout: 10_000 });
|
||||
const st = { ok: 0, wrong: 0, late: 0, other: 0 };
|
||||
let n = 0;
|
||||
let shots = 0;
|
||||
const until = Date.now() + 180_000;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
const p = pages[n++ % pages.length]!;
|
||||
const wantWrong = st.wrong === 0 && st.ok >= 3 && p === guest;
|
||||
const text = await dobbleTap(p, wantWrong);
|
||||
if (text === null) {
|
||||
await host.waitForTimeout(150);
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith('찾았어요')) st.ok++;
|
||||
else if (text.includes('틀렸어요')) {
|
||||
st.wrong++;
|
||||
await guest.screenshot({ path: join(SHOTS, 'dobble-wrong-phone.png') });
|
||||
if (!(await guest.locator('.db-lock').count())) throw new Error('no lock after a wrong tap');
|
||||
} else if (text.includes('늦었어요')) st.late++;
|
||||
else {
|
||||
st.other++;
|
||||
if (st.other <= 3) console.log(` dobble: other judgement ${JSON.stringify(text)}`);
|
||||
}
|
||||
// Human pace: a tap right after the cards changed counts as aimed at the old card (the screen drops it as late).
|
||||
await host.waitForTimeout(300);
|
||||
if (shots === 0 && st.ok >= 6) {
|
||||
shots = 1;
|
||||
await host.waitForTimeout(500);
|
||||
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish ${JSON.stringify(st)}`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'dobble-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'dobble-result-phone.png') });
|
||||
if (st.ok < 20 || st.wrong < 1) throw new Error(`too few real finds / wrong taps: ${JSON.stringify(st)}`);
|
||||
console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`);
|
||||
},
|
||||
},
|
||||
catchmind: {
|
||||
// PC host + phone guest, 1 round of 60 s: each player draws once with real mouse strokes; the other sees the strokes
|
||||
// (stroke count + pixels), guesses wrong, gets "아까워요" for a near miss, then the right answer; scores update.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '1라운드' }).click();
|
||||
await host.getByRole('group', { name: '그리기 시간' }).getByRole('button', { name: '60초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^여러 명 맞히기 · 1라운드 · 60초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const words: string[] = [];
|
||||
for (let turn = 1; turn <= 2; turn++) {
|
||||
words.push(await catchmindTurn(pages, turn, true));
|
||||
// Multi mode: 1st solver 10 points, drawer 2 per solver.
|
||||
const scores = await host.locator('.cm-score').innerText();
|
||||
if (turn === 1 && !(/10점/.test(scores) && /2점/.test(scores))) throw new Error(`scores not updated: ${scores.replace(/\n/g, ' ')}`);
|
||||
}
|
||||
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 });
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(500);
|
||||
await host.screenshot({ path: join(SHOTS, 'catchmind-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'catchmind-result-phone.png') });
|
||||
const final = await host.locator('.cm-score').innerText();
|
||||
if ((final.match(/12점/g) ?? []).length !== 2) throw new Error(`unexpected final scores: ${final.replace(/\n/g, ' ')}`);
|
||||
console.log(` catchmind: drew and guessed ${words.join(', ')}; both 12 points (10 + 2)`);
|
||||
},
|
||||
},
|
||||
mafia: {
|
||||
// PC host + 3 phone guests (마피아·경찰·의사·시민), short phases via the lobby. Real flow through the UI: role card,
|
||||
// greeting chat in 전체, night (mafia chat + target, police investigates the mafia, doctor guards themself), the
|
||||
// morning news, dead chat, discussion → vote → last defense → 찬반 → the mafia is voted out and the town wins.
|
||||
// Channel checks: mafia and dead messages never reach other screens; the room chat is closed during the game.
|
||||
players: 4,
|
||||
lobby: async (host) => {
|
||||
// Night cannot end early (only its clock advances it), so give the night's chat+3 picks ample room under load.
|
||||
await host.getByRole('group', { name: '밤 시간' }).getByRole('button', { name: '45초' }).click();
|
||||
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
|
||||
await host.getByRole('group', { name: '지목 투표 시간' }).getByRole('button', { name: '10초', exact: true }).click();
|
||||
await host.getByRole('group', { name: '최후의 반론 시간' }).getByRole('button', { name: '10초', exact: true }).click();
|
||||
await host.getByRole('group', { name: '찬반 투표 시간' }).getByRole('button', { name: '5초', exact: true }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/밤 45초 · 투표 10초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, phone] = pages as [Page, Page, Page, Page];
|
||||
for (const p of pages) await p.locator('.mf-banner').waitFor();
|
||||
// Hold-to-see role card (keyboard: Enter shows it for a moment).
|
||||
const card = host.locator('.mf-rolecard');
|
||||
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업 보기')) throw new Error('role card shows the role before it is held');
|
||||
await card.focus();
|
||||
await host.keyboard.press('Enter');
|
||||
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업:')) throw new Error('role card did not reveal on Enter');
|
||||
await host.screenshot({ path: join(SHOTS, 'mafia-1-role-pc.png') });
|
||||
// Room chat is closed while the game runs (the board has its own chat).
|
||||
if (await host.locator('.chat-panel').count()) throw new Error('room chat panel is visible during the game');
|
||||
if (await phone.getByRole('button', { name: /^채팅/ }).count()) throw new Error('room chat button is visible on the phone during the game');
|
||||
|
||||
await mafiaBanner(host, '인사 시간');
|
||||
await mafiaSay(phone, '전체', '안녕하세요 반가워요');
|
||||
for (const p of pages) await p.locator('.mf-msgs .mf-msg', { hasText: '안녕하세요 반가워요' }).waitFor({ timeout: 15_000 });
|
||||
for (const p of pages) await p.getByRole('button', { name: /^인사 끝내기/ }).click();
|
||||
|
||||
for (const p of pages) await mafiaBanner(p, '밤');
|
||||
const roles = await Promise.all(pages.map(mafiaRoleOf));
|
||||
const at = (r: string) => roles.indexOf(r as (typeof roles)[number]);
|
||||
const [M, P, D, C] = [at('mafia'), at('police'), at('doctor'), at('citizen')];
|
||||
if ([M, P, D, C].some((i) => i < 0)) throw new Error(`unexpected roles ${roles.join(',')}`);
|
||||
console.log(` mafia: roles ${roles.map((r, i) => `${MAFIA_NICKS[i]}=${r}`).join(' ')}`);
|
||||
// Same night layout for everyone; only the mafia has the 마피아 tab.
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if ((await p.getByRole('tab', { name: /^마피아 채팅/ }).count()) !== (i === M ? 1 : 0)) throw new Error(`${MAFIA_NICKS[i]}: wrong 마피아 tab`);
|
||||
if (!(await p.locator('.mf-actions').count())) throw new Error('night panel missing');
|
||||
}
|
||||
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('day chat open at night');
|
||||
await mafiaSay(pages[M]!, '마피아', 'MAFIA-SECRET 오늘은 조용히');
|
||||
await mafiaPick(pages[M]!, MAFIA_NICKS[C]!);
|
||||
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
|
||||
await mafiaPick(pages[D]!, MAFIA_NICKS[D]!);
|
||||
await pages[0]!.waitForTimeout(300);
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]}`);
|
||||
}
|
||||
await mafiaShots(pages, '2-night');
|
||||
|
||||
for (const p of pages) await mafiaBanner(p, '낮 토론', 60_000);
|
||||
for (const p of pages) await p.locator('.mf-news', { hasText: '아침 발표' }).waitFor();
|
||||
await pages[C]!.locator('.mf-news', { hasText: '탈락했어요' }).waitFor();
|
||||
await pages[P]!.locator('.mf-results', { hasText: '마피아예요!' }).waitFor();
|
||||
for (const [i, p] of pages.entries()) if (i !== P && (await p.locator('.mf-results').count())) throw new Error('police result shown to someone else');
|
||||
// The dead citizen: no day chat, writes in 죽은 사람 only — nobody alive sees it.
|
||||
await pages[C]!.getByRole('tab', { name: /^전체 채팅/ }).click();
|
||||
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('dead player can write in day chat');
|
||||
await mafiaSay(pages[C]!, '죽은 사람', 'DEAD-SECRET 저 시민이었어요');
|
||||
await mafiaSay(pages[P]!, '전체', `${MAFIA_NICKS[M]}님이 마피아예요`);
|
||||
await pages[0]!.waitForTimeout(300);
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if (i !== C && (await p.content()).includes('DEAD-SECRET')) throw new Error(`dead chat leaked to ${MAFIA_NICKS[i]}`);
|
||||
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]} by day`);
|
||||
}
|
||||
await mafiaShots(pages, '3-day');
|
||||
for (const i of [M, P, D]) await pages[i]!.getByRole('button', { name: /^토론 끝내기/ }).click();
|
||||
|
||||
for (const i of [M, P, D]) await mafiaBanner(pages[i]!, '지목 투표', 15_000);
|
||||
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
|
||||
await mafiaPick(pages[D]!, MAFIA_NICKS[M]!);
|
||||
await mafiaPick(pages[M]!, MAFIA_NICKS[P]!);
|
||||
await mafiaBanner(host, '최후의 반론', 20_000);
|
||||
await pages[M]!.locator('.mf-news', { hasText: '지목 투표 결과' }).waitFor();
|
||||
if (await pages[P]!.getByLabel('전체 채팅 메시지').count()) throw new Error('others can talk during the last defense');
|
||||
await mafiaSay(pages[M]!, '전체', '저는 진짜 시민이에요');
|
||||
await pages[P]!.locator('.mf-msgs .mf-msg', { hasText: '저는 진짜 시민이에요' }).waitFor({ timeout: 15_000 });
|
||||
await mafiaShots(pages, '4-defense');
|
||||
await mafiaBanner(pages[P]!, '찬반 투표', 20_000);
|
||||
await pages[P]!.getByRole('button', { name: '찬성 (탈락)' }).click();
|
||||
await pages[D]!.getByRole('button', { name: '찬성 (탈락)' }).click();
|
||||
await pages[P]!.getByRole('button', { name: '찬성 (탈락)', pressed: true }).waitFor();
|
||||
|
||||
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 });
|
||||
for (const p of pages) await p.locator('.mf-final', { hasText: '시민 팀 승리' }).waitFor();
|
||||
// After the end every channel is revealed (revealChatsAtEnd).
|
||||
await pages[C]!.getByRole('tab', { name: /^마피아 채팅/ }).waitFor();
|
||||
await host.waitForTimeout(500);
|
||||
await host.screenshot({ path: join(SHOTS, 'mafia-5-result-pc.png') });
|
||||
await phone.screenshot({ path: join(SHOTS, 'mafia-5-result-phone.png') });
|
||||
console.log(' mafia: night kill, police result, dead chat, vote → defense → 찬반 → town won');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface SetupContext<O> {
|
||||
gameNo?: number;
|
||||
}
|
||||
|
||||
/** Optional server context for `validate` (time-based checks such as chat rate limits). Absent in pure tests. */
|
||||
export interface ValidateContext {
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface ApplyContext {
|
||||
rng: Rng;
|
||||
now: number;
|
||||
@@ -47,12 +52,22 @@ export interface GameDefinition<S, A, V, O> {
|
||||
optionsSchema: ZodType<O>;
|
||||
actionSchema: ZodType<A>;
|
||||
playersFor?(options: O): { min: number; max: number };
|
||||
/** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */
|
||||
ownChat?: boolean;
|
||||
/** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */
|
||||
playerCountError?(options: O, count: number): string | null;
|
||||
setup(ctx: SetupContext<O>): S;
|
||||
validate(state: S, actor: PlayerId, action: A): Validation;
|
||||
/** `ctx.now` is passed by the server (real-time games may rate-limit with it); tests may omit it. */
|
||||
validate(state: S, actor: PlayerId, action: A, ctx?: ValidateContext): Validation;
|
||||
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
|
||||
view(state: S, viewer: PlayerId | null): V;
|
||||
/**
|
||||
* Streaming actions (e.g. drawing strokes): the server relays only their events (`stream` message) instead of
|
||||
* recomputing and broadcasting every view, and persists them in batches. Their events must be public.
|
||||
*/
|
||||
isStreamAction?(action: A): boolean;
|
||||
/** View sent on (re)join and resync; defaults to `view`. Lets `view` omit bulky data that streams as events. */
|
||||
syncView?(state: S, viewer: PlayerId | null): V;
|
||||
activePlayers(state: S): PlayerId[];
|
||||
deadline?(state: S): number | null;
|
||||
/** Players the deadline applies to (default: activePlayers). */
|
||||
@@ -62,6 +77,8 @@ export interface GameDefinition<S, A, V, O> {
|
||||
* pulls forward to the end of the grace period). Real-time games use it to keep a minimum pace.
|
||||
*/
|
||||
timeoutNotBefore?(state: S): number | null;
|
||||
/** Grace period (ms) for disconnected timed players in this state, overriding the room's `graceSec`; null = room default. */
|
||||
disconnectGraceMs?(state: S): number | null;
|
||||
onTimeout(state: S, player: PlayerId): A;
|
||||
onLeave?(state: S, player: PlayerId): A | null;
|
||||
result(state: S): GameResult | null;
|
||||
|
||||
669
packages/games/src/catchmind/catchmind.test.ts
Normal file
669
packages/games/src/catchmind/catchmind.test.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
|
||||
import {
|
||||
BUILTIN_WORDS,
|
||||
CHOOSE_MS,
|
||||
REVEAL_MS,
|
||||
buildPool,
|
||||
catchmind as cm,
|
||||
catchmindOptionsSchema,
|
||||
chosung,
|
||||
judge,
|
||||
normalize,
|
||||
type CanvasOp,
|
||||
type CatchmindAction,
|
||||
type CatchmindOptions,
|
||||
type CatchmindState,
|
||||
type CatchmindView,
|
||||
type WordEntry,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const T0 = 1_000_000;
|
||||
const rng = () => SeededRng.fromSeed([3, 1, 4, 1]);
|
||||
const P = (n: number) => Array.from({ length: n }, (_, i) => `p${i + 1}`);
|
||||
const opts = (o: Partial<CatchmindOptions> = {}) => catchmindOptionsSchema.parse(o);
|
||||
|
||||
function make(n = 4, o: Partial<CatchmindOptions> = {}, seed = 1): CatchmindState {
|
||||
return cm.setup({ players: P(n), options: opts(o), rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
|
||||
}
|
||||
|
||||
function applyE(s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number): { state: CatchmindState; events: GameEvent[] } {
|
||||
const v = cm.validate(s, p, a, { now });
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return cm.apply(s, p, a, { rng: rng(), now });
|
||||
}
|
||||
const act = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number) => applyE(s, p, a, now).state;
|
||||
const reason = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now?: number) => {
|
||||
const v = cm.validate(s, p, a, now === undefined ? undefined : { now });
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
|
||||
const entry = (word: string): WordEntry =>
|
||||
BUILTIN_WORDS.find((w) => w.word === word) ?? { word, aliases: [], category: 'custom', difficulty: 'normal' };
|
||||
|
||||
/** Puts `word` in front of the current drawer and picks it; returns the drawing state. */
|
||||
function drawWith(s0: CatchmindState, word: string, now = T0 + 1000): CatchmindState {
|
||||
const s = structuredClone(s0);
|
||||
expect(s.turn.phase).toBe('choosing');
|
||||
s.turn.choices = [entry(word), ...s.turn.choices.slice(1)];
|
||||
return act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, now);
|
||||
}
|
||||
|
||||
const guessers = (s: CatchmindState) => s.order.filter((p) => p !== s.turn.drawer);
|
||||
const guess = (text: string) => ({ type: 'guess', text }) as const;
|
||||
const draw = (op: CanvasOp) => ({ type: 'draw', op }) as const;
|
||||
const view = (s: CatchmindState, p: PlayerId | null): CatchmindView => cm.view(s, p);
|
||||
|
||||
// ---------------------------------------------------------------- §5.3 text
|
||||
|
||||
describe('정답 판정(§5.3)', () => {
|
||||
const ice = ['아이스크림'];
|
||||
test('정규화: 띄어쓰기·문장부호, 끝말, NFD, 전각 영문, 제로폭 문자 모두 정답', () => {
|
||||
expect(judge('아이스 크림!', ice, true)).toBe('correct');
|
||||
expect(judge('아이스크림요', ice, true)).toBe('correct');
|
||||
expect(judge('아이스크림'.normalize('NFD'), ice, true)).toBe('correct');
|
||||
expect('아이스크림'.normalize('NFD')).not.toBe('아이스크림');
|
||||
expect(judge('ABC', ['ABC'], true)).toBe('correct');
|
||||
expect(judge('abc', ['ABC'], true)).toBe('correct');
|
||||
expect(judge('아이스크림', ice, true)).toBe('correct');
|
||||
expect(judge(' 아이스크림입니다~ ', ice, true)).toBe('correct');
|
||||
expect(judge('아이스크림인가요?', ice, true)).toBe('correct');
|
||||
expect(judge('🍦아이스크림🍦', ice, true)).toBe('correct');
|
||||
expect(normalize('A B·C…')).toBe('abc');
|
||||
});
|
||||
|
||||
test('초성만(ㅇㅇㅅㅋㄹ)은 정답 아님 + 비공개, 낱자 ㅅㅏㄱㅘ도 정답 아님', () => {
|
||||
expect(judge('ㅇㅇㅅㅋㄹ', ice, true)).toBe('chosung');
|
||||
expect(judge('ㅇ ㅇ ㅅ ㅋ ㄹ', ice, true)).toBe('chosung');
|
||||
expect('ㅅㅏㄱㅘ'.normalize('NFKC')).toBe('사과'); // why the raw check must come first
|
||||
expect(judge('ㅅㅏㄱㅘ', ['사과'], true)).not.toBe('correct');
|
||||
expect(judge('ㅋㅋㅋ', ['사과'], true)).toBe('plain');
|
||||
expect(chosung('아이스크림')).toBe('ㅇㅇㅅㅋㄹ');
|
||||
});
|
||||
|
||||
test('끝말 허용이 정답 자체를 깎지 않음: 바다 ← "바" 오답, "바다요" 정답', () => {
|
||||
expect(judge('바', ['바다'], true)).not.toBe('correct');
|
||||
expect(judge('바다요', ['바다'], true)).toBe('correct');
|
||||
expect(judge('바다다', ['바다'], true)).toBe('correct');
|
||||
expect(judge('바다야', ['바다'], true)).not.toBe('correct');
|
||||
});
|
||||
|
||||
test('별칭도 정답', () => {
|
||||
expect(judge('핸드폰', ['휴대폰', '핸드폰', '휴대전화', '스마트폰'], true)).toBe('correct');
|
||||
});
|
||||
|
||||
test('정답이 들어간 말은 contains, 아까워요는 자모 거리 1(3글자 이하)/2(4글자 이상)', () => {
|
||||
expect(judge('사과나무', ['사과'], true)).toBe('contains');
|
||||
expect(judge('사과나무 아님', ['사과'], true)).toBe('contains');
|
||||
expect(judge('코끼라', ['코끼리'], true)).toBe('close');
|
||||
expect(judge('코끼라', ['코끼리'], false)).toBe('plain');
|
||||
expect(judge('코기리', ['코끼리'], true)).toBe('close');
|
||||
expect(judge('고기리', ['코끼리'], true)).toBe('plain'); // distance 2 on a 3-letter word
|
||||
expect(judge('아이스크룸', ['아이스크림'], true)).toBe('close');
|
||||
expect(judge('아이스크름', ['아이스크림'], true)).toBe('close');
|
||||
expect(judge('하마', ['코끼리'], true)).toBe('plain');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- chat visibility
|
||||
|
||||
describe('채팅 공개 범위(§5.3, §8.3)', () => {
|
||||
test('초성 입력은 본인만 보고, 정답 포함 메시지는 막고 안내, 아까워요는 본인에게만', () => {
|
||||
let s = drawWith(make(4), '아이스크림');
|
||||
const [a, b, c] = guessers(s) as [string, string, string];
|
||||
s = act(s, a, guess('ㅇㅇㅅㅋㄹ'), T0 + 2000);
|
||||
s = act(s, b, guess('아이스크림 케이크'), T0 + 2100);
|
||||
s = act(s, c, guess('아이스크룸'), T0 + 2200);
|
||||
const va = view(s, a);
|
||||
const vb = view(s, b);
|
||||
const vc = view(s, c);
|
||||
const vd = view(s, s.turn.drawer);
|
||||
// a sees own blocked chosung line; nobody else does.
|
||||
expect(va.chat.find((m) => m.from === a)).toMatchObject({ kind: 'blocked', note: 'chosung' });
|
||||
for (const v of [vb, vc, vd, view(s, null)]) expect(v.chat.some((m) => m.from === a)).toBe(false);
|
||||
expect(vb.chat.find((m) => m.from === b)).toMatchObject({ kind: 'blocked', note: 'contains', text: '아이스크림 케이크' });
|
||||
for (const v of [va, vc, vd]) expect(v.chat.some((m) => m.from === b)).toBe(false);
|
||||
// close: sender sees the "아까워요" kind, others see a normal chat line.
|
||||
expect(vc.chat.find((m) => m.from === c)).toMatchObject({ kind: 'close', text: '아이스크룸' });
|
||||
for (const v of [va, vb, vd, view(s, null)]) expect(v.chat.find((m) => m.from === c)).toMatchObject({ kind: 'chat', text: '아이스크룸' });
|
||||
expect(s.turn.solvers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('정답은 "맞혔어요" 줄로 바뀌고, 맞힌 사람의 채팅은 출제자·맞힌 사람에게만', () => {
|
||||
let s = drawWith(make(4), '사과');
|
||||
const [a, b, c] = guessers(s) as [string, string, string];
|
||||
s = act(s, a, guess('사과요'), T0 + 2000);
|
||||
expect(s.turn.solvers.map((x) => x.id)).toEqual([a]);
|
||||
expect(view(s, b).chat.at(-1)).toMatchObject({ from: a, kind: 'correct', text: '' });
|
||||
s = act(s, a, guess('빨간 거 맞죠'), T0 + 2500);
|
||||
expect(view(s, b).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false);
|
||||
expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false);
|
||||
expect(view(s, s.turn.drawer).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true });
|
||||
expect(view(s, a).word).toBe('사과');
|
||||
expect(view(s, b).word).toBeNull();
|
||||
expect(view(s, a).role).toBe('solver');
|
||||
s = act(s, b, guess('사과'), T0 + 3000);
|
||||
expect(view(s, b).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true });
|
||||
expect(view(s, c).word).toBeNull();
|
||||
s = act(s, c, guess('사과'), T0 + 3500);
|
||||
// Everyone got it → reveal: everyone sees the word and the solvers' chat.
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(view(s, null).word).toBe('사과');
|
||||
expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(true);
|
||||
});
|
||||
|
||||
test('출제자는 그리는 동안 정답 입력·채팅 불가, 반응 버튼만', () => {
|
||||
const s = drawWith(make(3), '사과');
|
||||
expect(reason(s, s.turn.drawer, guess('사과'))).toContain('출제자');
|
||||
expect(reason(s, s.turn.drawer, { type: 'chat', text: '안녕' })).not.toBeNull();
|
||||
const r = act(s, s.turn.drawer, { type: 'reaction', code: 'almost' }, T0 + 2000);
|
||||
expect(view(r, guessers(r)[0]!).chat.at(-1)).toMatchObject({ kind: 'reaction', text: 'almost' });
|
||||
});
|
||||
|
||||
test('글자 수 제한·초당 3개 제한', () => {
|
||||
const s = drawWith(make(3), '사과');
|
||||
const g = guessers(s)[0]!;
|
||||
expect(reason(s, g, guess('가'.repeat(41)))).toContain('40자');
|
||||
expect(reason(s, g, guess(' '))).not.toBeNull();
|
||||
let t = s;
|
||||
for (let i = 0; i < 3; i++) t = act(t, g, guess(`배${i}`), T0 + 2000 + i * 100);
|
||||
expect(reason(t, g, guess('배3'), T0 + 2300)).toContain('천천히');
|
||||
expect(reason(t, g, guess('배3'), T0 + 3000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- §6 scores
|
||||
|
||||
describe('점수(§6)', () => {
|
||||
test('multi: 4명 순서대로 맞히면 10/8/6/4, 출제자 8점', () => {
|
||||
let s = drawWith(make(5), '기린');
|
||||
const d = s.turn.drawer;
|
||||
const gs = guessers(s);
|
||||
gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 500)));
|
||||
expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4]);
|
||||
expect(s.scores[d]).toBe(8);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('allSolved');
|
||||
expect(view(s, null).turnPoints).toMatchObject({ [d]: 8, [gs[0]!]: 10, [gs[3]!]: 4 });
|
||||
});
|
||||
|
||||
test('multi: 다섯 번째부터 4점, 출제자 최대 12점', () => {
|
||||
let s = drawWith(make(8), '기린');
|
||||
const d = s.turn.drawer;
|
||||
const gs = guessers(s);
|
||||
gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 400)));
|
||||
expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4, 4, 4, 4]);
|
||||
expect(s.scores[d]).toBe(12);
|
||||
});
|
||||
|
||||
test('multi: 아무도 못 맞히면 출제자 0점, 시간 만료로 공개', () => {
|
||||
let s = drawWith(make(3), '기린');
|
||||
const d = s.turn.drawer;
|
||||
s = act(s, d, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('time');
|
||||
expect(s.scores[d]).toBe(0);
|
||||
expect(view(s, guessers(s)[0]!).word).toBe('기린');
|
||||
});
|
||||
|
||||
test('firstOnly: 첫 정답 즉시 턴 종료, 정답자 10·출제자 5', () => {
|
||||
let s = drawWith(make(4, { guessMode: 'firstOnly' }), '기린');
|
||||
const d = s.turn.drawer;
|
||||
const [a, b] = guessers(s) as [string, string];
|
||||
s = act(s, a, guess('기린'), T0 + 2000);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('first');
|
||||
expect(s.scores[a]).toBe(10);
|
||||
expect(s.scores[d]).toBe(5);
|
||||
expect(reason(s, b, guess('기린'))).not.toBeNull();
|
||||
});
|
||||
|
||||
test('speedBonus: 남은 시간 비율 × 5점 추가', () => {
|
||||
let s = drawWith(make(3, { speedBonus: true }), '기린', T0);
|
||||
const g = guessers(s)[0]!;
|
||||
const dur = s.turn.endsAt - s.turn.startedAt;
|
||||
s = act(s, g, guess('기린'), T0 + dur / 2);
|
||||
expect(s.scores[g]).toBe(10 + 3); // round(2.5)
|
||||
});
|
||||
|
||||
test('신고 과반 → 즉시 종료, 출제자 0점(맞힌 사람 점수 유지)', () => {
|
||||
let s = drawWith(make(5), '기린');
|
||||
const d = s.turn.drawer;
|
||||
const [a, b, c] = guessers(s) as [string, string, string];
|
||||
s = act(s, a, guess('기린'), T0 + 2000);
|
||||
// 4 voters → 3 needed (more than half), solvers count too.
|
||||
expect(view(s, b).reportsNeeded).toBe(3);
|
||||
s = act(s, a, { type: 'report' }, T0 + 2100);
|
||||
s = act(s, b, { type: 'report' }, T0 + 2200);
|
||||
expect(reason(s, b, { type: 'report' })).toContain('이미');
|
||||
expect(s.turn.phase).toBe('drawing');
|
||||
s = act(s, c, { type: 'report' }, T0 + 2300);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('report');
|
||||
expect(s.scores[d]).toBe(0);
|
||||
expect(s.scores[a]).toBe(10);
|
||||
expect(reason(drawWith(make(3, { letterReport: false }), '기린'), 'p1', { type: 'report' })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- §5.4 hints
|
||||
|
||||
describe('힌트(§5.4)', () => {
|
||||
test('50%에 초성, 75%에 한 글자(RNG 위치)', () => {
|
||||
let s = drawWith(make(3, { drawSeconds: 80 }), '아이스크림', T0);
|
||||
const g = guessers(s)[0]!;
|
||||
const d = s.turn.drawer;
|
||||
expect(view(s, g).hint).toEqual(['○', '○', '○', '○', '○']);
|
||||
expect(cm.deadline!(s)).toBe(T0 + 40_000);
|
||||
s = act(s, d, { type: 'tick' }, T0 + 40_000);
|
||||
expect(view(s, g).hint).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ']);
|
||||
expect(cm.deadline!(s)).toBe(T0 + 60_000);
|
||||
s = act(s, d, { type: 'tick' }, T0 + 60_000);
|
||||
const h = view(s, g).hint!;
|
||||
const i = s.turn.revealIndex!;
|
||||
expect(h[i]).toBe('아이스크림'[i]);
|
||||
expect(h.filter((x, k) => k !== i)).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ'].filter((_, k) => k !== i));
|
||||
expect(cm.deadline!(s)).toBe(T0 + 80_000);
|
||||
expect(s.turn.phase).toBe('drawing');
|
||||
});
|
||||
|
||||
test('2글자 단어는 75% 단계 생략, 띄어쓰기 위치 표시', () => {
|
||||
let s = drawWith(make(3), '기린', T0);
|
||||
s = act(s, s.turn.drawer, { type: 'tick' }, T0 + 40_000);
|
||||
expect(view(s, null).hint).toEqual(['ㄱ', 'ㄹ']);
|
||||
expect(cm.deadline!(s)).toBe(s.turn.endsAt);
|
||||
const sp = drawWith(make(3), '사과 나무', T0);
|
||||
expect(view(sp, null).hint).toEqual(['○', '○', ' ', '○', '○']);
|
||||
});
|
||||
|
||||
test('basic은 글자 수만, none은 없음', () => {
|
||||
let s = drawWith(make(3, { hints: 'basic' }), '아이스크림', T0);
|
||||
expect(cm.deadline!(s)).toBe(s.turn.endsAt);
|
||||
expect(view(s, null).hint).toEqual(['○', '○', '○', '○', '○']);
|
||||
s = drawWith(make(3, { hints: 'none' }), '아이스크림', T0);
|
||||
expect(view(s, null).hint).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- §5.5 drawing
|
||||
|
||||
describe('그림 검증(§5.5)', () => {
|
||||
const begin = (s: number): CanvasOp => ({ k: 'b', s, c: 0, w: 1, e: 0 });
|
||||
test('출제자 아닌 사람, 좌표 1001, 점 65개, 획 번호 건너뛰기, 초당 31번째 메시지 거부', () => {
|
||||
let s = drawWith(make(3), '기린', T0);
|
||||
const d = s.turn.drawer;
|
||||
const g = guessers(s)[0]!;
|
||||
expect(reason(s, g, draw(begin(1)))).toContain('출제자');
|
||||
expect(reason(s, d, draw(begin(2)))).toContain('획 번호');
|
||||
s = act(s, d, draw(begin(1)), T0 + 10);
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1001, 5] }))).not.toBeNull();
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [999, 5, 2, 0] }))).not.toBeNull(); // 1001 via delta
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(130).fill(1) }))).toContain('점 개수');
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(128).fill(1) }))).toBeNull();
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 1, d: [5, 5] }))).toContain('순서');
|
||||
expect(reason(s, d, draw({ k: 'p', s: 2, q: 0, d: [5, 5] }))).toContain('획 번호');
|
||||
s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [0, 1000, 3, -4] }), T0 + 20);
|
||||
expect(s.turn.totalPoints).toBe(2);
|
||||
s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 30);
|
||||
// 30 messages within one second are fine, the 31st is not.
|
||||
let t = s;
|
||||
let k = 2;
|
||||
for (; t.turn.drawTimes.length < 30; k++) t = act(t, d, draw(begin(k)), T0 + 100 + k);
|
||||
expect(reason(t, d, draw(begin(k)), T0 + 1009)).toContain('너무 빨리');
|
||||
expect(reason(t, d, draw(begin(k)), T0 + 1010)).toBeNull();
|
||||
// Not while choosing / reveal.
|
||||
expect(reason(make(3), 'p1', draw(begin(1)))).not.toBeNull();
|
||||
});
|
||||
|
||||
test('턴당 누적 점 30,000 초과 거부', () => {
|
||||
let s = drawWith(make(3), '기린', T0);
|
||||
const d = s.turn.drawer;
|
||||
s = act(s, d, draw(begin(1)), T0);
|
||||
s = { ...s, turn: { ...s.turn, totalPoints: 29_999 } };
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1, 1, 1] }))).toContain('다 썼어요');
|
||||
expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1] }))).toBeNull();
|
||||
});
|
||||
|
||||
test('되돌리기·전체 지우기, canvasOp 이벤트만 나가고 view에는 개수만(전체 그림은 syncView)', () => {
|
||||
let s = drawWith(make(3), '기린', T0);
|
||||
const d = s.turn.drawer;
|
||||
expect(reason(s, d, draw({ k: 'u' }))).not.toBeNull();
|
||||
const before = s;
|
||||
const r = applyE(s, d, draw(begin(1)), T0 + 1);
|
||||
expect(r.events).toEqual([{ type: 'canvasOp', turn: s.turn.no, n: 0, op: begin(1) }]);
|
||||
expect(before.turn.canvas).toHaveLength(0); // previous state untouched
|
||||
s = r.state;
|
||||
s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [10, 10, 5, 5] }), T0 + 2);
|
||||
s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 3);
|
||||
s = act(s, d, draw(begin(2)), T0 + 4);
|
||||
s = act(s, d, draw({ k: 'u' }), T0 + 5);
|
||||
expect(s.turn.live).toBe(1);
|
||||
s = act(s, d, draw({ k: 'x' }), T0 + 6);
|
||||
expect(s.turn.live).toBe(0);
|
||||
expect(reason(s, d, draw({ k: 'x' }))).not.toBeNull();
|
||||
expect(cm.isStreamAction!(draw({ k: 'x' }))).toBe(true);
|
||||
expect(cm.isStreamAction!(guess('a'))).toBe(false);
|
||||
const g = guessers(s)[0]!;
|
||||
expect(view(s, g).canvas).toBeNull();
|
||||
expect(view(s, g).canvasCount).toBe(6);
|
||||
expect(cm.syncView!(s, g).canvas).toEqual(s.turn.canvas);
|
||||
expect(cm.syncView!(s, null).canvas).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- flow
|
||||
|
||||
describe('진행(§5.1, §5.2, §8.5)', () => {
|
||||
test('고르기 10초 초과 → 첫 단어, 그리기·공개·다음 출제자, 라운드 진행과 종료', () => {
|
||||
let s = make(2, { rounds: 1 });
|
||||
const first = s.turn.drawer;
|
||||
expect(s.turn.choices).toHaveLength(3);
|
||||
expect(cm.deadline!(s)).toBe(T0 + CHOOSE_MS);
|
||||
const c0 = s.turn.choices[0]!.word;
|
||||
s = act(s, first, { type: 'tick' }, T0 + CHOOSE_MS);
|
||||
expect(s.turn.phase).toBe('drawing');
|
||||
expect(s.turn.word!.word).toBe(c0);
|
||||
expect(s.away).toEqual([]);
|
||||
s = act(s, first, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(cm.deadline!(s)).toBe(s.turn.startedAt + REVEAL_MS);
|
||||
s = act(s, first, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.turn.drawer).not.toBe(first);
|
||||
expect(s.turn.no).toBe(2);
|
||||
const second = s.turn.drawer;
|
||||
s = act(s, second, { type: 'chooseWord', index: 2 }, s.turn.startedAt + 1000);
|
||||
s = act(s, second, { type: 'tick' }, s.turn.endsAt);
|
||||
s = act(s, second, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.finished).toBe(true);
|
||||
const r = cm.result(s)!;
|
||||
expect(r.ranking.flat().sort()).toEqual(['p1', 'p2']);
|
||||
expect(r.summary).toContain('1등');
|
||||
expect(s.final!.turns.map((t) => t.drawer)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
test('wordChoice off → 바로 그리기(무작위 1개)', () => {
|
||||
const s = make(3, { wordChoice: false });
|
||||
expect(s.turn.phase).toBe('drawing');
|
||||
expect(s.turn.word).not.toBeNull();
|
||||
expect(view(s, s.turn.drawer).choices).toBeNull();
|
||||
});
|
||||
|
||||
test('출제자 연결 끊김 10초 → 턴 종료(출제자 0점, 정답 공개), 다음 출제 차례는 건너뜀', () => {
|
||||
let s = make(3, { rounds: 2 });
|
||||
const d = s.turn.drawer;
|
||||
// The room times out a disconnected drawer at once while choosing (disconnectGraceMs 0) → auto pick + away mark.
|
||||
expect(cm.disconnectGraceMs!(s)).toBe(0);
|
||||
s = act(s, d, { type: 'tick' }, T0 + 500);
|
||||
expect(s.turn.phase).toBe('drawing');
|
||||
expect(s.away).toEqual([d]);
|
||||
expect(cm.disconnectGraceMs!(s)).toBe(10_000);
|
||||
expect(cm.timeoutNotBefore!(s)).toBe(T0 + 500 + 10_000);
|
||||
s = act(s, guessers(s)[0]!, guess(s.turn.word!.word), T0 + 3000);
|
||||
s = act(s, d, { type: 'tick' }, T0 + 10_500);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('drawerAway');
|
||||
expect(s.scores[d]).toBe(0);
|
||||
expect(view(s, guessers(s)[1]!).word).toBe(s.turn.word!.word);
|
||||
// Skipped while away: the next drawers are the others.
|
||||
const seen: string[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
|
||||
seen.push(s.turn.drawer);
|
||||
s = act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, s.turn.startedAt + 100);
|
||||
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
|
||||
}
|
||||
expect(seen).not.toContain(d);
|
||||
// "돌아왔어요" clears the mark.
|
||||
expect(reason(s, d, { type: 'back' })).toBeNull();
|
||||
s = act(s, d, { type: 'back' }, s.turn.endsAt - 1);
|
||||
expect(s.away).toEqual([]);
|
||||
expect(reason(s, d, { type: 'back' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('나간 사람: 출제자가 나가면 턴 종료, 2명 미만이면 게임 종료', () => {
|
||||
let s = drawWith(make(3), '기린');
|
||||
const d = s.turn.drawer;
|
||||
expect(cm.onLeave!(s, d)).toEqual({ type: 'leave' });
|
||||
s = act(s, d, { type: 'leave' }, T0 + 3000);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('drawerLeft');
|
||||
expect(cm.onLeave!(s, d)).toBeNull();
|
||||
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.turn.drawer).not.toBe(d);
|
||||
const other = s.order.find((p) => p !== d && p !== s.turn.drawer)!;
|
||||
s = act(s, other, { type: 'leave' }, s.turn.startedAt + 100);
|
||||
expect(s.finished).toBe(true);
|
||||
const r = cm.result(s)!;
|
||||
expect(r.ranking.at(-1)).toContain(d);
|
||||
});
|
||||
|
||||
test('남은 맞히는 사람이 모두 맞힌 상태에서 못 맞힌 사람이 나가면 바로 공개', () => {
|
||||
let s = drawWith(make(4), '기린');
|
||||
const [a, b, c] = guessers(s) as [string, string, string];
|
||||
s = act(s, a, guess('기린'), T0 + 2000);
|
||||
s = act(s, b, guess('기린'), T0 + 2100);
|
||||
s = act(s, c, { type: 'leave' }, T0 + 2200);
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(s.turn.endReason).toBe('allSolved');
|
||||
});
|
||||
|
||||
test('answererDraws(firstOnly): 정답자가 다음 출제자', () => {
|
||||
let s = drawWith(make(4, { guessMode: 'firstOnly', answererDraws: true }), '기린');
|
||||
const g = guessers(s)[2]!;
|
||||
s = act(s, g, guess('기린'), T0 + 2000);
|
||||
s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(s.turn.drawer).toBe(g);
|
||||
});
|
||||
|
||||
test('최종 순위: 총점 → 정답 횟수 → 공동 순위', () => {
|
||||
const s = structuredClone(make(4));
|
||||
s.scores = { p1: 20, p2: 20, p3: 20, p4: 5 };
|
||||
s.correctCounts = { p1: 1, p2: 2, p3: 1, p4: 0 };
|
||||
s.turn.phase = 'reveal';
|
||||
s.turnsPlayed = s.totalTurns - 1;
|
||||
s.round = s.options.rounds;
|
||||
s.turn.drawer = s.order.at(-1)!;
|
||||
const r = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt);
|
||||
expect(r.finished).toBe(true);
|
||||
expect(cm.result(r)!.ranking.map((g) => [...g].sort())).toEqual([['p2'], ['p1', 'p3'], ['p4']]);
|
||||
expect(r.final!.ranking.map((x) => x.rank)).toEqual([1, 2, 2, 4]);
|
||||
});
|
||||
|
||||
test('단어 풀: 분야·난이도·추가 단어(필터), 부족하면 시작 거절', () => {
|
||||
expect(buildPool(opts()).length).toBe(BUILTIN_WORDS.length);
|
||||
expect(BUILTIN_WORDS.length).toBeGreaterThanOrEqual(600);
|
||||
const animalsEasy = buildPool(opts({ categories: { ...opts().categories, food: false, object: false, vehicle: false, nature: false, place: false, job: false, sport: false, body: false, season: false }, difficulty: 'easy' }));
|
||||
expect(animalsEasy.every((w) => w.category === 'animal' && w.difficulty === 'easy')).toBe(true);
|
||||
const custom = opts({ customOnly: true, customWords: '우리집 강아지\n돌멩이\n돌멩이\nABC\n시발\n가나다라마바사아자차카\n붕어빵' });
|
||||
expect(buildPool(custom).map((w) => w.word)).toEqual(['돌멩이', 'ABC', '붕어빵']);
|
||||
expect(cm.playerCountError!(custom, 3)).toContain('추가 단어');
|
||||
expect(cm.playerCountError!(opts(), 10)).toBeNull();
|
||||
expect(cm.view(make(2, { customWords: '돌멩이' }), 'p1').options.customWordCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- leaks
|
||||
|
||||
/** Secrets a non-solver guesser / spectator must never see during drawing (§8.3, §10). */
|
||||
function hiddenFrom(s: CatchmindState): string[] {
|
||||
const t = s.turn;
|
||||
// The next few pool words (checking all ~700 on every step would dominate the run time).
|
||||
const out = s.wordPool.slice(0, 12).map((w) => w.word);
|
||||
if (t.phase === 'choosing') out.push(...t.choices.map((w) => w.word));
|
||||
if (t.phase === 'drawing' && t.word) out.push(t.word.word, ...t.word.aliases);
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkLeaks(s: CatchmindState, events: GameEvent[] = []): void {
|
||||
if (s.finished || s.turn.phase === 'reveal') return;
|
||||
const secret = hiddenFrom(s);
|
||||
const solversChat = s.chat.filter((m) => m.turn === s.turn.no && m.audience === 'solvers').map((m) => m.id);
|
||||
const evJson = JSON.stringify(events);
|
||||
for (const w of secret) if (evJson.includes(JSON.stringify(w))) throw new Error(`event leaks ${w}`);
|
||||
const outsiders: (PlayerId | null)[] = [null, ...s.order.filter((p) => p !== s.turn.drawer && !s.turn.solvers.some((x) => x.id === p))];
|
||||
for (const p of outsiders) {
|
||||
for (const v of [cm.view(s, p), cm.syncView!(s, p)]) {
|
||||
const json = JSON.stringify(v);
|
||||
for (const w of secret) {
|
||||
// Their own chat lines may legitimately contain anything they typed.
|
||||
if (json.includes(JSON.stringify(w))) throw new Error(`view of ${p} leaks "${w}"`);
|
||||
}
|
||||
if (v.word !== null || v.choices !== null || v.category !== null) throw new Error(`view of ${p} has the answer`);
|
||||
if (v.chat.some((m) => solversChat.includes(m.id))) throw new Error(`view of ${p} has solvers chat`);
|
||||
if (v.chat.some((m) => m.from !== p && (m.kind === 'close' || m.kind === 'blocked'))) throw new Error(`view of ${p} has others' private lines`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
interface Run {
|
||||
state: CatchmindState;
|
||||
trace: { p: PlayerId; a: CatchmindAction; now: number }[];
|
||||
steps: number;
|
||||
}
|
||||
|
||||
const CHEAT = ['ㅇ', '아까', '모르겠어요', '정답?'];
|
||||
|
||||
/** Fast loop: random actor (anyone, not just active), random legal action incl. near-misses; 0.2–3s per step. */
|
||||
function fastPlayout(players: PlayerId[], options: CatchmindOptions, seed: RngState, maxSteps: number, leaks: boolean): Run {
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
const pick = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let now = 1_700_000_000_000;
|
||||
let s = cm.setup({ players, options, rng, now });
|
||||
const poolSize = s.wordPool.length + s.turn.choices.length + (s.turn.word ? 1 : 0);
|
||||
const trace: Run['trace'] = [];
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
invariants(s, poolSize);
|
||||
if (cm.result(s)) return { state: s, trace, steps: step };
|
||||
// Timer: the drawer's tick when due (as the room would), otherwise a random actor.
|
||||
const due = cm.deadline!(s)!;
|
||||
let p: PlayerId;
|
||||
let a: CatchmindAction;
|
||||
if (now >= due || pick.int(12) === 0) {
|
||||
p = cm.timeoutPlayers!(s)[0]!;
|
||||
a = cm.onTimeout(s, p);
|
||||
now = Math.max(now, Math.min(due, now + 12_000));
|
||||
} else {
|
||||
p = players[pick.int(players.length)]!;
|
||||
const legal = cm.legalActions!(s, p);
|
||||
if (legal.length === 0) continue;
|
||||
a = legal[pick.int(legal.length)]!;
|
||||
if (a.type === 'guess' && pick.int(3) === 0) a = { type: 'guess', text: CHEAT[pick.int(CHEAT.length)]! };
|
||||
}
|
||||
const v = cm.validate(s, p, a, { now });
|
||||
if (!v.ok) {
|
||||
// Rate limits / time-up may reject; nothing else should.
|
||||
if (/천천히|너무 빨리|시간이 다/.test(v.reason)) {
|
||||
now += 400;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`rejected ${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
}
|
||||
const r = cm.apply(s, p, a, { rng, now });
|
||||
if (leaks) checkLeaks(r.state, r.events);
|
||||
s = r.state;
|
||||
trace.push({ p, a, now });
|
||||
now += 200 + pick.int(2800);
|
||||
}
|
||||
return { state: s, trace, steps: maxSteps };
|
||||
}
|
||||
|
||||
function invariants(s: CatchmindState, poolSize: number): void {
|
||||
const t = s.turn;
|
||||
// Word conservation: every pool word is in exactly one place.
|
||||
const used = s.history.map((h) => h.word).filter(Boolean);
|
||||
const current = t.phase !== 'reveal' && t.word ? [t.word.word] : [];
|
||||
const all = [...s.wordPool.map((w) => w.word), ...s.spare.map((w) => w.word), ...t.choices.map((w) => w.word), ...current, ...used];
|
||||
if (all.length !== poolSize) throw new Error(`word count ${all.length} != ${poolSize}`);
|
||||
if (new Set(all).size !== all.length) throw new Error('word duplicated');
|
||||
// Score conservation.
|
||||
const total = Object.values(s.scores).reduce((x, y) => x + y, 0);
|
||||
let expected = t.phase === 'reveal' || s.finished ? 0 : t.solvers.reduce((x, y) => x + y.points, 0);
|
||||
for (const h of s.history) expected += h.drawerPoints + h.solverPoints.reduce((x, y) => x + y, 0);
|
||||
if (total !== expected) throw new Error(`scores ${total} != ${expected}`);
|
||||
const correct = Object.values(s.correctCounts).reduce((x, y) => x + y, 0);
|
||||
if (correct !== s.history.reduce((x, h) => x + h.solvers.length, 0) + (t.phase === 'reveal' || s.finished ? 0 : t.solvers.length)) throw new Error('correct count');
|
||||
if (t.totalPoints > 30_000 || t.live < 0) throw new Error('canvas limits');
|
||||
if (s.round > s.options.rounds) throw new Error('round overflow');
|
||||
}
|
||||
|
||||
describe('무작위 대국(§10, docs/09 §7)', () => {
|
||||
test('1,000판: 예외 없음, 단어·점수 보존, 종료, 결정성', () => {
|
||||
const t0 = performance.now();
|
||||
let finished = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const pk = SeededRng.fromSeed(seedN(9000 + i));
|
||||
const n = 2 + pk.int(9);
|
||||
const options = opts({
|
||||
guessMode: pk.int(3) === 0 ? 'firstOnly' : 'multi',
|
||||
rounds: (1 + pk.int(3)) as 1 | 2 | 3,
|
||||
drawSeconds: ([60, 80, 100, 120] as const)[pk.int(4)]!,
|
||||
wordChoice: pk.int(4) !== 0,
|
||||
hints: (['none', 'basic', 'full'] as const)[pk.int(3)]!,
|
||||
letterReport: pk.int(3) !== 0,
|
||||
answererDraws: pk.int(2) === 0,
|
||||
speedBonus: pk.int(3) === 0,
|
||||
difficulty: (['easy', 'normal', 'mixed'] as const)[pk.int(3)]!,
|
||||
});
|
||||
const run = fastPlayout(P(n), options, seedN(i), 4000, i < 150);
|
||||
if (run.state.finished) finished++;
|
||||
else throw new Error(`seed ${i}: not finished after ${run.steps} steps`);
|
||||
if (i % 100 === 0) {
|
||||
const again = fastPlayout(P(n), options, seedN(i), 4000, false);
|
||||
expect(JSON.stringify(again.state)).toBe(JSON.stringify(run.state));
|
||||
}
|
||||
}
|
||||
expect(finished).toBe(1000);
|
||||
expect(performance.now() - t0).toBeLessThan(55_000);
|
||||
}, 60_000);
|
||||
|
||||
test('엔진 randomPlayout(매 단계 직렬화·secretsOf 누출 검사)', () => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const n = 2 + (i % 9);
|
||||
const r = randomPlayout(cm, { players: P(n), options: opts({ rounds: 1 }), seed: seedN(700 + i), maxSteps: 5000, maxApplyMs: 20 });
|
||||
expect(r.finished).toBe(true);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('같은 시드 + 액션 로그 재생 → 점수·그림 기록 동일', () => {
|
||||
const options = opts({ rounds: 2 });
|
||||
const run = fastPlayout(P(5), options, seedN(77), 4000, false);
|
||||
const rngR = SeededRng.fromSeed(seedN(77));
|
||||
let s = cm.setup({ players: P(5), options, rng: rngR, now: 1_700_000_000_000 });
|
||||
const canvases: string[] = [];
|
||||
for (const { p, a, now } of run.trace) {
|
||||
expect(cm.validate(s, p, a, { now }).ok).toBe(true);
|
||||
s = cm.apply(s, p, a, { rng: rngR, now }).state;
|
||||
canvases.push(JSON.stringify(s.turn.canvas));
|
||||
}
|
||||
expect(s.scores).toEqual(run.state.scores);
|
||||
expect(JSON.stringify(s)).toBe(JSON.stringify(run.state));
|
||||
expect(canvases.some((c) => c.length > 50)).toBe(true);
|
||||
});
|
||||
|
||||
test('같은 단어는 한 게임에서 두 번 나오지 않음(풀이 딱 맞을 때 포함)', () => {
|
||||
const words = Array.from({ length: 6 }, (_, i) => `단어${'가나다라마바'[i]}`).join('\n');
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const run = fastPlayout(P(3), opts({ customOnly: true, customWords: words, rounds: 2 }), seedN(300 + i), 4000, false);
|
||||
const answers = run.state.history.map((h) => h.word).filter(Boolean);
|
||||
expect(new Set(answers).size).toBe(answers.length);
|
||||
expect(run.state.finished).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('onTimeout은 모든 상태에서 합법', () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const run = fastPlayout(P(2 + (i % 5)), opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), seedN(50 + i), 4000, false);
|
||||
const rngR = SeededRng.fromSeed(seedN(50 + i));
|
||||
let s = cm.setup({ players: P(2 + (i % 5)), options: opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), rng: rngR, now: 1_700_000_000_000 });
|
||||
for (const { p, a, now } of run.trace) {
|
||||
for (const q of cm.timeoutPlayers!(s)) expect(cm.validate(s, q, cm.onTimeout(s, q), { now }).ok).toBe(true);
|
||||
s = cm.apply(s, p, a, { rng: rngR, now }).state;
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
791
packages/games/src/catchmind/index.ts
Normal file
791
packages/games/src/catchmind/index.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
/** 그림 맞히기 (docs/games/catchmind.md). Pure and deterministic; state is JSON; time only via ctx.now. */
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||
import WORDS from './words.ko.json';
|
||||
import { cleanText, initialOf, judge, maskBanned, normalize, parseCustomWords } from './text';
|
||||
import {
|
||||
BOARD,
|
||||
CATEGORIES,
|
||||
COLOR_COUNT,
|
||||
DRAW_MSGS_PER_SEC,
|
||||
GUESS_MSGS_PER_SEC,
|
||||
MAX_BATCH_POINTS,
|
||||
MAX_TURN_OPS,
|
||||
MAX_TURN_POINTS,
|
||||
WIDTHS,
|
||||
catchmindActionSchema,
|
||||
catchmindOptionsSchema,
|
||||
type CanvasOp,
|
||||
type CatchmindAction,
|
||||
type CatchmindFinal,
|
||||
type CatchmindOptions,
|
||||
type CatchmindState,
|
||||
type CatchmindView,
|
||||
type ChatEntry,
|
||||
type ChatView,
|
||||
type RankRow,
|
||||
type Turn,
|
||||
type TurnEndReason,
|
||||
type WordEntry,
|
||||
} from './types';
|
||||
|
||||
export * from './types';
|
||||
export { judge, normalize, chosung, jamo, levenshtein, parseCustomWords, isCompatJamoOnly } from './text';
|
||||
|
||||
type S = CatchmindState;
|
||||
type A = CatchmindAction;
|
||||
|
||||
export const CHOOSE_MS = 10_000;
|
||||
export const REVEAL_MS = 5_000;
|
||||
/** §5.2 / §8.5: a disconnected drawer ends the turn after this long. */
|
||||
export const DRAWER_AWAY_MS = 10_000;
|
||||
export const GUESS_MAX = 40;
|
||||
export const CHAT_MAX = 100;
|
||||
const CHAT_KEEP = 80;
|
||||
const CHAT_VIEW = 50;
|
||||
|
||||
export const BUILTIN_WORDS = WORDS as WordEntry[];
|
||||
|
||||
// ---------------------------------------------------------------- word pool
|
||||
|
||||
/** §4.2: the words a game may use with these options (built-in filtered + host words), before shuffling. */
|
||||
export function buildPool(o: CatchmindOptions): WordEntry[] {
|
||||
const custom: WordEntry[] = parseCustomWords(o.customWords).map((word) => ({ word, aliases: [], category: 'custom', difficulty: 'normal' }));
|
||||
if (o.customOnly) return custom;
|
||||
const anyCat = CATEGORIES.some((c) => o.categories[c]);
|
||||
const builtin = BUILTIN_WORDS.filter(
|
||||
(w) => (!anyCat || o.categories[w.category as (typeof CATEGORIES)[number]]) && (o.difficulty === 'mixed' || w.difficulty === o.difficulty),
|
||||
);
|
||||
const seen = new Set(builtin.flatMap((w) => [w.word, ...w.aliases].map(normalize)));
|
||||
return [...builtin, ...custom.filter((w) => !seen.has(normalize(w.word)))];
|
||||
}
|
||||
|
||||
const turnsFor = (o: CatchmindOptions, players: number): number => o.rounds * players;
|
||||
|
||||
function takeWords(s: S, n: number): WordEntry[] {
|
||||
const out: WordEntry[] = [];
|
||||
while (out.length < n && s.wordPool.length > 0) out.push(s.wordPool.shift()!);
|
||||
while (out.length < n && s.spare.length > 0) out.push(s.spare.shift()!);
|
||||
if (out.length === 0) throw new Error('catchmind: word pool exhausted');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const answersOf = (w: WordEntry): string[] => [w.word, ...w.aliases];
|
||||
const isSolver = (t: Turn, p: PlayerId): boolean => t.solvers.some((x) => x.id === p);
|
||||
const letters = (word: string): number => [...word].filter((c) => !/\s/u.test(c)).length;
|
||||
|
||||
function clone(s: S): S {
|
||||
const t = s.turn;
|
||||
return {
|
||||
...s,
|
||||
scores: { ...s.scores },
|
||||
correctCounts: { ...s.correctCounts },
|
||||
wordPool: [...s.wordPool],
|
||||
spare: [...s.spare],
|
||||
turn: { ...t, choices: [...t.choices], solvers: [...t.solvers], reports: [...t.reports] },
|
||||
chat: [...s.chat],
|
||||
msgTimes: { ...s.msgTimes },
|
||||
away: [...s.away],
|
||||
left: [...s.left],
|
||||
history: [...s.history],
|
||||
};
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
now: number;
|
||||
rng: Rng;
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
function addChat(s: S, e: Omit<ChatEntry, 'id' | 'turn'>): void {
|
||||
s.chatSeq += 1;
|
||||
s.chat.push({ id: s.chatSeq, turn: s.turn.no, ...e });
|
||||
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
|
||||
}
|
||||
|
||||
function markAway(s: S, p: PlayerId): void {
|
||||
if (!s.away.includes(p)) s.away.push(p);
|
||||
}
|
||||
|
||||
/** Players whose guesses the drawing waits for (§5.2 multi; disconnected/away and left players excluded, §8.5). */
|
||||
function waitingGuessers(s: S): PlayerId[] {
|
||||
return s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p) && !s.away.includes(p));
|
||||
}
|
||||
|
||||
export function reportsNeeded(s: S): number {
|
||||
const voters = s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p)).length;
|
||||
return Math.floor(voters / 2) + 1;
|
||||
}
|
||||
|
||||
/** Next hint time while drawing (`hints: full` only): 50% → initials, 75% → one letter (skipped for ≤ 2 letters). */
|
||||
export function nextHintAt(s: S): number | null {
|
||||
const t = s.turn;
|
||||
if (s.finished || t.phase !== 'drawing' || s.options.hints !== 'full' || !t.word) return null;
|
||||
const dur = t.endsAt - t.startedAt;
|
||||
if (t.hintStage === 0) return t.startedAt + Math.round(dur * 0.5);
|
||||
if (t.hintStage === 1 && letters(t.word.word) > 2) return t.startedAt + Math.round(dur * 0.75);
|
||||
return null;
|
||||
}
|
||||
|
||||
function rateOk(times: readonly number[] | undefined, perSec: number, now: number | undefined): boolean {
|
||||
if (now === undefined || !times || times.length < perSec) return true;
|
||||
return now - times[times.length - perSec]! >= 1000;
|
||||
}
|
||||
|
||||
function stamp(s: S, p: PlayerId, now: number): void {
|
||||
s.msgTimes[p] = [...(s.msgTimes[p] ?? []).slice(-(GUESS_MSGS_PER_SEC - 1)), now];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- turn flow
|
||||
|
||||
function newTurn(no: number, drawer: PlayerId, choices: WordEntry[], now: number): Turn {
|
||||
return {
|
||||
no,
|
||||
phase: 'choosing',
|
||||
drawer,
|
||||
choices,
|
||||
word: null,
|
||||
startedAt: now,
|
||||
endsAt: now + CHOOSE_MS,
|
||||
hintStage: 0,
|
||||
revealIndex: null,
|
||||
solvers: [],
|
||||
reports: [],
|
||||
canvas: [],
|
||||
totalPoints: 0,
|
||||
lastStroke: 0,
|
||||
lastBatch: -1,
|
||||
open: false,
|
||||
live: 0,
|
||||
drawTimes: [],
|
||||
drawerPoints: 0,
|
||||
endReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function startTurn(s: S, drawer: PlayerId, ctx: Ctx): void {
|
||||
const choices = takeWords(s, s.options.wordChoice ? 3 : 1);
|
||||
s.turn = newTurn(s.turnsPlayed + 1, drawer, choices, ctx.now);
|
||||
ctx.events.push({ type: 'turn', no: s.turn.no, drawer, round: s.round });
|
||||
if (!s.options.wordChoice) beginDrawing(s, 0, ctx);
|
||||
}
|
||||
|
||||
function beginDrawing(s: S, index: number, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
t.word = t.choices[index]!;
|
||||
s.spare.push(...t.choices.filter((_, i) => i !== index));
|
||||
t.choices = [];
|
||||
t.phase = 'drawing';
|
||||
t.startedAt = ctx.now;
|
||||
t.endsAt = ctx.now + s.options.drawSeconds * 1000;
|
||||
t.hintStage = 0;
|
||||
ctx.events.push({ type: 'drawing', no: t.no, drawer: t.drawer });
|
||||
}
|
||||
|
||||
function advanceHint(s: S, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
if (t.hintStage === 0) {
|
||||
t.hintStage = 1;
|
||||
} else if (t.hintStage === 1) {
|
||||
const idx = [...t.word!.word].map((c, i) => (/\s/u.test(c) ? -1 : i)).filter((i) => i >= 0);
|
||||
t.revealIndex = idx[ctx.rng.int(idx.length)]!;
|
||||
t.hintStage = 2;
|
||||
}
|
||||
ctx.events.push({ type: 'hint', stage: t.hintStage });
|
||||
}
|
||||
|
||||
/** Drawing (or choosing) is over: drawer points, reveal for 5 s (§5.1, §6). */
|
||||
function endDrawing(s: S, reason: TurnEndReason, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
const o = s.options;
|
||||
let dp = 0;
|
||||
if (reason !== 'report' && reason !== 'drawerAway' && reason !== 'drawerLeft') {
|
||||
dp = o.guessMode === 'multi' ? Math.min(12, 2 * t.solvers.length) : t.solvers.length > 0 ? 5 : 0;
|
||||
}
|
||||
if (t.phase === 'choosing') {
|
||||
// Nobody saw these candidates being drawn; keep them for later turns.
|
||||
s.spare.push(...t.choices);
|
||||
t.choices = [];
|
||||
}
|
||||
s.scores[t.drawer] = (s.scores[t.drawer] ?? 0) + dp;
|
||||
t.drawerPoints = dp;
|
||||
t.endReason = reason;
|
||||
t.phase = 'reveal';
|
||||
t.open = false;
|
||||
t.startedAt = ctx.now;
|
||||
t.endsAt = ctx.now + REVEAL_MS;
|
||||
s.history.push({ no: t.no, drawer: t.drawer, word: t.word?.word ?? '', solvers: t.solvers.map((x) => x.id), solverPoints: t.solvers.map((x) => x.points), drawerPoints: dp, reason });
|
||||
ctx.events.push({ type: 'reveal', no: t.no, word: t.word?.word ?? null, reason, drawerPoints: dp });
|
||||
}
|
||||
|
||||
/** Next drawer clockwise after `from`, skipping players who left and (if anyone else is there) away players. */
|
||||
function nextInOrder(s: S, from: PlayerId): { id: PlayerId; wrapped: boolean } | null {
|
||||
const n = s.order.length;
|
||||
const i = s.order.indexOf(from);
|
||||
for (const skipAway of [true, false]) {
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const q = s.order[(i + k) % n]!;
|
||||
if (s.left.includes(q) || (skipAway && s.away.includes(q))) continue;
|
||||
return { id: q, wrapped: i + k >= n };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextTurn(s: S, ctx: Ctx): void {
|
||||
const o = s.options;
|
||||
s.turnsPlayed += 1;
|
||||
const remaining = s.order.filter((p) => !s.left.includes(p));
|
||||
if (remaining.length < 2) return finish(s, ctx);
|
||||
let next: PlayerId;
|
||||
if (o.guessMode === 'firstOnly' && o.answererDraws) {
|
||||
if (s.turnsPlayed >= s.totalTurns) return finish(s, ctx);
|
||||
const first = s.turn.solvers[0]?.id;
|
||||
next = first && !s.left.includes(first) && !s.away.includes(first) ? first : nextInOrder(s, s.turn.drawer)!.id;
|
||||
s.round = Math.min(o.rounds, Math.floor(s.turnsPlayed / s.order.length) + 1);
|
||||
} else {
|
||||
const r = nextInOrder(s, s.turn.drawer)!;
|
||||
if (r.wrapped) {
|
||||
if (s.round >= o.rounds) return finish(s, ctx);
|
||||
s.round += 1;
|
||||
}
|
||||
next = r.id;
|
||||
}
|
||||
startTurn(s, next, ctx);
|
||||
}
|
||||
|
||||
function finish(s: S, ctx: Ctx): void {
|
||||
if (s.finished) return;
|
||||
s.finished = true;
|
||||
s.turn.open = false;
|
||||
const by = (a: PlayerId, b: PlayerId) => (s.scores[b] ?? 0) - (s.scores[a] ?? 0) || (s.correctCounts[b] ?? 0) - (s.correctCounts[a] ?? 0);
|
||||
const stay = s.order.filter((p) => !s.left.includes(p)).sort(by);
|
||||
const gone = s.order.filter((p) => s.left.includes(p)).sort(by);
|
||||
const ranking: RankRow[] = [];
|
||||
for (const group of [stay, gone]) {
|
||||
group.forEach((id, i) => {
|
||||
const prev = ranking[ranking.length - 1];
|
||||
const tie = i > 0 && prev && prev.score === (s.scores[id] ?? 0) && prev.correct === (s.correctCounts[id] ?? 0);
|
||||
ranking.push({ rank: tie ? prev.rank : ranking.length + 1, id, score: s.scores[id] ?? 0, correct: s.correctCounts[id] ?? 0 });
|
||||
});
|
||||
}
|
||||
const top = ranking.filter((r) => r.rank === 1);
|
||||
const most = Math.max(0, ...ranking.map((r) => r.correct));
|
||||
let summary = top.length > 1 ? `공동 1등 ${top.length}명, ${top[0]!.score}점이에요!` : `1등은 ${top[0]?.score ?? 0}점이에요!`;
|
||||
if (most > 0) summary += top.length === 1 && top[0]!.correct === most ? ` 1등이 가장 많이 맞혔어요(${most}번).` : ` 가장 많이 맞힌 횟수는 ${most}번이에요.`;
|
||||
else summary += ' 이번 게임에서는 아무도 맞히지 못했어요.';
|
||||
s.final = {
|
||||
ranking,
|
||||
turns: s.history.map((h) => ({ drawer: h.drawer, word: h.word, solvers: h.solvers })),
|
||||
summary,
|
||||
} satisfies CatchmindFinal;
|
||||
ctx.events.push({ type: 'gameEnd' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
function guess(s: S, p: PlayerId, raw: string, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
const text = cleanText(raw);
|
||||
stamp(s, p, ctx.now);
|
||||
if (isSolver(t, p)) {
|
||||
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'solvers', kind: 'chat' });
|
||||
return;
|
||||
}
|
||||
const j = judge(text, answersOf(t.word!), s.options.closeHint);
|
||||
if (j === 'correct') {
|
||||
const k = t.solvers.length + 1;
|
||||
let points = s.options.guessMode === 'multi' ? Math.max(10 - 2 * (k - 1), 4) : 10;
|
||||
if (s.options.speedBonus) points += Math.round((5 * Math.max(0, t.endsAt - ctx.now)) / Math.max(1, t.endsAt - t.startedAt));
|
||||
t.solvers.push({ id: p, at: ctx.now, points });
|
||||
s.scores[p] = (s.scores[p] ?? 0) + points;
|
||||
s.correctCounts[p] = (s.correctCounts[p] ?? 0) + 1;
|
||||
addChat(s, { from: p, text: '', at: ctx.now, audience: 'all', kind: 'correct' });
|
||||
ctx.events.push({ type: 'correct', player: p, points, rank: k });
|
||||
if (s.options.guessMode === 'firstOnly') endDrawing(s, 'first', ctx);
|
||||
else if (waitingGuessers(s).every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
|
||||
return;
|
||||
}
|
||||
if (j === 'chosung' || j === 'contains') {
|
||||
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'self', kind: 'blocked', note: j });
|
||||
return;
|
||||
}
|
||||
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'all', kind: j === 'close' ? 'close' : 'chat' });
|
||||
}
|
||||
|
||||
function tick(s: S, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
if (t.phase === 'choosing') {
|
||||
// Only a disconnected drawer is timed out before the 10 s are up (room grace hook): skip them next time.
|
||||
if (ctx.now < t.endsAt) markAway(s, t.drawer);
|
||||
beginDrawing(s, 0, ctx);
|
||||
return;
|
||||
}
|
||||
if (t.phase === 'drawing') {
|
||||
let hinted = false;
|
||||
for (let h = nextHintAt(s); h !== null && ctx.now >= h; h = nextHintAt(s)) {
|
||||
advanceHint(s, ctx);
|
||||
hinted = true;
|
||||
}
|
||||
if (ctx.now >= t.endsAt) return endDrawing(s, 'time', ctx);
|
||||
if (hinted) return;
|
||||
// Nothing was due: the drawer has been disconnected for DRAWER_AWAY_MS (§5.2) → 0 points, answer revealed.
|
||||
markAway(s, t.drawer);
|
||||
return endDrawing(s, 'drawerAway', ctx);
|
||||
}
|
||||
nextTurn(s, ctx);
|
||||
}
|
||||
|
||||
function leave(s: S, p: PlayerId, ctx: Ctx): void {
|
||||
const t = s.turn;
|
||||
s.left.push(p);
|
||||
s.away = s.away.filter((q) => q !== p);
|
||||
ctx.events.push({ type: 'left', player: p });
|
||||
const remaining = s.order.filter((q) => !s.left.includes(q));
|
||||
if (remaining.length < 2) {
|
||||
if (t.phase !== 'reveal') endDrawing(s, p === t.drawer ? 'drawerLeft' : 'time', ctx);
|
||||
return finish(s, ctx);
|
||||
}
|
||||
if (t.phase === 'reveal') return;
|
||||
if (p === t.drawer) return endDrawing(s, 'drawerLeft', ctx);
|
||||
if (t.phase === 'drawing') {
|
||||
if (s.options.letterReport && t.reports.length >= reportsNeeded(s)) return endDrawing(s, 'report', ctx);
|
||||
const waiting = waitingGuessers(s);
|
||||
if (s.options.guessMode === 'multi' && t.solvers.length > 0 && waiting.every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing ops
|
||||
|
||||
/** Absolute points of a `p` op (first absolute, then deltas), or null when malformed / off the board. */
|
||||
export function decodePoints(d: readonly number[]): number[] | null {
|
||||
if (d.length < 2 || d.length % 2 !== 0 || d.length > MAX_BATCH_POINTS * 2) return null;
|
||||
const out: number[] = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i < d.length; i += 2) {
|
||||
x = i === 0 ? d[0]! : x + d[i]!;
|
||||
y = i === 0 ? d[1]! : y + d[i + 1]!;
|
||||
if (x < 0 || x > BOARD || y < 0 || y > BOARD) return null;
|
||||
out.push(x, y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateDraw(s: S, actor: PlayerId, op: CanvasOp, now: number | undefined): Validation {
|
||||
const t = s.turn;
|
||||
if (actor !== t.drawer) return fail('그림은 출제자만 그릴 수 있어요.');
|
||||
if (t.phase !== 'drawing') return fail('지금은 그릴 수 없어요.');
|
||||
if (!rateOk(t.drawTimes, DRAW_MSGS_PER_SEC, now)) return fail('그림을 너무 빨리 보내고 있어요. 잠시 뒤에 이어서 그려 주세요.');
|
||||
if (t.canvas.length >= MAX_TURN_OPS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
|
||||
switch (op.k) {
|
||||
case 'b':
|
||||
if (op.s !== t.lastStroke + 1) return fail('획 번호가 맞지 않아요.');
|
||||
if (op.c < 0 || op.c >= COLOR_COUNT || op.w < 0 || op.w >= WIDTHS.length) return fail('없는 색이나 굵기예요.');
|
||||
return ok;
|
||||
case 'p': {
|
||||
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
|
||||
if (op.q !== t.lastBatch + 1) return fail('점 묶음 순서가 맞지 않아요.');
|
||||
const pts = decodePoints(op.d);
|
||||
if (!pts) return fail(op.d.length > MAX_BATCH_POINTS * 2 || op.d.length % 2 !== 0 ? '한 번에 보낼 수 있는 점 개수를 넘었어요.' : '그림판 밖의 점이에요.');
|
||||
if (t.totalPoints + pts.length / 2 > MAX_TURN_POINTS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'e':
|
||||
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
|
||||
return ok;
|
||||
case 'u':
|
||||
case 'x':
|
||||
if (t.live <= 0) return fail('지울 그림이 없어요.');
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
/** Shallow update: the canvas grows by one op; nothing else is deep-copied (§5.5 "깊은 복사하지 않는다"). */
|
||||
function applyDraw(s0: S, actor: PlayerId, op: CanvasOp, now: number): { state: S; events: GameEvent[] } {
|
||||
const t0 = s0.turn;
|
||||
const t: Turn = { ...t0, canvas: [...t0.canvas, op], drawTimes: [...t0.drawTimes.slice(-(DRAW_MSGS_PER_SEC - 1)), now] };
|
||||
switch (op.k) {
|
||||
case 'b':
|
||||
t.lastStroke = op.s;
|
||||
t.lastBatch = -1;
|
||||
t.open = true;
|
||||
t.live += 1;
|
||||
break;
|
||||
case 'p':
|
||||
t.lastBatch = op.q;
|
||||
t.totalPoints += op.d.length / 2;
|
||||
break;
|
||||
case 'e':
|
||||
t.open = false;
|
||||
break;
|
||||
case 'u':
|
||||
t.live -= 1;
|
||||
t.open = false;
|
||||
break;
|
||||
case 'x':
|
||||
t.live = 0;
|
||||
t.open = false;
|
||||
break;
|
||||
}
|
||||
const state: S = { ...s0, turn: t, away: s0.away.includes(actor) ? s0.away.filter((p) => p !== actor) : s0.away };
|
||||
return { state, events: [{ type: 'canvasOp', turn: t.no, n: t0.canvas.length, op }] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view helpers
|
||||
|
||||
function hintFor(s: S): string[] | null {
|
||||
const t = s.turn;
|
||||
if (s.options.hints === 'none' || !t.word) return null;
|
||||
const full = s.options.hints === 'full';
|
||||
return [...t.word.word].map((ch, i) => {
|
||||
if (/\s/u.test(ch)) return ' ';
|
||||
if (full && t.hintStage >= 2 && i === t.revealIndex) return ch;
|
||||
if (full && t.hintStage >= 1) {
|
||||
const ini = initialOf(ch);
|
||||
return ini === ch ? '○' : ini;
|
||||
}
|
||||
return '○';
|
||||
});
|
||||
}
|
||||
|
||||
function chatFor(s: S, me: PlayerId | null): ChatView[] {
|
||||
const t = s.turn;
|
||||
const insider = !!me && (me === t.drawer || isSolver(t, me));
|
||||
const open = t.phase === 'reveal' || s.finished;
|
||||
const out: ChatView[] = [];
|
||||
for (const e of s.chat) {
|
||||
if (e.audience === 'self' && e.from !== me) continue;
|
||||
if (e.audience === 'solvers' && e.turn === t.no && !open && !insider) continue;
|
||||
const v: ChatView = {
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
text: e.text,
|
||||
at: e.at,
|
||||
kind: e.kind === 'close' && e.from !== me ? 'chat' : e.kind,
|
||||
solversOnly: e.audience === 'solvers',
|
||||
};
|
||||
if (e.note) v.note = e.note;
|
||||
out.push(v);
|
||||
}
|
||||
return out.slice(-CHAT_VIEW);
|
||||
}
|
||||
|
||||
function buildView(s: S, viewer: PlayerId | null, withCanvas: boolean): CatchmindView {
|
||||
const t = s.turn;
|
||||
const o = s.options;
|
||||
const me = viewer && s.order.includes(viewer) ? viewer : null;
|
||||
const isDrawer = me === t.drawer;
|
||||
const solved = !!me && isSolver(t, me);
|
||||
const revealed = t.phase === 'reveal' || s.finished;
|
||||
const knows = isDrawer || solved || revealed;
|
||||
const active = !!me && !s.left.includes(me) && !s.finished;
|
||||
const drawing = t.phase === 'drawing' && !s.finished;
|
||||
const { customWords, ...rest } = o;
|
||||
return {
|
||||
options: { ...rest, customWordCount: parseCustomWords(customWords).length },
|
||||
me,
|
||||
role: !me ? 'spectator' : isDrawer ? 'drawer' : solved ? 'solver' : 'guesser',
|
||||
order: s.order,
|
||||
scores: s.scores,
|
||||
correctCounts: s.correctCounts,
|
||||
round: s.round,
|
||||
rounds: o.rounds,
|
||||
turnNo: t.no,
|
||||
turnsPlayed: s.turnsPlayed,
|
||||
totalTurns: s.totalTurns,
|
||||
phase: s.finished ? 'ended' : t.phase,
|
||||
drawer: t.drawer,
|
||||
startedAt: t.startedAt,
|
||||
endsAt: t.endsAt,
|
||||
phaseMs: t.endsAt - t.startedAt,
|
||||
choices: isDrawer && t.phase === 'choosing' && !s.finished ? t.choices.map((w) => ({ word: w.word, category: w.category, difficulty: w.difficulty })) : null,
|
||||
word: knows && t.word ? t.word.word : null,
|
||||
category: knows && t.word ? t.word.category : null,
|
||||
hint: hintFor(s),
|
||||
hintStage: t.hintStage,
|
||||
nextHintAt: nextHintAt(s),
|
||||
solvers: t.solvers.map((x) => ({ id: x.id, points: x.points })),
|
||||
reports: t.reports.length,
|
||||
reportsNeeded: reportsNeeded(s),
|
||||
reportedByMe: !!me && t.reports.includes(me),
|
||||
canGuess: active && drawing && !isDrawer,
|
||||
canChat: active && (t.phase === 'reveal' || (t.phase === 'choosing' && !isDrawer)),
|
||||
canDraw: active && drawing && isDrawer,
|
||||
canReport: active && drawing && o.letterReport && !isDrawer && !t.reports.includes(me),
|
||||
canReact: active && drawing && isDrawer,
|
||||
canvas: withCanvas ? t.canvas : null,
|
||||
canvasCount: t.canvas.length,
|
||||
nextStroke: t.lastStroke + 1,
|
||||
openStroke: t.open,
|
||||
lastBatch: t.lastBatch,
|
||||
liveStrokes: t.live,
|
||||
pointsUsed: t.totalPoints,
|
||||
pointLimit: MAX_TURN_POINTS,
|
||||
chat: chatFor(s, me),
|
||||
away: s.away,
|
||||
left: s.left,
|
||||
turnPoints:
|
||||
t.phase === 'reveal' || s.finished
|
||||
? Object.fromEntries([...t.solvers.map((x) => [x.id, x.points] as const), [t.drawer, t.drawerPoints] as const])
|
||||
: null,
|
||||
drawerPoints: t.drawerPoints,
|
||||
endReason: t.endReason,
|
||||
history: s.history,
|
||||
result: s.final,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const catchmind: GameDefinition<S, A, CatchmindView, CatchmindOptions> = {
|
||||
id: 'catchmind',
|
||||
nameKo: '그림 맞히기',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 10,
|
||||
stateVersion: 1,
|
||||
defaultOptions: catchmindOptionsSchema.parse({}),
|
||||
optionsSchema: catchmindOptionsSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['optionsSchema'],
|
||||
actionSchema: catchmindActionSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['actionSchema'],
|
||||
/** Guesses and talk go through the game's own filtered chat (§4·§7); the open room chat would leak answers. */
|
||||
ownChat: true,
|
||||
|
||||
playerCountError(options, count) {
|
||||
const have = buildPool(options).length;
|
||||
const need = turnsFor(options, count);
|
||||
if (have >= need) return null;
|
||||
return options.customOnly
|
||||
? `추가 단어가 부족해요. ${need}개 이상 넣어 주세요(지금 ${have}개).`
|
||||
: `단어가 부족해요(${have}개). 분야나 난이도를 넓히거나 라운드를 줄여 주세요.`;
|
||||
},
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 2 || players.length > 10) throw new Error(`catchmind: ${players.length} players not allowed`);
|
||||
// §4: drawing order and the word pool come from the seeded RNG.
|
||||
const order = rng.shuffle(players);
|
||||
const pool = rng.shuffle(buildPool(options));
|
||||
if (pool.length < turnsFor(options, order.length)) throw new Error('catchmind: not enough words');
|
||||
const zero = Object.fromEntries(order.map((p) => [p, 0])) as Record<PlayerId, number>;
|
||||
const s: S = {
|
||||
options,
|
||||
order,
|
||||
scores: { ...zero },
|
||||
correctCounts: { ...zero },
|
||||
wordPool: pool,
|
||||
spare: [],
|
||||
round: 1,
|
||||
turnsPlayed: 0,
|
||||
totalTurns: turnsFor(options, order.length),
|
||||
turn: newTurn(0, order[0]!, [], now),
|
||||
chat: [],
|
||||
chatSeq: 0,
|
||||
msgTimes: {},
|
||||
away: [],
|
||||
left: [],
|
||||
history: [],
|
||||
finished: false,
|
||||
final: null,
|
||||
};
|
||||
startTurn(s, order[0]!, { now, rng, events: [] });
|
||||
return s;
|
||||
},
|
||||
|
||||
validate(s, actor, a, vctx) {
|
||||
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.finished) return fail('게임이 이미 끝났어요.');
|
||||
const t = s.turn;
|
||||
const now = vctx?.now;
|
||||
if (a.type === 'tick') return actor === t.drawer ? ok : fail('시간 초과 대상이 아니에요.');
|
||||
if (a.type === 'leave') return s.left.includes(actor) ? fail('이미 게임에서 나갔어요.') : ok;
|
||||
if (s.left.includes(actor)) return fail('게임에서 나갔어요.');
|
||||
switch (a.type) {
|
||||
case 'chooseWord':
|
||||
if (actor !== t.drawer) return fail('출제자만 단어를 고를 수 있어요.');
|
||||
if (t.phase !== 'choosing') return fail('지금은 단어를 고를 때가 아니에요.');
|
||||
if (a.index >= t.choices.length) return fail('없는 단어예요.');
|
||||
return ok;
|
||||
case 'draw':
|
||||
return validateDraw(s, actor, a.op, now);
|
||||
case 'guess': {
|
||||
if (t.phase !== 'drawing') return fail('지금은 맞힐 때가 아니에요.');
|
||||
if (actor === t.drawer) return fail('출제자는 그림만 그릴 수 있어요. 반응 버튼을 써 주세요.');
|
||||
const len = [...cleanText(a.text)].length;
|
||||
if (len < 1) return fail('내용을 입력해 주세요.');
|
||||
if (len > GUESS_MAX) return fail(`${GUESS_MAX}자까지 쓸 수 있어요.`);
|
||||
if (now !== undefined && now >= t.endsAt) return fail('시간이 다 됐어요.');
|
||||
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
|
||||
return ok;
|
||||
}
|
||||
case 'chat': {
|
||||
if (t.phase === 'drawing') return fail('그리는 동안에는 정답 입력으로 보내 주세요.');
|
||||
if (t.phase === 'choosing' && actor === t.drawer) return fail('단어를 고르는 동안에는 채팅할 수 없어요.');
|
||||
const len = [...cleanText(a.text)].length;
|
||||
if (len < 1) return fail('내용을 입력해 주세요.');
|
||||
if (len > CHAT_MAX) return fail(`${CHAT_MAX}자까지 쓸 수 있어요.`);
|
||||
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
|
||||
return ok;
|
||||
}
|
||||
case 'reaction':
|
||||
if (actor !== t.drawer || t.phase !== 'drawing') return fail('그리는 동안 출제자만 쓸 수 있어요.');
|
||||
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
|
||||
return ok;
|
||||
case 'back':
|
||||
return s.away.includes(actor) ? ok : fail('이미 게임에 참여하고 있어요.');
|
||||
case 'report':
|
||||
if (!s.options.letterReport) return fail('이 방은 신고를 쓰지 않아요.');
|
||||
if (t.phase !== 'drawing') return fail('그리는 동안에만 신고할 수 있어요.');
|
||||
if (actor === t.drawer) return fail('출제자는 신고할 수 없어요.');
|
||||
if (t.reports.includes(actor)) return fail('이미 신고했어요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now, rng }) {
|
||||
if (a.type === 'draw') return applyDraw(prev, actor, a.op, now);
|
||||
const s = clone(prev);
|
||||
const ctx: Ctx = { now, rng, events: [] };
|
||||
if (a.type !== 'tick' && a.type !== 'leave' && s.away.includes(actor)) s.away = s.away.filter((p) => p !== actor);
|
||||
switch (a.type) {
|
||||
case 'tick':
|
||||
tick(s, ctx);
|
||||
break;
|
||||
case 'leave':
|
||||
leave(s, actor, ctx);
|
||||
break;
|
||||
case 'chooseWord':
|
||||
beginDrawing(s, a.index, ctx);
|
||||
break;
|
||||
case 'guess':
|
||||
guess(s, actor, a.text, ctx);
|
||||
break;
|
||||
case 'chat':
|
||||
stamp(s, actor, now);
|
||||
addChat(s, { from: actor, text: maskBanned(cleanText(a.text)), at: now, audience: 'all', kind: 'chat' });
|
||||
break;
|
||||
case 'reaction':
|
||||
stamp(s, actor, now);
|
||||
addChat(s, { from: actor, text: a.code, at: now, audience: 'all', kind: 'reaction' });
|
||||
break;
|
||||
case 'back':
|
||||
ctx.events.push({ type: 'back', player: actor });
|
||||
break;
|
||||
case 'report':
|
||||
s.turn.reports.push(actor);
|
||||
ctx.events.push({ type: 'report', count: s.turn.reports.length, need: reportsNeeded(s) });
|
||||
if (s.turn.reports.length >= reportsNeeded(s)) endDrawing(s, 'report', ctx);
|
||||
break;
|
||||
}
|
||||
return { state: s, events: ctx.events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
return buildView(s, viewer, false);
|
||||
},
|
||||
|
||||
/** (Re)joining or resyncing clients get the whole drawing once (§5.5). */
|
||||
syncView(s, viewer) {
|
||||
return buildView(s, viewer, true);
|
||||
},
|
||||
|
||||
isStreamAction(a) {
|
||||
return a.type === 'draw';
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.finished) return [];
|
||||
const t = s.turn;
|
||||
if (t.phase !== 'drawing') return [t.drawer];
|
||||
return [t.drawer, ...s.order.filter((p) => p !== t.drawer && !s.left.includes(p) && !isSolver(t, p))];
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.finished) return null;
|
||||
const h = nextHintAt(s);
|
||||
return h === null ? s.turn.endsAt : Math.min(h, s.turn.endsAt);
|
||||
},
|
||||
|
||||
/** The drawer's clock drives every server tick (choosing, hints, time up, reveal). */
|
||||
timeoutPlayers(s) {
|
||||
return s.finished ? [] : [s.turn.drawer];
|
||||
},
|
||||
|
||||
/** A drawer who was already away when drawing began still gets DRAWER_AWAY_MS; reveal always lasts its 5 s. */
|
||||
timeoutNotBefore(s) {
|
||||
if (s.finished) return null;
|
||||
const t = s.turn;
|
||||
if (t.phase === 'drawing') return t.startedAt + DRAWER_AWAY_MS;
|
||||
if (t.phase === 'reveal') return t.endsAt;
|
||||
return null;
|
||||
},
|
||||
|
||||
/** §8.5: disconnected drawer → pick at once while choosing, end the turn after 10 s while drawing. */
|
||||
disconnectGraceMs(s) {
|
||||
if (s.finished) return null;
|
||||
if (s.turn.phase === 'choosing') return 0;
|
||||
if (s.turn.phase === 'drawing') return DRAWER_AWAY_MS;
|
||||
return null;
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'tick' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.finished || s.left.includes(player)) return null;
|
||||
return { type: 'leave' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
const f = s.final;
|
||||
if (!f) return null;
|
||||
const groups = new Map<number, PlayerId[]>();
|
||||
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
|
||||
return {
|
||||
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
|
||||
scores: { ...s.scores },
|
||||
summary: f.summary,
|
||||
reason: 'normal',
|
||||
};
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
if (s.finished || !s.order.includes(p)) return [];
|
||||
const t = s.turn;
|
||||
const out: A[] = [];
|
||||
if (p === t.drawer) out.push({ type: 'tick' });
|
||||
if (s.away.includes(p)) out.push({ type: 'back' });
|
||||
if (t.phase === 'choosing') {
|
||||
if (p === t.drawer) t.choices.forEach((_, index) => out.push({ type: 'chooseWord', index }));
|
||||
else out.push({ type: 'chat', text: '안녕하세요' });
|
||||
} else if (t.phase === 'drawing') {
|
||||
if (p === t.drawer) {
|
||||
if (!t.open) out.push({ type: 'draw', op: { k: 'b', s: t.lastStroke + 1, c: t.lastStroke % COLOR_COUNT, w: t.lastStroke % WIDTHS.length, e: t.lastStroke % 5 === 4 ? 1 : 0 } });
|
||||
else {
|
||||
const x = 100 + ((t.lastBatch * 37 + t.lastStroke * 11) % 800);
|
||||
out.push({ type: 'draw', op: { k: 'p', s: t.lastStroke, q: t.lastBatch + 1, d: [x, 900 - (x % 700), 3, -4, 5, 2, -1, 6] } });
|
||||
out.push({ type: 'draw', op: { k: 'e', s: t.lastStroke } });
|
||||
}
|
||||
if (t.live > 0) out.push({ type: 'draw', op: { k: 'u' } }, { type: 'draw', op: { k: 'x' } });
|
||||
out.push({ type: 'reaction', code: 'almost' });
|
||||
} else {
|
||||
out.push({ type: 'guess', text: t.word!.word }, { type: 'guess', text: '모르겠어요' }, { type: 'guess', text: `${t.word!.word} 맞나` }, { type: 'report' });
|
||||
}
|
||||
} else {
|
||||
out.push({ type: 'chat', text: '잘 그렸어요' });
|
||||
}
|
||||
return out.filter((a) => catchmind.validate(s, p, a).ok);
|
||||
},
|
||||
|
||||
/**
|
||||
* Leak-check secrets (§8.3): upcoming pool words are nobody's to see; the drawer's candidates and — until someone
|
||||
* else legitimately knows it — the answer belong to the drawer.
|
||||
*/
|
||||
secretsOf(s, player) {
|
||||
const t = s.turn;
|
||||
const out = s.wordPool.slice(0, 8).map((w) => w.word);
|
||||
if (player === t.drawer && t.phase === 'choosing') out.push(...t.choices.map((w) => w.word));
|
||||
if (player === t.drawer && t.phase === 'drawing' && t.solvers.length === 0 && t.word) out.push(...answersOf(t.word));
|
||||
return out;
|
||||
},
|
||||
};
|
||||
173
packages/games/src/catchmind/text.ts
Normal file
173
packages/games/src/catchmind/text.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/** 그림 맞히기 text rules: answer normalisation, initial consonants, "아까워요" distance (docs/games/catchmind.md §5.3). */
|
||||
|
||||
const CHO = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
|
||||
const JUNG = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
|
||||
const JONG = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
|
||||
/** Compound vowels and double finals split into their parts (§5.3 "겹받침·이중모음도 분해"). */
|
||||
const SPLIT: Record<string, string> = {
|
||||
ㅘ: 'ㅗㅏ',
|
||||
ㅙ: 'ㅗㅐ',
|
||||
ㅚ: 'ㅗㅣ',
|
||||
ㅝ: 'ㅜㅓ',
|
||||
ㅞ: 'ㅜㅔ',
|
||||
ㅟ: 'ㅜㅣ',
|
||||
ㅢ: 'ㅡㅣ',
|
||||
ㄳ: 'ㄱㅅ',
|
||||
ㄵ: 'ㄴㅈ',
|
||||
ㄶ: 'ㄴㅎ',
|
||||
ㄺ: 'ㄹㄱ',
|
||||
ㄻ: 'ㄹㅁ',
|
||||
ㄼ: 'ㄹㅂ',
|
||||
ㄽ: 'ㄹㅅ',
|
||||
ㄾ: 'ㄹㅌ',
|
||||
ㄿ: 'ㄹㅍ',
|
||||
ㅀ: 'ㄹㅎ',
|
||||
ㅄ: 'ㅂㅅ',
|
||||
};
|
||||
|
||||
const SYL_FIRST = 0xac00;
|
||||
const SYL_LAST = 0xd7a3;
|
||||
const isSyllable = (ch: string): boolean => {
|
||||
const c = ch.codePointAt(0)!;
|
||||
return c >= SYL_FIRST && c <= SYL_LAST;
|
||||
};
|
||||
|
||||
/** Text without spaces, punctuation and symbols (raw, before NFKC). */
|
||||
const bare = (raw: string): string => raw.replace(/[\s\p{P}\p{S}\u200B-\u200D\uFEFF]/gu, '');
|
||||
|
||||
/** Hangul compatibility jamo only (U+3131–U+318E), ignoring spaces and punctuation. */
|
||||
export function isCompatJamoOnly(raw: string): boolean {
|
||||
const t = bare(raw);
|
||||
return t.length > 0 && /^[ㄱ-ㆎ]+$/u.test(t);
|
||||
}
|
||||
|
||||
const ENDINGS = ['요', '다', '이다', '입니다', '인가', '인가요'];
|
||||
|
||||
/** §5.3 normalize(): NFKC, zero-width, spaces, punctuation/symbols/emoji, lower case. */
|
||||
export function normalize(s: string): string {
|
||||
return s
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u200B-\u200D\uFEFF\u2060\uFE0F\u20E3]/gu, '')
|
||||
.replace(/\s+/gu, '')
|
||||
.replace(/[\p{P}\p{S}\p{Extended_Pictographic}]/gu, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Initial consonants of every syllable (other characters kept as they are). */
|
||||
export function chosung(word: string): string {
|
||||
return [...word]
|
||||
.map((ch) => (isSyllable(ch) ? CHO[Math.floor((ch.codePointAt(0)! - SYL_FIRST) / 588)]! : ch))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function initialOf(ch: string): string {
|
||||
return isSyllable(ch) ? CHO[Math.floor((ch.codePointAt(0)! - SYL_FIRST) / 588)]! : ch;
|
||||
}
|
||||
|
||||
/** Syllables → compatibility jamo with compound vowels / double finals split. */
|
||||
export function jamo(s: string): string {
|
||||
let out = '';
|
||||
for (const ch of s) {
|
||||
if (!isSyllable(ch)) {
|
||||
out += SPLIT[ch] ?? ch;
|
||||
continue;
|
||||
}
|
||||
const i = ch.codePointAt(0)! - SYL_FIRST;
|
||||
const l = CHO[Math.floor(i / 588)]!;
|
||||
const v = JUNG[Math.floor((i % 588) / 28)]!;
|
||||
const t = JONG[i % 28]!;
|
||||
out += l + (SPLIT[v] ?? v) + (SPLIT[t] ?? t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
const x = [...a];
|
||||
const y = [...b];
|
||||
let prev = Array.from({ length: y.length + 1 }, (_, j) => j);
|
||||
for (let i = 1; i <= x.length; i++) {
|
||||
const cur = [i];
|
||||
for (let j = 1; j <= y.length; j++) {
|
||||
cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (x[i - 1] === y[j - 1] ? 0 : 1));
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
return prev[y.length]!;
|
||||
}
|
||||
|
||||
export type Judgement = 'correct' | 'chosung' | 'contains' | 'close' | 'plain';
|
||||
|
||||
/**
|
||||
* Judges one guess against the answer and its aliases (§5.3).
|
||||
* - Compatibility-jamo-only text is never correct; it is held back when it spells the answer's initials or the answer itself.
|
||||
* - correct: exact match after normalize, optionally followed by a polite ending (요/다/이다/입니다/인가/인가요).
|
||||
* - contains: the answer inside a longer message → held back.
|
||||
* - close: jamo edit distance ≤ 1 (answers up to 3 letters) or ≤ 2 (4+ letters).
|
||||
*/
|
||||
export function judge(text: string, answers: string[], closeHint: boolean): Judgement {
|
||||
const cands = answers.map(normalize).filter((c) => c.length > 0);
|
||||
if (isCompatJamoOnly(text)) {
|
||||
const t = bare(text);
|
||||
const spelled = normalize(text);
|
||||
if (cands.some((c) => chosung(c) === t || spelled === c || spelled.includes(c))) return 'chosung';
|
||||
return 'plain';
|
||||
}
|
||||
const n = normalize(text);
|
||||
if (!n) return 'plain';
|
||||
for (const c of cands) {
|
||||
if (n === c) return 'correct';
|
||||
if (n.startsWith(c) && ENDINGS.includes(n.slice(c.length))) return 'correct';
|
||||
}
|
||||
if (cands.some((c) => n.includes(c))) return 'contains';
|
||||
if (closeHint) {
|
||||
const jn = jamo(n);
|
||||
for (const c of cands) {
|
||||
const limit = [...c].length <= 3 ? 1 : 2;
|
||||
if (levenshtein(jn, jamo(c)) <= limit) return 'close';
|
||||
}
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filters
|
||||
|
||||
/** Small built-in list (same spirit as the nickname filter). Matching ignores spaces and punctuation. */
|
||||
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', '섹스', '자살', '살인', 'fuck', 'shit', 'bitch', 'sex'];
|
||||
|
||||
export function isBanned(text: string): boolean {
|
||||
const squashed = text.normalize('NFKC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
|
||||
return BANNED.some((w) => squashed.includes(w));
|
||||
}
|
||||
|
||||
export function maskBanned(text: string): string {
|
||||
let out = text;
|
||||
for (const w of BANNED) {
|
||||
const re = new RegExp(w.split('').join('[\\s_.\\-*]*'), 'gi');
|
||||
out = out.replace(re, (m) => '*'.repeat(m.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Control characters out, trimmed. */
|
||||
export function cleanText(text: string): string {
|
||||
return text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||
}
|
||||
|
||||
export const CUSTOM_WORD_RE = /^[가-힣A-Za-z0-9]{1,10}$/;
|
||||
export const MAX_CUSTOM_WORDS = 200;
|
||||
|
||||
/** §3.2 customWords: one per line, 1–10 Hangul/Latin/digits, filtered, deduplicated, at most 200. */
|
||||
export function parseCustomWords(raw: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const w = line.normalize('NFC').trim();
|
||||
if (!CUSTOM_WORD_RE.test(w) || isBanned(w)) continue;
|
||||
const key = normalize(w);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(w);
|
||||
if (out.length >= MAX_CUSTOM_WORDS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
286
packages/games/src/catchmind/types.ts
Normal file
286
packages/games/src/catchmind/types.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/** 그림 맞히기 options, canvas ops, actions, state and view types (docs/games/catchmind.md §2, §3, §5.5, §8). */
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
export const CATEGORIES = ['animal', 'food', 'object', 'vehicle', 'nature', 'place', 'job', 'sport', 'body', 'season'] as const;
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
export const CATEGORY_KO: Record<Category, string> = {
|
||||
animal: '동물',
|
||||
food: '음식',
|
||||
object: '물건',
|
||||
vehicle: '탈것',
|
||||
nature: '자연',
|
||||
place: '장소',
|
||||
job: '직업',
|
||||
sport: '운동·놀이',
|
||||
body: '몸',
|
||||
season: '계절·명절',
|
||||
};
|
||||
|
||||
export interface WordEntry {
|
||||
word: string;
|
||||
aliases: string[];
|
||||
category: Category | 'custom';
|
||||
difficulty: 'easy' | 'normal';
|
||||
}
|
||||
|
||||
/** §3.1: 12 colours (index into the client palette), 4 widths in logical units on the 1000×1000 board. */
|
||||
export const COLOR_COUNT = 12;
|
||||
export const WIDTHS = [4, 8, 16, 32] as const;
|
||||
export const BOARD = 1000;
|
||||
/** §5.5 server limits. */
|
||||
export const MAX_BATCH_POINTS = 64;
|
||||
export const MAX_TURN_POINTS = 30_000;
|
||||
/** Upper bound on canvas ops per turn (keeps one turn's record bounded even with 1-point batches). */
|
||||
export const MAX_TURN_OPS = 8_000;
|
||||
export const DRAW_MSGS_PER_SEC = 30;
|
||||
export const GUESS_MSGS_PER_SEC = 3;
|
||||
|
||||
export const REACTIONS = ['almost', 'no', 'similar'] as const;
|
||||
export type Reaction = (typeof REACTIONS)[number];
|
||||
export const REACTION_KO: Record<Reaction, string> = { almost: '거의 다 왔어요', no: '아니에요', similar: '비슷해요' };
|
||||
|
||||
const lit = <T extends string | number | null>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
const allCategories = Object.fromEntries(CATEGORIES.map((c) => [c, true])) as Record<Category, boolean>;
|
||||
|
||||
export const catchmindOptionsSchema = z.object({
|
||||
guessMode: z
|
||||
.enum(['multi', 'firstOnly'])
|
||||
.default('multi')
|
||||
.meta({ title: '맞히기 방식', labels: { multi: '여러 명이 맞힐 때까지', firstOnly: '첫 정답자가 나오면 바로 끝' } }),
|
||||
rounds: lit<1 | 2 | 3>(1, 2, 3)
|
||||
.default(2)
|
||||
.meta({ title: '라운드 수(모두 한 번씩 그리면 1라운드)', labels: { 1: '1라운드', 2: '2라운드', 3: '3라운드' } }),
|
||||
drawSeconds: lit<60 | 80 | 100 | 120>(60, 80, 100, 120)
|
||||
.default(80)
|
||||
.meta({ title: '그리기 제한 시간', labels: { 60: '60초', 80: '80초', 100: '100초', 120: '120초' } }),
|
||||
wordChoice: z.boolean().default(true).meta({ title: '출제자가 단어 3개 중 고르기(끄면 무작위 1개)' }),
|
||||
categories: z
|
||||
.object(Object.fromEntries(CATEGORIES.map((c) => [c, z.boolean().default(true).meta({ title: CATEGORY_KO[c] })])) as Record<Category, z.ZodDefault<z.ZodBoolean>>)
|
||||
.default(allCategories)
|
||||
.meta({ title: '단어 분야(모두 끄면 전체)' }),
|
||||
difficulty: z
|
||||
.enum(['easy', 'normal', 'mixed'])
|
||||
.default('mixed')
|
||||
.meta({ title: '단어 난이도', labels: { easy: '쉬운 단어만', normal: '보통 단어만', mixed: '섞어서' } }),
|
||||
customWords: z.string().max(3000).default('').meta({ title: '추가 단어(줄마다 하나, 최대 200개, 1~10자 한글·영문·숫자)' }),
|
||||
customOnly: z.boolean().default(false).meta({ title: '추가 단어만 쓰기' }),
|
||||
hints: z
|
||||
.enum(['none', 'basic', 'full'])
|
||||
.default('full')
|
||||
.meta({ title: '힌트', labels: { none: '없음(고수 방)', basic: '글자 수만', full: '글자 수 + 초성 + 한 글자' } }),
|
||||
closeHint: z.boolean().default(true).meta({ title: '"아까워요" 알림(보낸 사람에게만)' }),
|
||||
letterReport: z.boolean().default(true).meta({ title: '"글자를 썼어요" 신고(과반이면 출제자 0점)' }),
|
||||
answererDraws: z.boolean().default(false).meta({ title: '정답자가 다음 출제자(첫 정답자 방식에서만)' }),
|
||||
speedBonus: z.boolean().default(false).meta({ title: '빨리 맞히면 보너스(남은 시간 비율 × 5점)' }),
|
||||
});
|
||||
export type CatchmindOptions = z.infer<typeof catchmindOptionsSchema>;
|
||||
|
||||
/** §5.5 canvas ops (short keys). Shape only here; `validate` checks numbering, ranges and limits. */
|
||||
export type CanvasOp =
|
||||
| { k: 'b'; s: number; c: number; w: number; e: 0 | 1 }
|
||||
| { k: 'p'; s: number; q: number; d: number[] }
|
||||
| { k: 'e'; s: number }
|
||||
| { k: 'u' }
|
||||
| { k: 'x' };
|
||||
|
||||
const int = z.number().int();
|
||||
export const canvasOpSchema = z.discriminatedUnion('k', [
|
||||
z.object({ k: z.literal('b'), s: int.min(0).max(1_000_000), c: int.min(0).max(99), w: int.min(0).max(9), e: z.union([z.literal(0), z.literal(1)]) }),
|
||||
z.object({ k: z.literal('p'), s: int.min(0).max(1_000_000), q: int.min(0).max(1_000_000), d: z.array(int.min(-2000).max(2000)).min(1).max(256) }),
|
||||
z.object({ k: z.literal('e'), s: int.min(0).max(1_000_000) }),
|
||||
z.object({ k: z.literal('u') }),
|
||||
z.object({ k: z.literal('x') }),
|
||||
]);
|
||||
|
||||
export const catchmindActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('chooseWord'), index: int.min(0).max(2) }),
|
||||
z.object({ type: z.literal('draw'), op: canvasOpSchema }),
|
||||
z.object({ type: z.literal('guess'), text: z.string().max(200) }),
|
||||
z.object({ type: z.literal('chat'), text: z.string().max(200) }),
|
||||
z.object({ type: z.literal('reaction'), code: z.enum(REACTIONS) }),
|
||||
z.object({ type: z.literal('report') }),
|
||||
/** "돌아왔어요": clears the away mark (players marked away are skipped as drawer). */
|
||||
z.object({ type: z.literal('back') }),
|
||||
]);
|
||||
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
|
||||
export type CatchmindAction = z.infer<typeof catchmindActionSchema> | { type: 'tick' } | { type: 'leave' };
|
||||
|
||||
export type ChatKind = 'chat' | 'correct' | 'close' | 'blocked' | 'reaction';
|
||||
|
||||
export interface ChatEntry {
|
||||
id: number;
|
||||
from: PlayerId;
|
||||
text: string;
|
||||
at: number;
|
||||
/** all: everyone · solvers: drawer + players who already got it (everyone once the turn is revealed) · self: sender only. */
|
||||
audience: 'all' | 'solvers' | 'self';
|
||||
kind: ChatKind;
|
||||
/** Turn number the message belongs to. */
|
||||
turn: number;
|
||||
/** Why a message was held back (blocked only). */
|
||||
note?: 'contains' | 'chosung';
|
||||
}
|
||||
|
||||
export interface Solver {
|
||||
id: PlayerId;
|
||||
at: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export type TurnEndReason = 'time' | 'allSolved' | 'first' | 'report' | 'drawerAway' | 'drawerLeft';
|
||||
|
||||
export interface Turn {
|
||||
/** 1-based turn counter (also the canvas key). */
|
||||
no: number;
|
||||
phase: 'choosing' | 'drawing' | 'reveal';
|
||||
drawer: PlayerId;
|
||||
choices: WordEntry[];
|
||||
word: WordEntry | null;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
/** 0 letter count, 1 initial consonants, 2 one letter (§5.4). */
|
||||
hintStage: 0 | 1 | 2;
|
||||
revealIndex: number | null;
|
||||
solvers: Solver[];
|
||||
reports: PlayerId[];
|
||||
/** Append-only drawing record of this turn. */
|
||||
canvas: CanvasOp[];
|
||||
totalPoints: number;
|
||||
lastStroke: number;
|
||||
lastBatch: number;
|
||||
/** A stroke was begun and not ended yet. */
|
||||
open: boolean;
|
||||
/** Strokes currently visible (undo / clear aware). */
|
||||
live: number;
|
||||
/** Server times of the last draw messages (rate limit, §5.5). */
|
||||
drawTimes: number[];
|
||||
drawerPoints: number;
|
||||
endReason: TurnEndReason | null;
|
||||
}
|
||||
|
||||
export interface TurnRecord {
|
||||
no: number;
|
||||
drawer: PlayerId;
|
||||
word: string;
|
||||
solvers: PlayerId[];
|
||||
/** Points of each solver, same order as `solvers`. */
|
||||
solverPoints: number[];
|
||||
drawerPoints: number;
|
||||
reason: TurnEndReason;
|
||||
}
|
||||
|
||||
export interface RankRow {
|
||||
rank: number;
|
||||
id: PlayerId;
|
||||
score: number;
|
||||
correct: number;
|
||||
}
|
||||
|
||||
export interface CatchmindFinal {
|
||||
ranking: RankRow[];
|
||||
turns: { drawer: PlayerId; word: string; solvers: PlayerId[] }[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CatchmindState {
|
||||
options: CatchmindOptions;
|
||||
order: PlayerId[];
|
||||
scores: Record<PlayerId, number>;
|
||||
correctCounts: Record<PlayerId, number>;
|
||||
/** Words still to come (shuffled). Never sent to anyone. */
|
||||
wordPool: WordEntry[];
|
||||
/** Candidates a drawer saw but did not pick (only used when the pool runs dry; never an answer before). */
|
||||
spare: WordEntry[];
|
||||
round: number;
|
||||
/** Turns finished so far. */
|
||||
turnsPlayed: number;
|
||||
totalTurns: number;
|
||||
turn: Turn;
|
||||
chat: ChatEntry[];
|
||||
chatSeq: number;
|
||||
/** Server times of each player's recent guesses/chats (rate limit). */
|
||||
msgTimes: Record<PlayerId, number[]>;
|
||||
/** Players timed out as drawer and not back yet: skipped as drawer, not waited for in "everyone got it". */
|
||||
away: PlayerId[];
|
||||
left: PlayerId[];
|
||||
history: TurnRecord[];
|
||||
finished: boolean;
|
||||
final: CatchmindFinal | null;
|
||||
}
|
||||
|
||||
export interface ChatView {
|
||||
id: number;
|
||||
from: PlayerId;
|
||||
text: string;
|
||||
at: number;
|
||||
kind: ChatKind;
|
||||
/** Shown with the "맞힌 사람끼리 대화" label. */
|
||||
solversOnly: boolean;
|
||||
note?: 'contains' | 'chosung';
|
||||
}
|
||||
|
||||
export interface ChoiceView {
|
||||
word: string;
|
||||
category: WordEntry['category'];
|
||||
difficulty: WordEntry['difficulty'];
|
||||
}
|
||||
|
||||
export interface CatchmindView {
|
||||
options: Omit<CatchmindOptions, 'customWords'> & { customWordCount: number };
|
||||
me: PlayerId | null;
|
||||
role: 'drawer' | 'solver' | 'guesser' | 'spectator';
|
||||
order: PlayerId[];
|
||||
scores: Record<PlayerId, number>;
|
||||
correctCounts: Record<PlayerId, number>;
|
||||
round: number;
|
||||
rounds: number;
|
||||
turnNo: number;
|
||||
turnsPlayed: number;
|
||||
totalTurns: number;
|
||||
phase: Turn['phase'] | 'ended';
|
||||
drawer: PlayerId;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
/** Length of the current phase (ms), for timer rings. */
|
||||
phaseMs: number;
|
||||
/** Drawer only, while choosing. */
|
||||
choices: ChoiceView[] | null;
|
||||
/** Drawer, solvers and (after the turn) everyone. */
|
||||
word: string | null;
|
||||
category: WordEntry['category'] | null;
|
||||
/** Per character: '○' hidden, an initial consonant, the letter itself, or ' ' (space). Null when hints are off. */
|
||||
hint: string[] | null;
|
||||
hintStage: 0 | 1 | 2;
|
||||
/** Next hint time (ms), or null. */
|
||||
nextHintAt: number | null;
|
||||
solvers: { id: PlayerId; points: number }[];
|
||||
reports: number;
|
||||
reportsNeeded: number;
|
||||
reportedByMe: boolean;
|
||||
canGuess: boolean;
|
||||
canChat: boolean;
|
||||
canDraw: boolean;
|
||||
canReport: boolean;
|
||||
canReact: boolean;
|
||||
/** Full canvas only in the (re)join / resync view; regular views carry just the count (§5.5). */
|
||||
canvas: CanvasOp[] | null;
|
||||
canvasCount: number;
|
||||
nextStroke: number;
|
||||
openStroke: boolean;
|
||||
lastBatch: number;
|
||||
liveStrokes: number;
|
||||
pointsUsed: number;
|
||||
pointLimit: number;
|
||||
chat: ChatView[];
|
||||
away: PlayerId[];
|
||||
left: PlayerId[];
|
||||
/** Points each player got this turn (reveal only). */
|
||||
turnPoints: Record<PlayerId, number> | null;
|
||||
drawerPoints: number;
|
||||
endReason: TurnEndReason | null;
|
||||
history: TurnRecord[];
|
||||
result: CatchmindFinal | null;
|
||||
}
|
||||
742
packages/games/src/catchmind/words.ko.json
Normal file
742
packages/games/src/catchmind/words.ko.json
Normal file
@@ -0,0 +1,742 @@
|
||||
[
|
||||
{"word":"고양이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"강아지","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"기린","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"코끼리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"펭귄","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"거북이","aliases":["거북"],"category":"animal","difficulty":"easy"},
|
||||
{"word":"토끼","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"사자","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"문어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"나비","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"호랑이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"원숭이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"돼지","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"오리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"병아리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"여우","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"판다","aliases":["팬더"],"category":"animal","difficulty":"easy"},
|
||||
{"word":"다람쥐","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"고래","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"상어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"돌고래","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"개구리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"악어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"하마","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"낙타","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"얼룩말","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"캥거루","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"코알라","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"부엉이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"독수리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"앵무새","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"참새","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"비둘기","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"거미","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"개미","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"꿀벌","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"잠자리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"무당벌레","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"달팽이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"지렁이","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"사슴","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"염소","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"젖소","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"생쥐","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"햄스터","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"고슴도치","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"타조","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"물개","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"해파리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"오징어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"꽃게","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"새우","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"불가사리","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"조개","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"금붕어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"공룡","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"코뿔소","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"고릴라","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"애벌레","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"북극곰","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"매미","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"모기","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"송아지","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"망아지","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"수탉","aliases":["장닭"],"category":"animal","difficulty":"easy"},
|
||||
{"word":"암탉","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"고등어","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"펠리컨","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"순록","aliases":[],"category":"animal","difficulty":"easy"},
|
||||
{"word":"공작새","aliases":["공작"],"category":"animal","difficulty":"normal"},
|
||||
{"word":"홍학","aliases":["플라밍고"],"category":"animal","difficulty":"normal"},
|
||||
{"word":"메뚜기","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"조랑말","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"너구리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"박쥐","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"가재","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"늑대","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"표범","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"수달","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"두더지","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"카멜레온","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"백조","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"까마귀","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"딱따구리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"미어캣","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"나무늘보","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"해마","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"사마귀","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"반딧불이","aliases":["반딧불"],"category":"animal","difficulty":"normal"},
|
||||
{"word":"오리너구리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"기러기","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"거위","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"칠면조","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"올빼미","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"장어","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"바다표범","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"치타","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"해달","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"랍스터","aliases":["바닷가재"],"category":"animal","difficulty":"normal"},
|
||||
{"word":"도마뱀","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"소라게","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"개코원숭이","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"알파카","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"오소리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"청개구리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"올챙이","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"가오리","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"성게","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"산호","aliases":[],"category":"animal","difficulty":"normal"},
|
||||
{"word":"김밥","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"떡볶이","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"피자","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"라면","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"수박","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"아이스크림","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"바나나","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"케이크","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"만두","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"붕어빵","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"사과","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"포도","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"딸기","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"참외","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"복숭아","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"파인애플","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"체리","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"레몬","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"키위","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"당근","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"감자","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"고구마","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"옥수수","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"오이","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"양파","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"버섯","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"토마토","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"고추","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"호박","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"브로콜리","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"달걀","aliases":["계란"],"category":"food","difficulty":"easy"},
|
||||
{"word":"우유","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"식빵","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"햄버거","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"핫도그","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"샌드위치","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"도넛","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"쿠키","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"사탕","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"초콜릿","aliases":["초콜렛","초코"],"category":"food","difficulty":"easy"},
|
||||
{"word":"젤리","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"팝콘","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"솜사탕","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"김치","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"비빔밥","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"짜장면","aliases":["자장면"],"category":"food","difficulty":"easy"},
|
||||
{"word":"국수","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"볶음밥","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"치킨","aliases":["통닭"],"category":"food","difficulty":"easy"},
|
||||
{"word":"돈가스","aliases":["돈까스"],"category":"food","difficulty":"easy"},
|
||||
{"word":"스파게티","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"카레","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"주먹밥","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"호떡","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"어묵","aliases":["오뎅"],"category":"food","difficulty":"easy"},
|
||||
{"word":"튀김","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"감자튀김","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"소시지","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"치즈","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"요구르트","aliases":["요거트"],"category":"food","difficulty":"easy"},
|
||||
{"word":"주스","aliases":["쥬스"],"category":"food","difficulty":"easy"},
|
||||
{"word":"땅콩","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"팥빙수","aliases":["빙수"],"category":"food","difficulty":"easy"},
|
||||
{"word":"컵라면","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"막대사탕","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"롤케이크","aliases":[],"category":"food","difficulty":"easy"},
|
||||
{"word":"곶감","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"망고","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"배추","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"마늘","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"가지","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"콩나물","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"와플","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"짬뽕","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"냉면","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"삼각김밥","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"꿀떡","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"순대","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"계란말이","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"아보카도","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"호두","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"찐빵","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"꽈배기","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"떡꼬치","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"마카롱","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"푸딩","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"프레첼","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"생선구이","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"새우튀김","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"김치찌개","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"된장찌개","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"미역국","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"삼겹살","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"꼬치구이","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"오므라이스","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"유부초밥","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"잡채","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"군만두","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"탕후루","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"석류","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"자두","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"블루베리","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"코코넛","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"무화과","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"연근","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"피망","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"시금치","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"양배추","aliases":[],"category":"food","difficulty":"normal"},
|
||||
{"word":"우산","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"안경","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"가위","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"시계","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"열쇠","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"연필","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"의자","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"냉장고","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"칫솔","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"선풍기","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"책상","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"침대","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"거울","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"숟가락","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"젓가락","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"포크","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"접시","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"냄비","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"프라이팬","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"텔레비전","aliases":["티비","텔레비젼","티브이"],"category":"object","difficulty":"easy"},
|
||||
{"word":"컴퓨터","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"휴대폰","aliases":["핸드폰","휴대전화","스마트폰"],"category":"object","difficulty":"easy"},
|
||||
{"word":"전화기","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"카메라","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"모자","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"장갑","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"양말","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"신발","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"운동화","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"바지","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"치마","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"티셔츠","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"목도리","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"가방","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"풍선","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"인형","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"로봇","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"크레파스","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"지우개","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"공책","aliases":["노트"],"category":"object","difficulty":"easy"},
|
||||
{"word":"필통","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"색연필","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"달력","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"양초","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"전구","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"손전등","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"사다리","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"망치","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"빗자루","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"쓰레기통","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"휴지","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"수건","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"비누","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"치약","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"옷걸이","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"리모컨","aliases":["리모콘"],"category":"object","difficulty":"easy"},
|
||||
{"word":"피아노","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"기타","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"실로폰","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"마이크","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"청소기","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"세탁기","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"소파","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"이불","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"베개","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"창문","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"저금통","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"주사위","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"팽이","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"왕관","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"반지","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"목걸이","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"리본","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"선물","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"편지","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"지도","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"깃발","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"텐트","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"망원경","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"신호등","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"자동문","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"칠판","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"책가방","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"물병","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"컵받침","aliases":[],"category":"object","difficulty":"easy"},
|
||||
{"word":"주전자","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"라디오","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"넥타이","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"지갑","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"물감","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"액자","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"드라이버","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"샴푸","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"빨래집게","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"단추","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"자물쇠","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"돋보기","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"나침반","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"지구본","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"드럼","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"바이올린","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"트라이앵글","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"탬버린","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"나팔","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"헤드폰","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"전자레인지","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"다리미","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"우체통","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"퍼즐","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"귀걸이","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"머리띠","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"봉투","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"신문","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"현미경","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"모래시계","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"오르골","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"체스판","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"국자","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"도마","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"밥솥","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"믹서기","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"토스터","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"손거울","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"머리빗","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"면봉","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"반창고","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"체온계","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"줄자","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"스테이플러","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"계산기","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"키보드","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"마우스","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"노트북","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"이어폰","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"충전기","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"손수건","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"효자손","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"부메랑","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"확성기","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"빨대","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"병따개","aliases":[],"category":"object","difficulty":"normal"},
|
||||
{"word":"자전거","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"비행기","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"기차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"버스","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"헬리콥터","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"로켓","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"소방차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"구급차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"경찰차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"택시","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"트럭","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"지하철","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"오토바이","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"자동차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"돛단배","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"잠수함","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"열기구","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"우주선","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"킥보드","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"유모차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"썰매","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"낙하산","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"쓰레기차","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"세발자전거","aliases":[],"category":"vehicle","difficulty":"easy"},
|
||||
{"word":"요트","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"유람선","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"스쿠터","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"휠체어","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"트랙터","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"굴착기","aliases":["포클레인","포크레인"],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"불도저","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"기중기","aliases":["크레인"],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"케이블카","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"마차","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"뗏목","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"견인차","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"비행선","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"행글라이더","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"캠핑카","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"경운기","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"기관차","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"모노레일","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"고무보트","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"카누","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"레미콘","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"증기기관차","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"수상스키","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"롤러코스터","aliases":[],"category":"vehicle","difficulty":"normal"},
|
||||
{"word":"무지개","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"화산","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"눈사람","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"해바라기","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"구름","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"번개","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"바다","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"태양","aliases":["해님"],"category":"nature","difficulty":"easy"},
|
||||
{"word":"보름달","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"초승달","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"별똥별","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"나무","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"장미","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"튤립","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"민들레","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"나뭇잎","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"선인장","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"폭포","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"사막","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"동굴","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"바위","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"파도","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"눈송이","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"고드름","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"지구","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"연못","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"도토리","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"솔방울","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"나팔꽃","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"빗방울","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"물방울","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"얼음","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"모닥불","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"무인도","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"소나무","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"야자수","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"꽃다발","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"모래성","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"거미줄","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"개나리","aliases":[],"category":"nature","difficulty":"easy"},
|
||||
{"word":"호수","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"오아시스","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"빙산","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"소나기","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"우박","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"회오리바람","aliases":["회오리","토네이도"],"category":"nature","difficulty":"normal"},
|
||||
{"word":"행성","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"토성","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"해돋이","aliases":["일출"],"category":"nature","difficulty":"normal"},
|
||||
{"word":"노을","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"개울","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"클로버","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"연꽃","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"코스모스","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"잔디","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"그림자","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"오로라","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"불꽃","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"은하수","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"달무리","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"안개","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"산호초","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"징검다리","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"갯벌","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"계곡","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"절벽","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"화석","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"이슬","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"단풍잎","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"강아지풀","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"수선화","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"무궁화","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"진달래","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"백합","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"갈대","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"버드나무","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"대나무","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"밀림","aliases":[],"category":"nature","difficulty":"normal"},
|
||||
{"word":"학교","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"병원","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"놀이터","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"수영장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"도서관","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"동물원","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"공원","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"시장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"마트","aliases":["슈퍼마켓","슈퍼"],"category":"place","difficulty":"easy"},
|
||||
{"word":"편의점","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"빵집","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"꽃집","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"우체국","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"소방서","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"경찰서","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"식당","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"영화관","aliases":["극장"],"category":"place","difficulty":"easy"},
|
||||
{"word":"미용실","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"공항","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"기차역","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"정류장","aliases":["버스정류장"],"category":"place","difficulty":"easy"},
|
||||
{"word":"등대","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"터널","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"놀이공원","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"수족관","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"운동장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"교실","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"부엌","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"화장실","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"주차장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"해수욕장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"농장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"아파트","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"엘리베이터","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"계단","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"스키장","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"유치원","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"횡단보도","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"문방구","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"약국","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"치과","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"놀이방","aliases":[],"category":"place","difficulty":"easy"},
|
||||
{"word":"항구","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"궁궐","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"박물관","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"미술관","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"체육관","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"욕실","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"거실","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"주유소","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"캠핑장","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"목장","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"과수원","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"공장","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"분수대","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"무대","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"시계탑","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"피라미드","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"이글루","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"오두막","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"우물","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"세탁소","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"사진관","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"서점","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"경기장","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"전망대","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"등산로","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"성벽","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"지하실","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"다락방","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"온실","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"비닐하우스","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"매표소","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"승강장","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"휴게소","aliases":[],"category":"place","difficulty":"normal"},
|
||||
{"word":"소방관","aliases":["소방대원"],"category":"job","difficulty":"easy"},
|
||||
{"word":"요리사","aliases":["셰프"],"category":"job","difficulty":"easy"},
|
||||
{"word":"의사","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"경찰관","aliases":["경찰"],"category":"job","difficulty":"easy"},
|
||||
{"word":"우체부","aliases":["집배원"],"category":"job","difficulty":"easy"},
|
||||
{"word":"화가","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"간호사","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"선생님","aliases":["교사","선생"],"category":"job","difficulty":"easy"},
|
||||
{"word":"농부","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"가수","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"우주비행사","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"과학자","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"축구선수","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"마술사","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"왕자","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"공주","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"마법사","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"산타클로스","aliases":["산타","산타할아버지"],"category":"job","difficulty":"easy"},
|
||||
{"word":"해적","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"요정","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"임금님","aliases":[],"category":"job","difficulty":"easy"},
|
||||
{"word":"어부","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"미용사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"조종사","aliases":["파일럿"],"category":"job","difficulty":"normal"},
|
||||
{"word":"발레리나","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"광대","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"제빵사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"사진작가","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"택배기사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"운전기사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"목수","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"경비원","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"판사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"수의사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"치과의사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"환경미화원","aliases":["청소부"],"category":"job","difficulty":"normal"},
|
||||
{"word":"탐정","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"기자","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"아나운서","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"지휘자","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"피아니스트","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"바리스타","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"잠수부","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"야구선수","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"정원사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"등대지기","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"양치기","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"마부","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"대장장이","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"사육사","aliases":[],"category":"job","difficulty":"normal"},
|
||||
{"word":"축구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"줄넘기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"수영","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"연날리기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"그네","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"미끄럼틀","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"야구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"농구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"배구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"탁구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"테니스","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"배드민턴","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"스키","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"스케이트","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"태권도","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"달리기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"줄다리기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"피구","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"시소","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"훌라후프","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"팽이치기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"제기차기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"윷놀이","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"비눗방울","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"등산","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"낚시","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"축구공","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"모래놀이","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"자전거타기","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"공놀이","aliases":[],"category":"sport","difficulty":"easy"},
|
||||
{"word":"골프","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"볼링","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"씨름","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"정글짐","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"숨바꼭질","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"술래잡기","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"공기놀이","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"딱지치기","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"캠핑","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"서핑","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"요가","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"체조","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"역도","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"양궁","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"스노보드","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"야구공","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"야구방망이","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"트램펄린","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"마라톤","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"철봉","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"다이빙","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"사방치기","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"고무줄놀이","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"줄타기","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"컬링","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"하키","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"럭비","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"승마","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"카약","aliases":[],"category":"sport","difficulty":"normal"},
|
||||
{"word":"머리카락","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"이빨","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"손가락","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"발가락","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"무릎","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"눈썹","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"입술","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"어깨","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"손톱","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"주먹","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"손바닥","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"발바닥","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"얼굴","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"머리","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"다리","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"수염","aliases":[],"category":"body","difficulty":"easy"},
|
||||
{"word":"팔꿈치","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"발톱","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"눈동자","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"콧수염","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"이마","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"엄지손가락","aliases":["엄지"],"category":"body","difficulty":"normal"},
|
||||
{"word":"콧구멍","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"보조개","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"주근깨","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"속눈썹","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"손목","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"발목","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"허리","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"근육","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"뒤통수","aliases":[],"category":"body","difficulty":"normal"},
|
||||
{"word":"송편","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"떡국","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"크리스마스트리","aliases":["트리"],"category":"season","difficulty":"easy"},
|
||||
{"word":"눈싸움","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"부채","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"단풍","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"세배","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"복주머니","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"한복","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"장화","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"우비","aliases":["비옷"],"category":"season","difficulty":"easy"},
|
||||
{"word":"밀짚모자","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"선글라스","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"허수아비","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"낙엽","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"군고구마","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"털모자","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"털장갑","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"벚꽃","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"도시락","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"눈썰매","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"크리스마스","aliases":["성탄절"],"category":"season","difficulty":"easy"},
|
||||
{"word":"생일","aliases":[],"category":"season","difficulty":"easy"},
|
||||
{"word":"설날","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"추석","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"장마","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"밤송이","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"호빵","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"카네이션","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"김장","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"강강술래","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"달맞이","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"삼계탕","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"수박화채","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"소풍","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"모내기","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"그네뛰기","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"동지팥죽","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"부럼","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"화로","aliases":[],"category":"season","difficulty":"normal"},
|
||||
{"word":"고무신","aliases":[],"category":"season","difficulty":"normal"}
|
||||
]
|
||||
63
packages/games/src/dobble/deck.ts
Normal file
63
packages/games/src/dobble/deck.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Projective-plane deck and card layouts (docs/games/dobble.md §3). */
|
||||
import type { Rng } from '@bg/engine';
|
||||
import type { CardLayout } from './types';
|
||||
|
||||
export const ORDER = 7;
|
||||
export const SYMBOLS = ORDER * ORDER + ORDER + 1; // 57
|
||||
export const FULL_DECK = SYMBOLS; // 57 cards
|
||||
export const PER_CARD = ORDER + 1; // 8
|
||||
|
||||
/** §3.1: the order-n projective plane (n prime). Card i → its n+1 symbol numbers. */
|
||||
export function buildDeck(n = ORDER): number[][] {
|
||||
const cards: number[][] = [];
|
||||
cards.push(Array.from({ length: n + 1 }, (_, i) => i));
|
||||
for (let j = 0; j < n; j++) {
|
||||
const c = [0];
|
||||
for (let k = 0; k < n; k++) c.push(n + 1 + n * j + k);
|
||||
cards.push(c);
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const c = [i + 1];
|
||||
for (let k = 0; k < n; k++) c.push(n + 1 + n * k + ((i * k + j) % n));
|
||||
cards.push(c);
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** The single symbol two different cards share (projective plane: exactly one). */
|
||||
export function commonSymbol(a: readonly number[], b: readonly number[]): number {
|
||||
for (const x of a) if (b.includes(x)) return x;
|
||||
throw new Error('dobble: cards share no symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.3: six circle-packing templates, slot = [x, y, r] in the unit circle. Every slot is at least 0.48 of
|
||||
* the radius wide (≥ 72px on a 300px card, well over the 48px touch target) and no two slots overlap.
|
||||
*/
|
||||
export const TEMPLATES: readonly (readonly [number, number, number])[][] = [
|
||||
[[-0.448, 0.516, 0.287], [-0.683, -0.028, 0.287], [0.011, -0.014, 0.287], [0.18, -0.659, 0.287], [0.628, -0.271, 0.287], [0.603, 0.322, 0.287], [-0.404, -0.552, 0.287], [0.124, 0.672, 0.287]],
|
||||
[[0, 0.002, 0.399], [-0.695, 0.005, 0.275], [0.568, -0.4, 0.275], [-0.465, -0.516, 0.275], [0.078, -0.691, 0.275], [0.677, 0.159, 0.275], [-0.457, 0.523, 0.275], [0.33, 0.612, 0.275]],
|
||||
[[-0.134, 0.603, 0.353], [0.365, -0.498, 0.353], [-0.654, -0.246, 0.271], [-0.261, -0.648, 0.271], [-0.129, -0.044, 0.271], [0.5, 0.488, 0.271], [0.726, 0.003, 0.244], [-0.666, 0.289, 0.244]],
|
||||
[[-0.033, 0.019, 0.343], [0.051, -0.653, 0.315], [0.595, 0.274, 0.315], [0.59, -0.346, 0.286], [-0.414, 0.545, 0.286], [0.147, 0.683, 0.272], [-0.526, -0.48, 0.257], [-0.662, 0.037, 0.257]],
|
||||
[[0.008, -0.646, 0.324], [0.059, 0.021, 0.324], [-0.568, -0.307, 0.324], [0.679, 0.108, 0.282], [-0.61, 0.318, 0.282], [-0.146, 0.672, 0.282], [0.568, -0.436, 0.254], [0.404, 0.591, 0.254]],
|
||||
[[0.363, 0.461, 0.383], [-0.043, -0.128, 0.312], [0.393, -0.562, 0.284], [-0.307, 0.614, 0.284], [-0.591, -0.376, 0.27], [-0.678, 0.177, 0.27], [0.708, -0.1, 0.255], [-0.158, -0.695, 0.241]],
|
||||
];
|
||||
|
||||
/** Fixes one card's look: template, which symbol sits in which slot, and (classic) size and rotation. */
|
||||
export function makeLayout(symbols: readonly number[], style: 'easy' | 'classic', rng: Rng): CardLayout {
|
||||
const template = rng.int(TEMPLATES.length);
|
||||
const slots = TEMPLATES[template]!;
|
||||
const order = rng.shuffle(symbols);
|
||||
return {
|
||||
template,
|
||||
items: order.map((symbol, i) => {
|
||||
const [x, y, r] = slots[i]!;
|
||||
if (style === 'easy') return { symbol, x, y, r, scale: 1, rot: 0 };
|
||||
const scale = Math.round((0.6 + rng.next() * 0.6) * 100) / 100;
|
||||
const rot = rng.int(360);
|
||||
return { symbol, x, y, r, scale, rot };
|
||||
}),
|
||||
};
|
||||
}
|
||||
849
packages/games/src/dobble/dobble.test.ts
Normal file
849
packages/games/src/dobble/dobble.test.ts
Normal file
@@ -0,0 +1,849 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
|
||||
import {
|
||||
AWAY_STEP_MS,
|
||||
COUNTDOWN_MS,
|
||||
FAST_MS,
|
||||
HINT_MS,
|
||||
LATE_TAKEN,
|
||||
ROUND_BREAK_MS,
|
||||
STALL_MS,
|
||||
SYMBOLS,
|
||||
SYMBOL_NAMES,
|
||||
TEMPLATES,
|
||||
WINDOW_MS,
|
||||
allCardIds,
|
||||
answerArt,
|
||||
buildDeck,
|
||||
cardKey,
|
||||
commonSymbol,
|
||||
dobble,
|
||||
dueAt,
|
||||
pairsFor,
|
||||
type ClaimAction,
|
||||
type DobbleAction,
|
||||
type DobbleOptions,
|
||||
type DobbleState,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||
const T0 = 1_000_000;
|
||||
const START = T0 + COUNTDOWN_MS;
|
||||
|
||||
function make(opts: Partial<DobbleOptions> = {}, players = ['A', 'B', 'C'], seed = 1): DobbleState {
|
||||
const options = dobble.optionsSchema.parse(opts);
|
||||
return dobble.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
|
||||
}
|
||||
|
||||
function applyE(s: DobbleState, p: string, a: DobbleAction, now: number): { state: DobbleState; events: GameEvent[] } {
|
||||
const v = dobble.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return dobble.apply(s, p, a, { rng: rng(), now });
|
||||
}
|
||||
const act = (s: DobbleState, p: string, a: DobbleAction, now: number) => applyE(s, p, a, now).state;
|
||||
/** Countdown over → playing. */
|
||||
const started = (s: DobbleState, now = START) => act(s, s.order[0]!, { type: 'tick' }, now);
|
||||
|
||||
/** The correct claim of `p` (on `target` for gift / hot potato). */
|
||||
function right(s: DobbleState, p: string, target?: string, extra: Partial<ClaimAction> = {}): ClaimAction {
|
||||
const pair = pairsFor(s, p).find((x) => (x.target ?? undefined) === target);
|
||||
if (!pair) throw new Error(`${p} has no pair on ${target}`);
|
||||
return {
|
||||
type: 'claim',
|
||||
base: pair.base,
|
||||
symbol: answerArt(s, p, pair)!,
|
||||
...(pair.target ? { target: pair.target } : {}),
|
||||
...(pair.mine !== null ? { mine: pair.mine } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A symbol that is on one compared card but is not the shared one. */
|
||||
function wrong(s: DobbleState, p: string, target?: string): ClaimAction {
|
||||
const r = right(s, p, target);
|
||||
const card = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[p]!.at(-1)!;
|
||||
const sym = s.cards[card]!.find((x) => s.symbolArt[x] !== r.symbol)!;
|
||||
return { ...r, symbol: s.symbolArt[sym]! };
|
||||
}
|
||||
|
||||
const top = (s: DobbleState, p: string) => s.piles[p]!.at(-1);
|
||||
/** Ranking groups with ids sorted inside each group (seats are shuffled, so tie order follows the seat order). */
|
||||
const groups = (r: { ranking: PlayerId[][] }) => r.ranking.map((g) => [...g].sort());
|
||||
|
||||
function conserved(s: DobbleState): void {
|
||||
const all = allCardIds(s);
|
||||
if (all.length !== 57) throw new Error(`card count ${all.length}`);
|
||||
if (new Set(all).size !== 57) throw new Error('duplicate card');
|
||||
}
|
||||
|
||||
/** Puts the given cards (bottom → top) on the given piles, everything else into the draw pile (tests only). */
|
||||
function rig(s: DobbleState, r: { piles?: Record<string, number[]>; center?: number[]; draw?: number[] }): DobbleState {
|
||||
const used = new Set([...Object.values(r.piles ?? {}).flat(), ...(r.center ?? []), ...(r.draw ?? [])]);
|
||||
for (const p of s.order) {
|
||||
s.piles[p] = r.piles?.[p] ?? [];
|
||||
s.taken[p] = [];
|
||||
}
|
||||
s.center = r.center ?? [];
|
||||
s.drawPile = r.draw ?? [];
|
||||
s.unused = Array.from({ length: 57 }, (_, i) => i).filter((i) => !used.has(i));
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- §3
|
||||
|
||||
describe('덱과 카드 모양 (§3)', () => {
|
||||
test('57장·57종, 카드당 서로 다른 8개, 모든 쌍(1596쌍) 공유 1개, 그림마다 8번', () => {
|
||||
const deck = buildDeck();
|
||||
expect(deck).toHaveLength(57);
|
||||
expect(SYMBOLS).toBe(57);
|
||||
expect(SYMBOL_NAMES).toHaveLength(57);
|
||||
expect(new Set(SYMBOL_NAMES).size).toBe(57);
|
||||
const seen = new Map<number, number>();
|
||||
for (const c of deck) {
|
||||
expect(new Set(c).size).toBe(8);
|
||||
for (const x of c) seen.set(x, (seen.get(x) ?? 0) + 1);
|
||||
}
|
||||
expect(seen.size).toBe(57);
|
||||
expect([...seen.keys()].every((x) => x >= 0 && x < 57)).toBe(true);
|
||||
expect([...seen.values()].every((n) => n === 8)).toBe(true);
|
||||
let pairs = 0;
|
||||
for (let i = 0; i < 57; i++) {
|
||||
for (let j = i + 1; j < 57; j++) {
|
||||
expect(deck[i]!.filter((x) => deck[j]!.includes(x))).toHaveLength(1);
|
||||
pairs++;
|
||||
}
|
||||
}
|
||||
expect(pairs).toBe(1596);
|
||||
});
|
||||
|
||||
test('55장 모드: RNG로 2장을 빼고 남은 55장도 쌍마다 공유 1개', () => {
|
||||
for (const seed of [1, 2, 3]) {
|
||||
const s = make({ deckSize: 55, miniGame: 'tower' }, ['A', 'B'], seed);
|
||||
conserved(s);
|
||||
expect(s.unused).toHaveLength(2);
|
||||
const inPlay = allCardIds(s).filter((id) => !s.unused.includes(id));
|
||||
expect(inPlay).toHaveLength(55);
|
||||
for (const a of inPlay) for (const b of inPlay) if (a < b) expect(s.cards[a]!.filter((x) => s.cards[b]!.includes(x))).toHaveLength(1);
|
||||
}
|
||||
// Different seeds remove different cards.
|
||||
expect(make({ deckSize: 55 }, ['A', 'B'], 1).unused).not.toEqual(make({ deckSize: 55 }, ['A', 'B'], 2).unused);
|
||||
});
|
||||
|
||||
test('템플릿 6종: 원 안, 겹침 없음, 지름 300px 카드에서 칸이 48px 이상', () => {
|
||||
expect(TEMPLATES).toHaveLength(6);
|
||||
for (const t of TEMPLATES) {
|
||||
expect(t).toHaveLength(8);
|
||||
for (const [x, y, r] of t) {
|
||||
expect(Math.hypot(x, y) + r).toBeLessThanOrEqual(1);
|
||||
expect(2 * r * 150).toBeGreaterThanOrEqual(48);
|
||||
}
|
||||
for (let i = 0; i < 8; i++) for (let j = i + 1; j < 8; j++) expect(Math.hypot(t[i]![0] - t[j]![0], t[i]![1] - t[j]![1])).toBeGreaterThanOrEqual(t[i]![2] + t[j]![2]);
|
||||
}
|
||||
});
|
||||
|
||||
test('레이아웃은 셋업 때 카드마다 확정: classic은 크기 0.6~1.2·회전 0~359, easy는 1·0', () => {
|
||||
const c = make({ symbolStyle: 'classic' });
|
||||
const e = make({ symbolStyle: 'easy' });
|
||||
for (const [s, easy] of [
|
||||
[c, false],
|
||||
[e, true],
|
||||
] as const) {
|
||||
expect(s.layouts).toHaveLength(57);
|
||||
s.layouts.forEach((l, id) => {
|
||||
expect(l.items.map((i) => i.symbol).sort((a, b) => a - b)).toEqual([...s.cards[id]!].sort((a, b) => a - b));
|
||||
for (const i of l.items) {
|
||||
if (easy) expect([i.scale, i.rot]).toEqual([1, 0]);
|
||||
else {
|
||||
expect(i.scale).toBeGreaterThanOrEqual(0.6);
|
||||
expect(i.scale).toBeLessThanOrEqual(1.2);
|
||||
expect(i.rot).toBeGreaterThanOrEqual(0);
|
||||
expect(i.rot).toBeLessThan(360);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(new Set(c.layouts.map((l) => l.template)).size).toBeGreaterThan(3);
|
||||
expect(new Set(c.layouts.flatMap((l) => l.items.map((i) => i.scale))).size).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test('그림 번호 → 아이콘 배정·좌석·덱 순서는 시드로 정해지고 판마다 달라요', () => {
|
||||
const a = make({}, ['A', 'B', 'C'], 1);
|
||||
const b = make({}, ['A', 'B', 'C'], 1);
|
||||
const c = make({}, ['A', 'B', 'C'], 2);
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
expect(a.symbolArt).not.toEqual(c.symbolArt);
|
||||
expect([...a.symbolArt].sort((x, y) => x - y)).toEqual(Array.from({ length: 57 }, (_, i) => i));
|
||||
expect(a.host).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- §4
|
||||
|
||||
describe('준비 (§4)', () => {
|
||||
test('탑 쌓기·독이 든 선물: 각자 1장 공개, 가운데 1장 공개, 나머지는 뽑기 더미. 3초 카운트다운 동안 그림은 안 보여요', () => {
|
||||
for (const miniGame of ['tower', 'poisonedGift'] as const) {
|
||||
const s = make({ miniGame }, ['A', 'B', 'C', 'D']);
|
||||
conserved(s);
|
||||
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
|
||||
expect(s.center).toHaveLength(1);
|
||||
expect(s.drawPile).toHaveLength(57 - 5);
|
||||
expect(s.phase).toBe('countdown');
|
||||
const v = dobble.view(s, 'A');
|
||||
expect(v.center).toBeNull();
|
||||
expect(v.seats.every((x) => x.top === null)).toBe(true);
|
||||
expect(v.pairs).toEqual([]);
|
||||
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center[0]!, symbol: 0 })).toEqual({ ok: false, reason: '곧 시작해요. 잠시만 기다려 주세요.' });
|
||||
expect(dueAt(s)).toBe(START);
|
||||
const p = started(s);
|
||||
expect(p.phase).toBe('playing');
|
||||
const v2 = dobble.view(p, 'A');
|
||||
expect(v2.center?.key).toBe(cardKey(p.center[0]!));
|
||||
expect(v2.seats.every((x) => x.top !== null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])('우물 %i명: 가운데 1장, 1인당 floor((덱-1)/N)장, 나머지는 사용 안 함', (n) => {
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
for (const deckSize of [57, 55] as const) {
|
||||
const s = make({ miniGame: 'well', deckSize }, players);
|
||||
conserved(s);
|
||||
const per = Math.floor((deckSize - 1) / n);
|
||||
for (const p of players) expect(s.piles[p]).toHaveLength(per);
|
||||
expect(s.center).toHaveLength(1);
|
||||
expect(s.unused).toHaveLength(57 - 1 - per * n);
|
||||
// Only the top of each pile is face up (after the countdown).
|
||||
const v = dobble.view(started(s), players[0]!);
|
||||
for (const seat of v.seats) expect(seat.top?.key).toBe(cardKey(s.piles[seat.id]!.at(-1)!));
|
||||
}
|
||||
});
|
||||
|
||||
test('뜨거운 감자: 라운드마다 각자 1장, 8명 × 7라운드도 57장이면 충분, 55장이면 시작 전에 막아요', () => {
|
||||
const s = make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']);
|
||||
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
|
||||
expect(s.drawPile).toHaveLength(54);
|
||||
const eight = Array.from({ length: 8 }, (_, i) => `P${i}`);
|
||||
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7 }), 8)).toBeNull();
|
||||
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 8)).toContain('카드가 모자라요');
|
||||
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 7)).toBeNull();
|
||||
expect(make({ miniGame: 'hotPotato', hotPotatoRounds: 7 }, eight).drawPile).toHaveLength(57 - 8);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- §5
|
||||
|
||||
describe('찾기 판정 (§5.1, §5.3)', () => {
|
||||
test('탑 쌓기: 같은 가운데 카드에 두 사람이 정답 → 먼저 도착한 사람만 가져가고, 나중 사람은 "늦었어요"(잠금 없음)', () => {
|
||||
let s = started(make({}, ['A', 'B', 'C']));
|
||||
const center = s.center[0]!;
|
||||
const a = right(s, 'A');
|
||||
const b = right(s, 'B');
|
||||
expect(a.base).toBe(center);
|
||||
expect(b.base).toBe(center);
|
||||
const { state, events } = applyE(s, 'A', a, START + 500);
|
||||
s = state;
|
||||
expect(s.piles.A).toEqual([expect.any(Number), center]);
|
||||
expect(top(s, 'A')).toBe(center);
|
||||
expect(s.center).toHaveLength(1);
|
||||
expect(s.center[0]).not.toBe(center);
|
||||
expect(events.map((e) => e.type)).toEqual(['hit', 'reveal']);
|
||||
expect(s.feedback.A).toMatchObject({ kind: 'ok', art: a.symbol });
|
||||
// B's claim for the same centre card arrives 50ms later.
|
||||
expect(dobble.validate(s, 'B', b)).toEqual({ ok: false, reason: LATE_TAKEN });
|
||||
expect(s.lockedUntil.B).toBe(0);
|
||||
expect(s.stats.B).toMatchObject({ correct: 0, wrong: 0 });
|
||||
});
|
||||
|
||||
test('틀린 그림 → wrongLockoutMs 동안 정답도 무시, 잠금이 풀리면 허용 (serverTs 기준)', () => {
|
||||
let s = started(make({ wrongLockoutMs: 2000 }, ['A', 'B']));
|
||||
s = act(s, 'A', wrong(s, 'A'), START + 100);
|
||||
expect(s.lockedUntil.A).toBe(START + 2100);
|
||||
expect(s.stats.A!.wrong).toBe(1);
|
||||
expect(s.feedback.A?.kind).toBe('wrong');
|
||||
const before = s.piles.A!.length;
|
||||
s = act(s, 'A', right(s, 'A'), START + 2099);
|
||||
expect(s.feedback.A?.kind).toBe('locked');
|
||||
expect(s.piles.A).toHaveLength(before);
|
||||
s = act(s, 'A', right(s, 'A'), START + 2100);
|
||||
expect(s.feedback.A?.kind).toBe('ok');
|
||||
expect(s.piles.A).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
test('wrongLockoutMs 0: 틀려도 바로 다시 누를 수 있어요', () => {
|
||||
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B']));
|
||||
s = act(s, 'A', wrong(s, 'A'), START + 100);
|
||||
s = act(s, 'A', right(s, 'A'), START + 100);
|
||||
expect(s.feedback.A?.kind).toBe('ok');
|
||||
});
|
||||
|
||||
test('house rule wrongGivesCard: 탑 쌓기에서 틀리면 내 탑 맨 위가 뽑기 더미 맨 아래로(마지막 1장은 지켜요)', () => {
|
||||
let s = started(make({ wrongGivesCard: true, wrongLockoutMs: 0 }, ['A', 'B']));
|
||||
s = act(s, 'A', wrong(s, 'A'), START + 10);
|
||||
expect(s.piles.A).toHaveLength(1);
|
||||
s = act(s, 'A', right(s, 'A'), START + 20);
|
||||
const myTop = top(s, 'A')!;
|
||||
const draw = s.drawPile.length;
|
||||
s = act(s, 'A', wrong(s, 'A'), START + 30);
|
||||
expect(s.piles.A).toHaveLength(1);
|
||||
expect(s.drawPile).toHaveLength(draw + 1);
|
||||
expect(s.drawPile[0]).toBe(myTop);
|
||||
conserved(s);
|
||||
});
|
||||
|
||||
test('잘못된 요청: 탑·우물은 대상 없음, 선물·감자는 대상 필수, 이미 바뀐 내 카드는 늦음', () => {
|
||||
const s = started(make({}, ['A', 'B']));
|
||||
expect(dobble.validate(s, 'A', { ...right(s, 'A'), target: 'B' }).ok).toBe(false);
|
||||
const g = started(make({ miniGame: 'poisonedGift' }, ['A', 'B']));
|
||||
expect(dobble.validate(g, 'A', { type: 'claim', base: g.center[0]!, symbol: 1 })).toEqual({ ok: false, reason: '누구 카드에서 찾을지 먼저 골라 주세요.' });
|
||||
expect(dobble.validate(s, 'Z', right(s, 'A')).ok).toBe(false);
|
||||
const h = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
|
||||
const c = right(h, 'A', 'B');
|
||||
expect(dobble.validate(h, 'A', { ...c, mine: (c.mine! + 1) % 57 }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('미니게임 (§5.2, §6)', () => {
|
||||
test('탑 쌓기: 뽑기 더미가 끝나면 카드가 가장 많은 사람이 1등, 동점은 공동 순위, 점수 3·2·1', () => {
|
||||
let s = started(make({}, ['A', 'B', 'C']));
|
||||
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5, 6] });
|
||||
let now = START;
|
||||
for (const p of ['A', 'A', 'B', 'C']) s = act(s, p, right(s, p), (now += 300));
|
||||
expect(s.phase).toBe('ended');
|
||||
expect(dobble.view(s, 'A').center).toBeNull();
|
||||
const r = dobble.result(s)!;
|
||||
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
|
||||
expect(r.scores).toEqual({ A: 3, B: 2, C: 2 });
|
||||
expect(r.summary).toBe('탑 쌓기 끝! 카드 3장을 모은 사람이 1등이에요.');
|
||||
expect(s.final!.ranking[0]).toMatchObject({ id: 'A', rank: 1, cards: 3, points: 3 });
|
||||
});
|
||||
|
||||
test('우물: 내 카드를 가운데로, 다 비운 순서가 순위, 마지막 1명은 꼴찌로 바로 종료', () => {
|
||||
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
|
||||
rig(s, { piles: { A: [0], B: [1, 2], C: [3, 4] }, center: [5] });
|
||||
let now = START;
|
||||
const aTop = top(s, 'A')!;
|
||||
s = act(s, 'A', right(s, 'A'), (now += 200));
|
||||
expect(s.center.at(-1)).toBe(aTop);
|
||||
expect(s.status.A).toBe('finished');
|
||||
expect(s.finishedOrder).toEqual(['A']);
|
||||
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center.at(-1)!, symbol: 0 })).toEqual({ ok: false, reason: '이미 카드를 다 비웠어요.' });
|
||||
s = act(s, 'C', right(s, 'C'), (now += 200));
|
||||
s = act(s, 'C', right(s, 'C'), (now += 200));
|
||||
expect(s.phase).toBe('ended');
|
||||
const r = dobble.result(s)!;
|
||||
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
|
||||
expect(r.scores).toEqual({ A: 3, C: 2, B: 1 });
|
||||
expect(dobble.view(s, null).seats.find((x) => x.id === 'C')!.place).toBe(2);
|
||||
});
|
||||
|
||||
test('우물 wellEndAtFirst: 처음 다 비운 사람이 나오면 바로 끝, 나머지는 남은 장수 순', () => {
|
||||
let s = started(make({ miniGame: 'well', wellEndAtFirst: true }, ['A', 'B', 'C']));
|
||||
rig(s, { piles: { A: [0, 1], B: [2, 3, 4], C: [5, 6] }, center: [7] });
|
||||
let now = START;
|
||||
s = act(s, 'A', right(s, 'A'), (now += 200));
|
||||
expect(s.phase).toBe('playing');
|
||||
s = act(s, 'A', right(s, 'A'), (now += 200));
|
||||
expect(s.phase).toBe('ended');
|
||||
expect(groups(dobble.result(s)!)).toEqual([['A'], ['C'], ['B']]);
|
||||
});
|
||||
|
||||
test('뜨거운 감자: 묶음 전체를 상대 위로, 탈출자는 대상 불가, 한 명이 다 가지면 라운드 끝·벌점 누적', () => {
|
||||
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B', 'C']));
|
||||
rig(s, { piles: { A: [0], B: [1], C: [2] }, draw: Array.from({ length: 10 }, (_, i) => 10 + i) });
|
||||
let now = START;
|
||||
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
|
||||
expect(s.piles.A).toEqual([]);
|
||||
expect(s.piles.B).toEqual([1, 0]);
|
||||
expect(s.status.A).toBe('escaped');
|
||||
expect(s.outThisRound).toEqual(['A']);
|
||||
// Nobody may target the escaped A, and A cannot claim this round.
|
||||
expect(dobble.validate(s, 'C', { type: 'claim', base: 0, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '이미 이번 라운드를 탈출한 사람이에요.' });
|
||||
expect(dobble.validate(s, 'A', { type: 'claim', base: 0, target: 'C', symbol: 1 }).ok).toBe(false);
|
||||
expect(pairsFor(s, 'C').map((x) => x.target).sort()).toEqual(['B']);
|
||||
// C's claim was made against B's old top → late.
|
||||
expect(dobble.validate(s, 'C', { type: 'claim', base: 1, target: 'B', symbol: 1 }).ok).toBe(false);
|
||||
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
|
||||
expect(s.phase).toBe('roundEnd');
|
||||
expect(s.penalty).toEqual({ A: 0, B: 3, C: 0 });
|
||||
expect(s.taken.B).toEqual([1, 0, 2]);
|
||||
expect(s.lastRound).toEqual({ round: 1, loser: 'B', cards: 3 });
|
||||
conserved(s);
|
||||
// Round 2 after the break + countdown.
|
||||
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
|
||||
expect(s.phase).toBe('countdown');
|
||||
expect(s.round).toBe(2);
|
||||
expect(s.order.every((p) => s.piles[p]!.length === 1 && s.status[p] === 'playing')).toBe(true);
|
||||
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
|
||||
expect(s.phase).toBe('playing');
|
||||
s = act(s, 'B', right(s, 'B', 'A'), (now += 300));
|
||||
s = act(s, 'C', right(s, 'C', 'A'), (now += 300));
|
||||
expect(s.penalty).toEqual({ A: 3, B: 3, C: 0 });
|
||||
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
|
||||
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
|
||||
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
|
||||
s = act(s, 'A', right(s, 'A', 'C'), (now += 300));
|
||||
expect(s.phase).toBe('ended');
|
||||
expect(s.penalty).toEqual({ A: 3, B: 3, C: 3 });
|
||||
expect(groups(dobble.result(s)!)).toEqual([['A', 'B', 'C']]);
|
||||
expect(dobble.result(s)!.summary).toContain('공동 1등 3명');
|
||||
conserved(s);
|
||||
});
|
||||
|
||||
test('뜨거운 감자: 설정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등', () => {
|
||||
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B']));
|
||||
let now = START;
|
||||
for (const loser of ['A', 'A', 'B']) {
|
||||
const winner = loser === 'A' ? 'B' : 'A';
|
||||
s = act(s, winner, right(s, winner, loser), (now += 400));
|
||||
if (s.phase === 'ended') break;
|
||||
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
|
||||
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
|
||||
}
|
||||
expect(s.phase).toBe('ended');
|
||||
expect(s.penalty).toEqual({ A: 4, B: 2 });
|
||||
const r = dobble.result(s)!;
|
||||
expect(groups(r)).toEqual([['B'], ['A']]);
|
||||
expect(s.final!.ranking.find((x) => x.id === 'B')).toMatchObject({ penalty: 2, points: 3 });
|
||||
});
|
||||
|
||||
test('독이 든 선물: 내 카드에는 못 올리고, 더미가 끝나면 카드가 가장 적은 사람이 1등(동점 공동)', () => {
|
||||
let s = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
|
||||
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5] });
|
||||
expect(dobble.validate(s, 'A', { type: 'claim', base: 3, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '내 카드에는 선물을 줄 수 없어요.' });
|
||||
expect(pairsFor(s, 'A').map((x) => x.target).sort()).toEqual(['B', 'C']);
|
||||
let now = START;
|
||||
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
|
||||
expect(s.piles.B).toEqual([1, 3]);
|
||||
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
|
||||
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
|
||||
expect(s.phase).toBe('ended');
|
||||
const r = dobble.result(s)!;
|
||||
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
|
||||
expect(r.summary).toBe('독이 든 선물 끝! 카드를 1장만 받은 사람이 1등이에요.');
|
||||
// Tie: two players with the fewest cards share 1st.
|
||||
let t = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
|
||||
rig(t, { piles: { A: [0], B: [1], C: [2] }, center: [3] });
|
||||
t = act(t, 'A', right(t, 'A', 'C'), START + 100);
|
||||
expect(groups(dobble.result(t)!)).toEqual([['A', 'B'], ['C']]);
|
||||
expect(dobble.result(t)!.scores).toEqual({ A: 3, B: 3, C: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('지연 보정 light (§5.3)', () => {
|
||||
function duel(delayMs: number) {
|
||||
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
|
||||
s.rtt = { A: 200, B: 20 };
|
||||
const t = START + 1000;
|
||||
const b = right(s, 'B', undefined, { rtt: 20 });
|
||||
const a = right(s, 'A', undefined, { rtt: 200 });
|
||||
s = act(s, 'B', b, t);
|
||||
expect(s.pendingWindow?.claims).toHaveLength(1);
|
||||
expect(s.center).toHaveLength(1);
|
||||
const v = dobble.validate(s, 'A', a);
|
||||
if (v.ok) s = act(s, 'A', a, t + delayMs);
|
||||
// The server closes the window when its deadline comes.
|
||||
if (s.pendingWindow) {
|
||||
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'resolve' });
|
||||
expect(dueAt(s)).toBe(t + WINDOW_MS);
|
||||
s = act(s, 'A', { type: 'resolve' }, t + WINDOW_MS);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
test('상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms): 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리', () => {
|
||||
const s = duel(40);
|
||||
expect(s.stats.A!.correct).toBe(1);
|
||||
expect(s.stats.B!.correct).toBe(0);
|
||||
expect(s.feedback.B?.kind).toBe('late');
|
||||
expect(s.lastHit?.player).toBe('A');
|
||||
});
|
||||
|
||||
test('100ms 늦게 도착하면 60ms 창 밖이라 패배("늦었어요")', () => {
|
||||
const s = duel(100);
|
||||
expect(s.stats.B!.correct).toBe(1);
|
||||
expect(s.stats.A!.correct).toBe(0);
|
||||
expect(s.feedback.A?.kind).toBe('late');
|
||||
});
|
||||
|
||||
test('RTT는 찾기에 실린 값의 지수이동평균(α=0.2), 보정 상한 80ms', () => {
|
||||
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
|
||||
s = act(s, 'A', wrong(s, 'A'), START + 10);
|
||||
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 100 }, START + 20);
|
||||
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 200 }, START + 3000);
|
||||
expect(s.rtt.B).toBe(120);
|
||||
expect(s.rtt.A).toBeNull();
|
||||
s = act(s, 'B', { ...right(s, 'B'), rtt: 5000 }, START + 6000);
|
||||
expect(s.rtt.B).toBe(496);
|
||||
expect(s.pendingWindow!.claims[0]!.adjTs).toBe(START + 6000 - 80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('시간 초과·자리 비움·나가기 (§8.5)', () => {
|
||||
test('onTimeout은 tick(창이 열려 있으면 resolve), 45초 동안 아무도 못 맞히면 공통 그림을 2초 보여 주고 계속', () => {
|
||||
let s = started(make({}, ['A', 'B']));
|
||||
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'tick' });
|
||||
expect(dueAt(s)).toBe(START + STALL_MS);
|
||||
s = act(s, 'A', { type: 'tick' }, START + STALL_MS);
|
||||
expect(s.hintUntil).toBe(START + STALL_MS + HINT_MS);
|
||||
const v = dobble.view(s, 'A');
|
||||
expect(v.hint).toEqual([right(s, 'A').symbol]);
|
||||
expect(dobble.view(s, 'B').hint).toEqual([right(s, 'B').symbol]);
|
||||
expect(dobble.view(s, null).hint).toEqual([]);
|
||||
s = act(s, 'B', { type: 'tick' }, START + STALL_MS + HINT_MS);
|
||||
expect(s.hintUntil).toBe(0);
|
||||
expect(dobble.view(s, 'A').hint).toEqual([]);
|
||||
expect(s.stats.A!.correct + s.stats.B!.correct).toBe(0);
|
||||
expect(dueAt(s)).toBe(START + STALL_MS + HINT_MS + STALL_MS);
|
||||
});
|
||||
|
||||
test('우물: 30초 넘게 끊긴 사람은 기권(카드는 상자로) → 꼴찌 그룹, 남은 사람끼리 계속', () => {
|
||||
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
|
||||
// The room times out a disconnected player before the game deadline only after its grace period:
|
||||
// such early ticks come at most every AWAY_STEP_MS (timeoutNotBefore) and are strikes.
|
||||
let now = START + 30_000;
|
||||
expect(dobble.timeoutNotBefore!(s)).toBeLessThanOrEqual(now);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(s.status.C).toBe('playing');
|
||||
s = act(s, 'C', { type: 'tick' }, now);
|
||||
expect(dobble.timeoutNotBefore!(s)).toBe(now + AWAY_STEP_MS);
|
||||
// Someone keeps playing meanwhile (the stall timer must not swallow the strikes).
|
||||
s = act(s, 'A', right(s, 'A'), now + 5000);
|
||||
now += AWAY_STEP_MS;
|
||||
}
|
||||
expect(s.status.C).toBe('forfeit');
|
||||
expect(s.piles.C).toEqual([]);
|
||||
expect(s.phase).toBe('playing');
|
||||
conserved(s);
|
||||
expect(dobble.activePlayers(s)).toEqual(s.order.filter((p) => p !== 'C'));
|
||||
expect(dobble.validate(s, 'C', { type: 'claim', base: s.center.at(-1)!, symbol: 0 }).ok).toBe(false);
|
||||
// A and B play on; the forfeited C ends in the bottom group.
|
||||
while (s.phase === 'playing') s = act(s, 'B', right(s, 'B'), (now += 300));
|
||||
const r = dobble.result(s)!;
|
||||
expect(r.ranking.at(-1)).toEqual(['C']);
|
||||
expect(s.final!.ranking.find((x) => x.id === 'C')).toMatchObject({ status: 'forfeit', points: 0 });
|
||||
});
|
||||
|
||||
test('짧게 끊겼다 돌아오면(찾기를 하면) 기권 카운트가 초기화돼요', () => {
|
||||
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
|
||||
let now = START + 60_000;
|
||||
s = act(s, 'C', { type: 'tick' }, now);
|
||||
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
|
||||
s = act(s, 'C', wrong(s, 'C'), (now += 1000));
|
||||
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
|
||||
expect(s.status.C).toBe('playing');
|
||||
expect(s.away.C!.strikes).toBe(1);
|
||||
});
|
||||
|
||||
test('탑 쌓기: 끊긴 사람이 있어도 그대로 진행, 모두 자리를 비우면 끝내요', () => {
|
||||
let s = started(make({}, ['A', 'B']));
|
||||
let now = START + 10_000;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
s = act(s, 'B', { type: 'tick' }, now);
|
||||
s = act(s, 'A', right(s, 'A'), now + 1000);
|
||||
now += AWAY_STEP_MS;
|
||||
}
|
||||
expect(s.away.B!.strikes).toBe(3);
|
||||
expect(s.status.B).toBe('playing');
|
||||
expect(s.phase).toBe('playing');
|
||||
// Now both are away: the room ticks whoever is first in timeoutPlayers once timeoutNotBefore passes.
|
||||
for (let i = 0; i < 20 && s.phase === 'playing'; i++) {
|
||||
now = Math.max(now, dobble.timeoutNotBefore!(s)!);
|
||||
const who = dobble.timeoutPlayers!(s)[0]!;
|
||||
s = act(s, who, { type: 'tick' }, now);
|
||||
}
|
||||
expect(s.phase).toBe('ended');
|
||||
expect(dobble.result(s)!.summary).toBe('모두 자리를 비워서 게임을 끝냈어요.');
|
||||
});
|
||||
|
||||
test('나가기: 탑 쌓기는 탑이 남고 꼴찌, 1명만 남으면 끝', () => {
|
||||
let s = started(make({}, ['A', 'B', 'C']));
|
||||
expect(dobble.onLeave!(s, 'C')).toEqual({ type: 'leave' });
|
||||
s = act(s, 'C', { type: 'leave' }, START + 100);
|
||||
expect(s.status.C).toBe('left');
|
||||
expect(s.piles.C).toHaveLength(1);
|
||||
expect(dobble.onLeave!(s, 'C')).toBeNull();
|
||||
s = act(s, 'B', { type: 'leave' }, START + 200);
|
||||
expect(s.phase).toBe('ended');
|
||||
const r = dobble.result(s)!;
|
||||
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
|
||||
expect(r.summary).toContain('다른 사람이 모두 나가서');
|
||||
});
|
||||
|
||||
test('나가기: 뜨거운 감자는 대상에서 빠지고 카드는 상자로, 겨룰 사람이 없어지면 그 라운드는 무효', () => {
|
||||
let s = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
|
||||
s = act(s, 'A', right(s, 'A', 'B'), START + 300);
|
||||
s = act(s, 'C', { type: 'leave' }, START + 400);
|
||||
expect(s.phase).toBe('roundEnd');
|
||||
expect(s.lastRound).toEqual({ round: 1, loser: null, cards: 0 });
|
||||
expect(s.penalty).toEqual({ A: 0, B: 0, C: 0 });
|
||||
conserved(s);
|
||||
s = act(s, 'A', { type: 'tick' }, START + 400 + ROUND_BREAK_MS);
|
||||
expect(s.piles.C).toEqual([]);
|
||||
expect(s.piles.A).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('onTimeout은 모든 상태에서 합법', () => {
|
||||
let s = make({ miniGame: 'hotPotato', latencyComp: 'light' }, ['A', 'B', 'C']);
|
||||
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
|
||||
s = started(s);
|
||||
s = act(s, 'A', right(s, 'A', 'B'), START + 10);
|
||||
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('비공개 정보·봇 의심·결정성 (§8.3, §10)', () => {
|
||||
test('view에는 뽑기 더미·우물 뒷면·미사용 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => {
|
||||
for (const miniGame of ['tower', 'well', 'hotPotato', 'poisonedGift'] as const) {
|
||||
const s = started(make({ miniGame, deckSize: 55 }, ['A', 'B', 'C']));
|
||||
const t = structuredClone(s);
|
||||
// Swap hidden cards around (draw pile ↔ box ↔ well face-down cards).
|
||||
const hidden = [...t.drawPile, ...t.unused, ...(miniGame === 'well' ? t.order.flatMap((p) => t.piles[p]!.slice(0, -1)) : [])];
|
||||
const shuffled = SeededRng.fromSeed([1, 2, 3, 4]).shuffle(hidden);
|
||||
let k = 0;
|
||||
t.drawPile = t.drawPile.map(() => shuffled[k++]!);
|
||||
t.unused = t.unused.map(() => shuffled[k++]!);
|
||||
if (miniGame === 'well') for (const p of t.order) t.piles[p] = [...t.piles[p]!.slice(0, -1).map(() => shuffled[k++]!), t.piles[p]!.at(-1)!];
|
||||
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(dobble.view(t, viewer))).toBe(JSON.stringify(dobble.view(s, viewer)));
|
||||
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
|
||||
// Layouts of hidden cards are never sent.
|
||||
const v = JSON.stringify(dobble.view(s, 'A'));
|
||||
for (const id of s.drawPile) expect(v).not.toContain(`"key":"${cardKey(id)}"`);
|
||||
expect(v).not.toContain('"symbol"');
|
||||
expect(v).not.toContain('rng');
|
||||
}
|
||||
});
|
||||
|
||||
test('카운트다운 중에는 모든 카드가 비밀(덱이 미리 보이지 않음)', () => {
|
||||
const s = make({ miniGame: 'hotPotato' }, ['A', 'B']);
|
||||
expect(dobble.secretsOf!(s, 'A')).toContain(cardKey(s.piles.A![0]!));
|
||||
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
|
||||
});
|
||||
|
||||
test('봇 의심: 카드가 바뀐 뒤 200ms 안에 맞히기를 5번 연속 → 방장 view에만 표시', () => {
|
||||
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B', 'C']));
|
||||
let now = START;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(s.suspect).toEqual([]);
|
||||
s = act(s, 'B', right(s, 'B'), (now += FAST_MS - 50));
|
||||
}
|
||||
expect(s.suspect).toEqual(['B']);
|
||||
expect(dobble.view(s, 'A').suspects).toEqual(['B']);
|
||||
expect(dobble.view(s, 'A').isHost).toBe(true);
|
||||
expect(dobble.view(s, 'B').suspects).toEqual([]);
|
||||
expect(dobble.view(s, 'C').suspects).toEqual([]);
|
||||
expect(dobble.view(s, null).suspects).toEqual([]);
|
||||
// A slow answer resets the streak.
|
||||
let t = started(make({}, ['A', 'B']));
|
||||
let n = START;
|
||||
for (let i = 0; i < 4; i++) t = act(t, 'B', right(t, 'B'), (n += 100));
|
||||
t = act(t, 'B', right(t, 'B'), (n += 900));
|
||||
expect(t.stats.B!.fastHits).toBe(0);
|
||||
});
|
||||
|
||||
test('이벤트에는 공개된 카드만 담긴다', () => {
|
||||
let s = started(make({}, ['A', 'B']));
|
||||
const { state, events } = applyE(s, 'A', right(s, 'A'), START + 300);
|
||||
s = state;
|
||||
const secrets = dobble.secretsOf!(s, 'A');
|
||||
for (const e of events) for (const k of secrets) expect(JSON.stringify(e)).not.toContain(`"${k}"`);
|
||||
expect(events.find((e) => e.type === 'reveal')).toEqual({ type: 'reveal', key: cardKey(s.center.at(-1)!) });
|
||||
});
|
||||
|
||||
test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => {
|
||||
for (const opts of [{}, { miniGame: 'hotPotato', latencyComp: 'light' }, { miniGame: 'well' }] as Partial<DobbleOptions>[]) {
|
||||
const options = dobble.optionsSchema.parse(opts);
|
||||
const a = fastPlayout(['A', 'B', 'C', 'D'], options, seedN(42), 20_000);
|
||||
expect(a.finished).toBe(true);
|
||||
const replay = (log: FastReport['log']) => {
|
||||
let s = dobble.setup({ players: ['A', 'B', 'C', 'D'], options, rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 });
|
||||
for (const x of log) s = dobble.apply(s, x.p, x.a, { rng: rng(), now: x.now }).state;
|
||||
return s;
|
||||
};
|
||||
expect(JSON.stringify(replay(a.log))).toBe(JSON.stringify(a.final));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
interface FastReport {
|
||||
steps: number;
|
||||
finished: boolean;
|
||||
log: { p: PlayerId; a: DobbleAction; now: number }[];
|
||||
final: DobbleState;
|
||||
}
|
||||
|
||||
function invariants(s: DobbleState): void {
|
||||
conserved(s);
|
||||
const o = s.options;
|
||||
for (const p of s.order) {
|
||||
const st = s.status[p]!;
|
||||
if (o.miniGame === 'well' && st === 'finished' && s.piles[p]!.length) throw new Error('finished with cards');
|
||||
if ((o.miniGame === 'tower' || o.miniGame === 'poisonedGift') && s.piles[p]!.length < 1) throw new Error(`${p} lost the starting card`);
|
||||
if (o.miniGame === 'hotPotato' && s.penalty[p] !== s.taken[p]!.length) throw new Error('penalty != taken');
|
||||
if (st === 'escaped' && (o.miniGame !== 'hotPotato' || s.piles[p]!.length)) throw new Error('bad escaped');
|
||||
}
|
||||
if (s.phase === 'playing') {
|
||||
if (s.center.length > 0 && o.miniGame !== 'well' && s.center.length !== 1) throw new Error('centre holds more than one card');
|
||||
const live = s.order.filter((p) => s.status[p] !== 'forfeit' && s.status[p] !== 'left');
|
||||
if (live.length < 2) throw new Error('game should have ended');
|
||||
if (o.miniGame === 'hotPotato' && s.order.filter((p) => s.status[p] === 'playing' && s.piles[p]!.length).length < 2) throw new Error('round should have ended');
|
||||
if (o.miniGame !== 'hotPotato' && s.center.length === 0) throw new Error('no centre card');
|
||||
}
|
||||
if (s.phase === 'ended' && !s.final) throw new Error('ended without result');
|
||||
}
|
||||
|
||||
/**
|
||||
* Time moves 20–1500ms per step. Mostly correct claims (some made against an older table → late),
|
||||
* some wrong ones, server ticks when due, and now and then a disconnected player's early tick or a leave.
|
||||
*/
|
||||
function fastPlayout(players: PlayerId[], options: DobbleOptions, seed: RngState, maxSteps: number, away: PlayerId | null = null): FastReport {
|
||||
const r = SeededRng.fromSeed(seed);
|
||||
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let now = 1_700_000_000_000;
|
||||
let s = dobble.setup({ players, options, rng: r, now });
|
||||
const log: FastReport['log'] = [];
|
||||
let stale: { p: PlayerId; a: ClaimAction } | null = null;
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
invariants(s);
|
||||
if (step % 20 === 0) checkNoLeak(dobble, s, players[step % players.length]!, players);
|
||||
for (const p of dobble.timeoutPlayers!(s)) {
|
||||
const v = dobble.validate(s, p, dobble.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
|
||||
}
|
||||
if (dobble.result(s)) return { steps: step, finished: true, log, final: s };
|
||||
now += 20 + chooser.int(1480);
|
||||
const due = dueAt(s)!;
|
||||
let actor: PlayerId;
|
||||
let action: DobbleAction;
|
||||
const roll = chooser.int(100);
|
||||
const claimers = s.order.filter((p) => p !== away && pairsFor(s, p).length > 0);
|
||||
const notBefore = dobble.timeoutNotBefore!(s);
|
||||
if (away && notBefore !== null && now >= notBefore && now < due && dobble.timeoutPlayers!(s).includes(away)) {
|
||||
// The disconnected player is timed out as soon as timeoutNotBefore allows (the room does the same after grace).
|
||||
actor = away;
|
||||
action = dobble.onTimeout(s, away);
|
||||
} else if (now >= due || claimers.length === 0) {
|
||||
if (now < due) now = due;
|
||||
const tp = dobble.timeoutPlayers!(s);
|
||||
actor = tp[chooser.int(tp.length)]!;
|
||||
action = dobble.onTimeout(s, actor);
|
||||
} else if (roll < 2 && dobble.timeoutPlayers!(s).length > 0) {
|
||||
// A disconnected player's early timeout (only fires once per away step, as the room would).
|
||||
const nb = dobble.timeoutNotBefore!(s)!;
|
||||
if (now < nb) now = nb;
|
||||
const tp = dobble.timeoutPlayers!(s);
|
||||
actor = tp[0]!;
|
||||
action = dobble.onTimeout(s, actor);
|
||||
} else if (roll < 3) {
|
||||
const live = s.order.filter((p) => dobble.onLeave!(s, p));
|
||||
actor = live[chooser.int(live.length)]!;
|
||||
action = { type: 'leave' };
|
||||
} else if (roll < 10 && stale) {
|
||||
actor = stale.p;
|
||||
action = stale.a;
|
||||
} else {
|
||||
actor = claimers[chooser.int(claimers.length)]!;
|
||||
const pairs = pairsFor(s, actor);
|
||||
const pair = pairs[chooser.int(pairs.length)]!;
|
||||
const ans = answerArt(s, actor, pair)!;
|
||||
const wrongOne = roll < 25;
|
||||
const cc = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[actor]!.at(-1)!;
|
||||
const sym = wrongOne ? s.symbolArt[s.cards[cc]![chooser.int(8)]!]! : ans;
|
||||
action = {
|
||||
type: 'claim',
|
||||
base: pair.base,
|
||||
symbol: sym,
|
||||
...(pair.target ? { target: pair.target } : {}),
|
||||
...(pair.mine !== null ? { mine: pair.mine } : {}),
|
||||
rtt: chooser.int(300),
|
||||
};
|
||||
stale = { p: claimers[chooser.int(claimers.length)]!, a: action as ClaimAction };
|
||||
}
|
||||
const v = dobble.validate(s, actor, action);
|
||||
if (!v.ok) {
|
||||
if (action.type !== 'claim') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${v.reason}`);
|
||||
continue;
|
||||
}
|
||||
const out = dobble.apply(s, actor, action, { rng: r, now });
|
||||
const secrets = dobble.secretsOf!(out.state, actor);
|
||||
for (const e of out.events) {
|
||||
const j = JSON.stringify(e);
|
||||
for (const k of secrets) if (j.includes(`"${k}"`)) throw new Error(`event leaks ${k}`);
|
||||
}
|
||||
s = out.state;
|
||||
log.push({ p: actor, a: action, now });
|
||||
}
|
||||
return { steps: maxSteps, finished: false, log, final: s };
|
||||
}
|
||||
|
||||
const PRESETS: Partial<DobbleOptions>[] = [
|
||||
{},
|
||||
{ miniGame: 'well' },
|
||||
{ miniGame: 'hotPotato' },
|
||||
{ miniGame: 'poisonedGift' },
|
||||
{ miniGame: 'tower', latencyComp: 'light', wrongGivesCard: true, deckSize: 55 },
|
||||
{ miniGame: 'well', wellEndAtFirst: true, wrongLockoutMs: 0, symbolStyle: 'easy' },
|
||||
{ miniGame: 'hotPotato', hotPotatoRounds: 7, latencyComp: 'light', wrongLockoutMs: 2000 },
|
||||
{ miniGame: 'poisonedGift', deckSize: 55, latencyComp: 'light' },
|
||||
{ miniGame: 'hotPotato', hotPotatoRounds: 3, deckSize: 55 },
|
||||
];
|
||||
|
||||
describe('random playouts (§10, docs/09 §7)', () => {
|
||||
test('1,000 seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
|
||||
const tally: Record<string, number> = { correct: 0, wrong: 0, forfeit: 0, left: 0 };
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const n = 2 + (i % 7);
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
||||
// Every 5th game one player is disconnected for the whole game (never claims).
|
||||
const away = i % 5 === 0 ? players[1]! : null;
|
||||
const a = fastPlayout(players, options, seedN(i), 20_000, away);
|
||||
if (!a.finished) throw new Error(`seed ${i} (${n} players, ${JSON.stringify(PRESETS[i % PRESETS.length])}) did not finish`);
|
||||
const res = dobble.result(a.final)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||
for (const p of players) {
|
||||
tally.correct! += a.final.stats[p]!.correct;
|
||||
tally.wrong! += a.final.stats[p]!.wrong;
|
||||
if (a.final.status[p] === 'forfeit') tally.forfeit!++;
|
||||
if (a.final.status[p] === 'left') tally.left!++;
|
||||
}
|
||||
if (i % 50 === 0) {
|
||||
const b = fastPlayout(players, options, seedN(i), 20_000, away);
|
||||
expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log));
|
||||
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||
}
|
||||
}
|
||||
expect(tally.correct).toBeGreaterThan(10_000);
|
||||
expect(tally.wrong).toBeGreaterThan(1000);
|
||||
expect(tally.forfeit).toBeGreaterThan(0);
|
||||
expect(tally.left).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
[2, {}],
|
||||
[3, { miniGame: 'well' }],
|
||||
[4, { miniGame: 'hotPotato', hotPotatoRounds: 3 }],
|
||||
[5, { miniGame: 'poisonedGift', latencyComp: 'light' }],
|
||||
[8, { miniGame: 'tower', wrongGivesCard: true }],
|
||||
] as const)('engine randomPlayout (per-step leak / round-trip checks): %i players %o', (n, opts) => {
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = dobble.optionsSchema.parse(opts);
|
||||
const inv = (st: unknown) => invariants(st as DobbleState);
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 });
|
||||
expect(a.finished).toBe(true);
|
||||
if (i === 1) {
|
||||
const b = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, maxApplyMs: 20 });
|
||||
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('공통 그림 헬퍼', () => {
|
||||
const d = buildDeck();
|
||||
expect(d[0]!).toContain(commonSymbol(d[0]!, d[1]!));
|
||||
expect(d[1]!).toContain(commonSymbol(d[0]!, d[1]!));
|
||||
});
|
||||
856
packages/games/src/dobble/index.ts
Normal file
856
packages/games/src/dobble/index.ts
Normal file
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* 도블 — 같은 그림 찾기 (docs/games/dobble.md). Pure and deterministic; state is JSON; time only via ctx.now.
|
||||
* Real time: everyone may claim at any moment, the room's single-threaded queue (arrival order) decides.
|
||||
*/
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||
import { FULL_DECK, buildDeck, makeLayout } from './deck';
|
||||
import {
|
||||
MINI_GAME_KO,
|
||||
dobbleActionSchema,
|
||||
dobbleOptionsSchema,
|
||||
type CardView,
|
||||
type ClaimAction,
|
||||
type ClaimPair,
|
||||
type DobbleAction,
|
||||
type DobbleFinal,
|
||||
type DobbleOptions,
|
||||
type DobbleState,
|
||||
type DobbleView,
|
||||
type Feedback,
|
||||
type LogEntry,
|
||||
type PendingClaim,
|
||||
type RankRow,
|
||||
type SeatStatus,
|
||||
} from './types';
|
||||
|
||||
export * from './types';
|
||||
export { FULL_DECK, PER_CARD, SYMBOLS, TEMPLATES, buildDeck, commonSymbol, makeLayout } from './deck';
|
||||
|
||||
type S = DobbleState;
|
||||
type A = DobbleAction;
|
||||
|
||||
export const COUNTDOWN_MS = 3000;
|
||||
/** Hot potato: pause showing the round's loser before the next countdown. */
|
||||
export const ROUND_BREAK_MS = 2500;
|
||||
/** §5.3 `latencyComp: 'light'` window. */
|
||||
export const WINDOW_MS = 60;
|
||||
export const MAX_LATENCY_CREDIT_MS = 80;
|
||||
/** §8.5 stall: nobody found anything for this long → show the common symbol for HINT_MS. */
|
||||
export const STALL_MS = 45_000;
|
||||
export const HINT_MS = 2000;
|
||||
/**
|
||||
* Away detection: the room only times a player out before the game's own deadline when they have been
|
||||
* disconnected past the grace period. Such early ticks are spaced AWAY_STEP_MS apart; AWAY_STRIKES of them
|
||||
* (≥ grace + 20s, so always ≥ 30s) forfeit the player in well / hot potato (§8.5).
|
||||
*/
|
||||
export const AWAY_STEP_MS = 10_000;
|
||||
export const AWAY_STRIKES = 3;
|
||||
/** §5.3 봇 의심: FAST_STREAK correct claims in a row, each < FAST_MS after the cards changed. */
|
||||
export const FAST_MS = 200;
|
||||
export const FAST_STREAK = 5;
|
||||
const RTT_ALPHA = 0.2;
|
||||
const LOG_KEEP = 30;
|
||||
const POINTS = [3, 2, 1];
|
||||
|
||||
/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */
|
||||
export const cardKey = (id: number): string => `dc${id}`;
|
||||
|
||||
export const LATE_TAKEN = '늦었어요! 다른 사람이 먼저 찾았어요.';
|
||||
export const LATE_CHANGED = '늦었어요! 그 사이에 카드가 바뀌었어요.';
|
||||
export const lockedText = (ms: number): string => `잠깐! ${Math.max(1, Math.ceil(ms / 1000))}초 뒤에 다시 눌러요`;
|
||||
|
||||
// ---------------------------------------------------------------- queries
|
||||
|
||||
const topOf = (s: S, p: PlayerId): number | undefined => s.piles[p]?.[s.piles[p]!.length - 1];
|
||||
const centerTop = (s: S): number | undefined => s.center[s.center.length - 1];
|
||||
/** Still in the game (not forfeited / left). */
|
||||
const inGame = (s: S, p: PlayerId): boolean => s.status[p] !== 'forfeit' && s.status[p] !== 'left';
|
||||
const usesTarget = (o: DobbleOptions): boolean => o.miniGame === 'hotPotato' || o.miniGame === 'poisonedGift';
|
||||
|
||||
/** Every card id, wherever it is (test invariant: each id exactly once). */
|
||||
export function allCardIds(s: S): number[] {
|
||||
return [...s.order.flatMap((p) => [...s.piles[p]!, ...s.taken[p]!]), ...s.center, ...s.drawPile, ...s.unused];
|
||||
}
|
||||
|
||||
export function canClaim(s: S, p: PlayerId): boolean {
|
||||
if (s.phase !== 'playing' || s.status[p] !== 'playing') return false;
|
||||
return s.options.miniGame === 'poisonedGift' || s.piles[p]!.length > 0;
|
||||
}
|
||||
|
||||
/** Whether `q` may be the target of `p`'s claim (gift / hot potato). */
|
||||
function validTarget(s: S, p: PlayerId, q: PlayerId): boolean {
|
||||
return q !== p && s.order.includes(q) && s.status[q] === 'playing' && s.piles[q]!.length > 0;
|
||||
}
|
||||
|
||||
/** Pairs of cards `p` may claim on right now (§5.2). */
|
||||
export function pairsFor(s: S, p: PlayerId): ClaimPair[] {
|
||||
if (!canClaim(s, p)) return [];
|
||||
const c = centerTop(s);
|
||||
const mine = topOf(s, p);
|
||||
switch (s.options.miniGame) {
|
||||
case 'tower':
|
||||
case 'well':
|
||||
return c === undefined || mine === undefined ? [] : [{ target: null, base: c, mine }];
|
||||
case 'poisonedGift':
|
||||
return c === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: c, mine: null }));
|
||||
case 'hotPotato':
|
||||
return mine === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: topOf(s, q)!, mine }));
|
||||
}
|
||||
}
|
||||
|
||||
/** The two card ids compared by a claim of `p` on `target` (null = the claim cannot be judged). */
|
||||
function comparedCards(s: S, p: PlayerId, target: PlayerId | undefined): [number, number] | null {
|
||||
const c = centerTop(s);
|
||||
const mine = topOf(s, p);
|
||||
switch (s.options.miniGame) {
|
||||
case 'tower':
|
||||
case 'well':
|
||||
return c === undefined || mine === undefined ? null : [mine, c];
|
||||
case 'poisonedGift': {
|
||||
const t = target === undefined ? undefined : topOf(s, target);
|
||||
return c === undefined || t === undefined ? null : [t, c];
|
||||
}
|
||||
case 'hotPotato': {
|
||||
const t = target === undefined ? undefined : topOf(s, target);
|
||||
return mine === undefined || t === undefined ? null : [mine, t];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The single symbol number shared by two cards. */
|
||||
function common(s: S, a: number, b: number): number {
|
||||
const x = s.cards[a]!;
|
||||
const y = s.cards[b]!;
|
||||
for (const v of x) if (y.includes(v)) return v;
|
||||
throw new Error(`dobble: cards ${a} and ${b} share nothing`);
|
||||
}
|
||||
|
||||
/** The correct icon id for a pair (tests / legalActions / stall hint). */
|
||||
export function answerArt(s: S, p: PlayerId, pair: ClaimPair): number | null {
|
||||
const cc = comparedCards(s, p, pair.target ?? undefined);
|
||||
return cc ? s.symbolArt[common(s, cc[0], cc[1])]! : null;
|
||||
}
|
||||
|
||||
/** Reference card id a claim on `target` must carry. */
|
||||
function currentBase(s: S, target: PlayerId | undefined): number | undefined {
|
||||
return s.options.miniGame === 'hotPotato' ? (target === undefined ? undefined : topOf(s, target)) : centerTop(s);
|
||||
}
|
||||
|
||||
/** Earliest time the server timer has something to do (null when ended). */
|
||||
export function dueAt(s: S): number | null {
|
||||
if (s.phase === 'ended') return null;
|
||||
if (s.phase === 'countdown' || s.phase === 'roundEnd') return s.phaseEndsAt;
|
||||
if (s.pendingWindow) return s.pendingWindow.openedAt + WINDOW_MS;
|
||||
return s.hintUntil > 0 ? s.hintUntil : s.changedAt + STALL_MS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
function clone(s: S): S {
|
||||
const rec = <T>(r: Record<PlayerId, T>, f: (v: T) => T) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, f(v)])) as Record<PlayerId, T>;
|
||||
return {
|
||||
...s, // cards, layouts, symbolArt and options are never mutated
|
||||
order: [...s.order],
|
||||
status: { ...s.status },
|
||||
piles: rec(s.piles, (v) => [...v]),
|
||||
center: [...s.center],
|
||||
drawPile: [...s.drawPile],
|
||||
unused: [...s.unused],
|
||||
taken: rec(s.taken, (v) => [...v]),
|
||||
lockedUntil: { ...s.lockedUntil },
|
||||
pendingWindow: s.pendingWindow ? { ...s.pendingWindow, claims: s.pendingWindow.claims.map((c) => ({ ...c })) } : null,
|
||||
penalty: { ...s.penalty },
|
||||
outThisRound: [...s.outThisRound],
|
||||
finishedOrder: [...s.finishedOrder],
|
||||
away: rec(s.away, (v) => ({ ...v })),
|
||||
rtt: { ...s.rtt },
|
||||
stats: rec(s.stats, (v) => ({ ...v })),
|
||||
suspect: [...s.suspect],
|
||||
feedback: { ...s.feedback },
|
||||
log: [...s.log],
|
||||
};
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
now: number;
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'n'> : never) : never;
|
||||
|
||||
function log(s: S, e: LogBody): void {
|
||||
s.logNo += 1;
|
||||
s.log.push({ n: s.logNo, ...e } as LogEntry);
|
||||
if (s.log.length > LOG_KEEP) s.log.splice(0, s.log.length - LOG_KEEP);
|
||||
}
|
||||
|
||||
function judge(s: S, p: PlayerId, kind: Feedback['kind'], art: number | null, ctx: Ctx): void {
|
||||
s.seq += 1;
|
||||
s.feedback[p] = { seq: s.seq, kind, art, at: ctx.now };
|
||||
}
|
||||
|
||||
/** Public cards changed: restart the stall timer and cancel a showing hint. */
|
||||
function cardsChanged(s: S, now: number): void {
|
||||
s.changedAt = now;
|
||||
s.hintUntil = 0;
|
||||
}
|
||||
|
||||
/** Turns the next hidden card face up in the centre (tower / gift); false when the draw pile is empty. */
|
||||
function revealNext(s: S, ctx: Ctx): boolean {
|
||||
const id = s.drawPile.pop();
|
||||
if (id === undefined) return false;
|
||||
s.center.push(id);
|
||||
ctx.events.push({ type: 'reveal', key: cardKey(id) });
|
||||
return true;
|
||||
}
|
||||
|
||||
function dealRound(s: S, ctx: Ctx): boolean {
|
||||
const players = s.order.filter((p) => inGame(s, p));
|
||||
if (s.drawPile.length < players.length) return false;
|
||||
for (const p of players) {
|
||||
s.status[p] = 'playing';
|
||||
s.piles[p] = [s.drawPile.pop()!];
|
||||
}
|
||||
s.outThisRound = [];
|
||||
s.phase = 'countdown';
|
||||
s.phaseEndsAt = ctx.now + COUNTDOWN_MS;
|
||||
s.changedAt = s.phaseEndsAt;
|
||||
s.hintUntil = 0;
|
||||
ctx.events.push({ type: 'roundStart', round: s.round });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- claims
|
||||
|
||||
function succeed(s: S, c: PendingClaim, ctx: Ctx): void {
|
||||
const p = c.player;
|
||||
const st = s.stats[p]!;
|
||||
st.correct += 1;
|
||||
if (ctx.now - s.changedAt < FAST_MS) {
|
||||
st.fastHits += 1;
|
||||
if (st.fastHits >= FAST_STREAK && !s.suspect.includes(p)) s.suspect.push(p);
|
||||
} else st.fastHits = 0;
|
||||
s.away[p] = { strikes: 0, last: 0 };
|
||||
const art = s.symbolArt[c.symbol]!;
|
||||
judge(s, p, 'ok', art, ctx);
|
||||
s.lastHit = { seq: s.seq, player: p, target: c.target, art, at: ctx.now };
|
||||
log(s, { t: 'hit', p, target: c.target, art });
|
||||
ctx.events.push({ type: 'hit', player: p, target: c.target, art });
|
||||
cardsChanged(s, ctx.now);
|
||||
switch (s.options.miniGame) {
|
||||
case 'tower': {
|
||||
s.piles[p]!.push(s.center.pop()!);
|
||||
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
case 'poisonedGift': {
|
||||
s.piles[c.target as PlayerId]!.push(s.center.pop()!);
|
||||
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
case 'well': {
|
||||
s.center.push(s.piles[p]!.pop()!);
|
||||
if (s.piles[p]!.length === 0) {
|
||||
s.status[p] = 'finished';
|
||||
s.finishedOrder.push(p);
|
||||
log(s, { t: 'done', p, place: s.finishedOrder.length });
|
||||
ctx.events.push({ type: 'done', player: p, place: s.finishedOrder.length });
|
||||
}
|
||||
checkWellEnd(s, ctx);
|
||||
return;
|
||||
}
|
||||
case 'hotPotato': {
|
||||
const t = c.target as PlayerId;
|
||||
s.piles[t]!.push(...s.piles[p]!);
|
||||
s.piles[p] = [];
|
||||
s.status[p] = 'escaped';
|
||||
s.outThisRound.push(p);
|
||||
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
|
||||
if (holders.length <= 1) endRound(s, holders[0] ?? null, ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkWellEnd(s: S, ctx: Ctx): void {
|
||||
if (s.phase === 'ended') return;
|
||||
const left = s.order.filter((q) => s.status[q] === 'playing');
|
||||
if ((s.options.wellEndAtFirst && s.finishedOrder.length > 0) || left.length <= 1) finish(s, ctx);
|
||||
}
|
||||
|
||||
/** Hot potato: the last holder keeps the bundle as penalty (null = round voided because someone left). */
|
||||
function endRound(s: S, loser: PlayerId | null, ctx: Ctx): void {
|
||||
let cards = 0;
|
||||
if (loser) {
|
||||
cards = s.piles[loser]!.length;
|
||||
s.penalty[loser] = (s.penalty[loser] ?? 0) + cards;
|
||||
s.taken[loser]!.push(...s.piles[loser]!);
|
||||
s.piles[loser] = [];
|
||||
} else {
|
||||
for (const p of s.order) {
|
||||
s.unused.push(...s.piles[p]!);
|
||||
s.piles[p] = [];
|
||||
}
|
||||
}
|
||||
s.pendingWindow = null;
|
||||
s.lastRound = { round: s.round, loser, cards };
|
||||
log(s, { t: 'round', round: s.round, loser, cards });
|
||||
ctx.events.push({ type: 'roundEnd', round: s.round, loser, cards });
|
||||
for (const p of s.order) if (s.status[p] === 'escaped') s.status[p] = 'playing';
|
||||
s.outThisRound = [];
|
||||
const players = s.order.filter((p) => inGame(s, p));
|
||||
if (s.round >= s.options.hotPotatoRounds || players.length < 2) {
|
||||
finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
s.phase = 'roundEnd';
|
||||
s.phaseEndsAt = ctx.now + ROUND_BREAK_MS;
|
||||
s.hintUntil = 0;
|
||||
}
|
||||
|
||||
/** Re-checks a claim against the current table (it may have changed since it arrived). */
|
||||
function stillValid(s: S, c: PendingClaim): boolean {
|
||||
if (!canClaim(s, c.player)) return false;
|
||||
const target = c.target === 'center' ? undefined : c.target;
|
||||
if (target !== undefined && !validTarget(s, c.player, target)) return false;
|
||||
if (currentBase(s, target) !== c.base) return false;
|
||||
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, c.player) !== c.mine) return false;
|
||||
const cc = comparedCards(s, c.player, target);
|
||||
return !!cc && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
|
||||
}
|
||||
|
||||
/** Closes the latency window: earliest adjusted time first (ties: arrival), stale claims become "late". */
|
||||
function resolveWindow(s: S, ctx: Ctx): void {
|
||||
const w = s.pendingWindow;
|
||||
if (!w) return;
|
||||
s.pendingWindow = null;
|
||||
const sorted = [...w.claims].sort((a, b) => a.adjTs - b.adjTs || a.arrived - b.arrived);
|
||||
for (const c of sorted) {
|
||||
if (s.phase === 'playing' && stillValid(s, c)) succeed(s, c, ctx);
|
||||
else if (s.order.includes(c.player)) {
|
||||
judge(s, c.player, 'late', null, ctx);
|
||||
ctx.events.push({ type: 'late', player: c.player });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function claim(s: S, p: PlayerId, a: ClaimAction, ctx: Ctx): void {
|
||||
if (s.pendingWindow && ctx.now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
|
||||
if (a.rtt !== undefined) {
|
||||
const sample = Math.min(2000, Math.max(0, Math.round(a.rtt)));
|
||||
const old = s.rtt[p];
|
||||
s.rtt[p] = old === null || old === undefined ? sample : Math.round(old * (1 - RTT_ALPHA) + sample * RTT_ALPHA);
|
||||
}
|
||||
const target = a.target;
|
||||
const c: PendingClaim = {
|
||||
player: p,
|
||||
target: target ?? 'center',
|
||||
base: a.base,
|
||||
mine: a.mine ?? null,
|
||||
symbol: s.symbolArt.indexOf(a.symbol),
|
||||
adjTs: ctx.now,
|
||||
arrived: ctx.now,
|
||||
};
|
||||
// The table moved while the window was being resolved → this claim is late (no penalty).
|
||||
if (s.phase !== 'playing' || !canClaim(s, p) || (target !== undefined && !validTarget(s, p, target)) || currentBase(s, target) !== a.base) {
|
||||
judge(s, p, 'late', null, ctx);
|
||||
ctx.events.push({ type: 'late', player: p });
|
||||
return;
|
||||
}
|
||||
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, p) !== c.mine) {
|
||||
judge(s, p, 'late', null, ctx);
|
||||
ctx.events.push({ type: 'late', player: p });
|
||||
return;
|
||||
}
|
||||
s.away[p] = { strikes: 0, last: 0 };
|
||||
// §5.3 잠금: judged on the server clock.
|
||||
if (ctx.now < (s.lockedUntil[p] ?? 0)) {
|
||||
judge(s, p, 'locked', null, ctx);
|
||||
ctx.events.push({ type: 'locked', player: p });
|
||||
return;
|
||||
}
|
||||
const cc = comparedCards(s, p, target)!;
|
||||
const right = c.symbol >= 0 && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
|
||||
if (!right) {
|
||||
s.stats[p]!.wrong += 1;
|
||||
s.lockedUntil[p] = ctx.now + s.options.wrongLockoutMs;
|
||||
judge(s, p, 'wrong', a.symbol, ctx);
|
||||
ctx.events.push({ type: 'wrong', player: p });
|
||||
// §7 wrongGivesCard (tower): my top card goes under the draw pile; my last card always stays.
|
||||
if (s.options.miniGame === 'tower' && s.options.wrongGivesCard && s.piles[p]!.length >= 2) {
|
||||
s.drawPile.unshift(s.piles[p]!.pop()!);
|
||||
ctx.events.push({ type: 'gaveCard', player: p });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (s.options.latencyComp === 'light') {
|
||||
const rtt = s.rtt[p] ?? 0;
|
||||
c.adjTs = ctx.now - Math.min(rtt / 2, MAX_LATENCY_CREDIT_MS);
|
||||
if (!s.pendingWindow) s.pendingWindow = { openedAt: ctx.now, claims: [] };
|
||||
s.pendingWindow.claims.push(c);
|
||||
ctx.events.push({ type: 'windowed', player: p });
|
||||
return;
|
||||
}
|
||||
succeed(s, c, ctx);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timer, away, leaving
|
||||
|
||||
function processDue(s: S, ctx: Ctx): void {
|
||||
if (s.phase === 'countdown') {
|
||||
s.phase = 'playing';
|
||||
cardsChanged(s, ctx.now);
|
||||
ctx.events.push({ type: 'start', round: s.round });
|
||||
return;
|
||||
}
|
||||
if (s.phase === 'roundEnd') {
|
||||
s.round += 1;
|
||||
if (!dealRound(s, ctx)) finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
if (s.pendingWindow) {
|
||||
resolveWindow(s, ctx);
|
||||
return;
|
||||
}
|
||||
if (s.hintUntil > 0) {
|
||||
// Hint over: keep playing the same cards (no points for anyone), stall timer restarts.
|
||||
cardsChanged(s, ctx.now);
|
||||
return;
|
||||
}
|
||||
s.hintUntil = ctx.now + HINT_MS;
|
||||
log(s, { t: 'hint' });
|
||||
ctx.events.push({ type: 'hint' });
|
||||
}
|
||||
|
||||
/** Removes `p` (left the room, or forfeited after being away in well / hot potato; §8.5). */
|
||||
function drop(s: S, p: PlayerId, why: 'forfeit' | 'left', ctx: Ctx): void {
|
||||
const was = s.status[p];
|
||||
s.status[p] = why;
|
||||
if (s.pendingWindow) s.pendingWindow.claims = s.pendingWindow.claims.filter((c) => c.player !== p);
|
||||
const mode = s.options.miniGame;
|
||||
const holding = s.piles[p]!.length > 0;
|
||||
if (mode === 'well' || mode === 'hotPotato') {
|
||||
// Cards go back to the box ("가운데로 반환"); tower / gift piles stay as they are.
|
||||
s.unused.push(...s.piles[p]!);
|
||||
s.piles[p] = [];
|
||||
}
|
||||
s.outThisRound = s.outThisRound.filter((q) => q !== p);
|
||||
log(s, { t: 'out', p, why });
|
||||
ctx.events.push({ type: 'out', player: p, why });
|
||||
if (s.order.filter((q) => inGame(s, q)).length < 2) {
|
||||
finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
if (mode === 'well') checkWellEnd(s, ctx);
|
||||
if (mode === 'hotPotato' && s.phase === 'playing' && was === 'playing' && holding) {
|
||||
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
|
||||
// Nobody left to race against: the round is void (no penalty).
|
||||
if (holders.length <= 1) endRound(s, null, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/** An early tick: the room timed `p` out while disconnected past the grace period. */
|
||||
function awayStrike(s: S, p: PlayerId, ctx: Ctx): void {
|
||||
const st = s.status[p];
|
||||
if (st !== 'playing' && st !== 'escaped') return;
|
||||
const prev = s.away[p] ?? { strikes: 0, last: 0 };
|
||||
// Strikes rotate over every away player, so the gap between one player's strikes is ≤ players × step.
|
||||
const fresh = prev.last > 0 && ctx.now - prev.last <= AWAY_STEP_MS * (s.order.length + 1);
|
||||
const strikes = fresh ? prev.strikes + 1 : 1;
|
||||
s.away[p] = { strikes, last: ctx.now };
|
||||
const mode = s.options.miniGame;
|
||||
if (strikes >= AWAY_STRIKES && (mode === 'well' || mode === 'hotPotato')) {
|
||||
drop(s, p, 'forfeit', ctx);
|
||||
return;
|
||||
}
|
||||
// Tower / gift keep going without the away player — unless everybody is away.
|
||||
if (mode === 'tower' || mode === 'poisonedGift') {
|
||||
const live = s.order.filter((q) => s.status[q] === 'playing');
|
||||
const allAway = live.every((q) => {
|
||||
const a = s.away[q];
|
||||
return !!a && a.strikes >= AWAY_STRIKES && ctx.now - a.last <= AWAY_STEP_MS * (s.order.length + 1);
|
||||
});
|
||||
if (allAway) finish(s, ctx, '모두 자리를 비워서 게임을 끝냈어요.');
|
||||
}
|
||||
}
|
||||
|
||||
function tick(s: S, actor: PlayerId, ctx: Ctx): void {
|
||||
const due = dueAt(s);
|
||||
if (due !== null && ctx.now >= due) processDue(s, ctx);
|
||||
else if (ctx.now >= s.awayCheckAt) {
|
||||
awayStrike(s, actor, ctx);
|
||||
s.awayRot += 1;
|
||||
}
|
||||
s.awayCheckAt = Math.max(s.awayCheckAt, ctx.now + AWAY_STEP_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ending
|
||||
|
||||
function rankBy(ids: PlayerId[], key: (p: PlayerId) => number, start: number): { id: PlayerId; rank: number }[] {
|
||||
const sorted = [...ids].sort((a, b) => key(a) - key(b));
|
||||
return sorted.map((id, i) => {
|
||||
let j = i;
|
||||
while (j > 0 && key(sorted[j - 1]!) === key(id)) j--;
|
||||
return { id, rank: start + j };
|
||||
});
|
||||
}
|
||||
|
||||
function finish(s: S, ctx: Ctx, override?: string): void {
|
||||
if (s.phase === 'ended') return;
|
||||
s.phase = 'ended';
|
||||
s.pendingWindow = null;
|
||||
s.hintUntil = 0;
|
||||
const mode = s.options.miniGame;
|
||||
const live = s.order.filter((p) => inGame(s, p));
|
||||
const gone = s.order.filter((p) => !inGame(s, p));
|
||||
const cards = (p: PlayerId) => s.piles[p]!.length;
|
||||
let ranks: { id: PlayerId; rank: number }[];
|
||||
switch (mode) {
|
||||
case 'tower':
|
||||
ranks = rankBy(live, (p) => -cards(p), 1);
|
||||
break;
|
||||
case 'poisonedGift':
|
||||
ranks = rankBy(live, cards, 1);
|
||||
break;
|
||||
case 'hotPotato':
|
||||
ranks = rankBy(live, (p) => s.penalty[p] ?? 0, 1);
|
||||
break;
|
||||
case 'well': {
|
||||
const done = s.finishedOrder.filter((p) => live.includes(p)).map((id, i) => ({ id, rank: i + 1 }));
|
||||
ranks = [...done, ...rankBy(live.filter((p) => !s.finishedOrder.includes(p)), cards, done.length + 1)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forfeited / left players share the bottom place (§8.5 "꼴찌 그룹").
|
||||
for (const id of gone) ranks.push({ id, rank: live.length + 1 });
|
||||
const ranking: RankRow[] = ranks.map(({ id, rank }) => ({
|
||||
rank,
|
||||
id,
|
||||
...(mode === 'hotPotato' ? { penalty: s.penalty[id] ?? 0 } : { cards: cards(id) }),
|
||||
// Forfeited / left players score nothing even when the bottom place is 3rd.
|
||||
points: inGame(s, id) ? (POINTS[rank - 1] ?? 0) : 0,
|
||||
status: s.status[id]!,
|
||||
}));
|
||||
const winners = ranking.filter((r) => r.rank === 1);
|
||||
const w = winners[0]!;
|
||||
let summary: string;
|
||||
if (override) summary = override;
|
||||
else if (live.length === 1 && gone.length > 0) summary = `다른 사람이 모두 나가서 ${MINI_GAME_KO[mode]}을(를) 끝냈어요.`;
|
||||
else if (mode === 'tower') summary = `탑 쌓기 끝! 카드 ${w.cards}장을 모은 사람이 1등이에요.`;
|
||||
else if (mode === 'poisonedGift') summary = `독이 든 선물 끝! 카드를 ${w.cards}장만 받은 사람이 1등이에요.`;
|
||||
else if (mode === 'hotPotato') summary = `뜨거운 감자 ${s.lastRound?.round ?? s.round}라운드 끝! 벌점 ${w.penalty}장으로 가장 적은 사람이 1등이에요.`;
|
||||
else summary = s.finishedOrder.length > 0 ? '우물 끝! 카드를 가장 먼저 다 비운 사람이 1등이에요.' : '우물 끝! 카드가 가장 적게 남은 사람이 1등이에요.';
|
||||
if (winners.length > 1 && !override) summary += ` (공동 1등 ${winners.length}명)`;
|
||||
const stats: DobbleFinal['stats'] = {};
|
||||
for (const p of s.order) stats[p] = { correct: s.stats[p]!.correct, wrong: s.stats[p]!.wrong };
|
||||
s.final = { miniGame: mode, ranking, stats, summary };
|
||||
ctx.events.push({ type: 'gameEnd' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
function cardView(s: S, id: number): CardView {
|
||||
return {
|
||||
id,
|
||||
key: cardKey(id),
|
||||
items: s.layouts[id]!.items.map((i) => ({ art: s.symbolArt[i.symbol]!, x: i.x, y: i.y, r: i.r, scale: i.scale, rot: i.rot })),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(players: PlayerId[], options: DobbleOptions, rng: Rng, now: number): S {
|
||||
if (players.length < 2 || players.length > 8) throw new Error(`dobble: ${players.length} players not allowed`);
|
||||
const err = playerCountError(options, players.length);
|
||||
if (err) throw new Error(err);
|
||||
// §8.4: seats, symbol → icon, card looks, deck order and the 55-card removals all come from the seeded RNG.
|
||||
const order = rng.shuffle(players);
|
||||
const symbolArt = rng.shuffle(Array.from({ length: FULL_DECK }, (_, i) => i));
|
||||
const cards = buildDeck();
|
||||
const layouts = cards.map((c) => makeLayout(c, options.symbolStyle, rng));
|
||||
const deck = rng.shuffle(cards.map((_, i) => i));
|
||||
const unused = options.deckSize === 55 ? deck.splice(0, FULL_DECK - 55) : [];
|
||||
const per0 = <T>(v: () => T) => Object.fromEntries(order.map((p) => [p, v()])) as Record<PlayerId, T>;
|
||||
const s: S = {
|
||||
options,
|
||||
host: players[0]!,
|
||||
cards,
|
||||
layouts,
|
||||
symbolArt,
|
||||
order,
|
||||
status: per0<SeatStatus>(() => 'playing'),
|
||||
piles: per0<number[]>(() => []),
|
||||
center: [],
|
||||
drawPile: [],
|
||||
unused,
|
||||
taken: per0<number[]>(() => []),
|
||||
lockedUntil: per0(() => 0),
|
||||
pendingWindow: null,
|
||||
round: 1,
|
||||
penalty: per0(() => 0),
|
||||
outThisRound: [],
|
||||
finishedOrder: [],
|
||||
phase: 'countdown',
|
||||
phaseEndsAt: now + COUNTDOWN_MS,
|
||||
changedAt: now + COUNTDOWN_MS,
|
||||
hintUntil: 0,
|
||||
awayCheckAt: now + COUNTDOWN_MS + AWAY_STEP_MS,
|
||||
awayRot: 0,
|
||||
away: per0(() => ({ strikes: 0, last: 0 })),
|
||||
rtt: per0<number | null>(() => null),
|
||||
stats: per0(() => ({ correct: 0, wrong: 0, fastHits: 0 })),
|
||||
suspect: [],
|
||||
feedback: {},
|
||||
lastHit: null,
|
||||
lastRound: null,
|
||||
seq: 0,
|
||||
logNo: 0,
|
||||
log: [],
|
||||
final: null,
|
||||
};
|
||||
const ctx: Ctx = { now, events: [] };
|
||||
switch (options.miniGame) {
|
||||
case 'tower':
|
||||
case 'poisonedGift':
|
||||
for (const p of order) s.piles[p] = [deck.pop()!];
|
||||
s.center = [deck.pop()!];
|
||||
s.drawPile = deck;
|
||||
break;
|
||||
case 'well': {
|
||||
s.center = [deck.pop()!];
|
||||
const per = Math.floor(deck.length / order.length);
|
||||
order.forEach((p, i) => (s.piles[p] = deck.slice(i * per, (i + 1) * per)));
|
||||
s.unused.push(...deck.slice(order.length * per));
|
||||
break;
|
||||
}
|
||||
case 'hotPotato':
|
||||
s.drawPile = deck;
|
||||
dealRound(s, ctx);
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function playerCountError(o: DobbleOptions, count: number): string | null {
|
||||
if (o.miniGame === 'hotPotato' && count * o.hotPotatoRounds > o.deckSize) {
|
||||
return `뜨거운 감자 ${o.hotPotatoRounds}라운드를 ${count}명이 하기에는 카드가 모자라요. 라운드를 줄이거나 57장 덱을 골라 주세요.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
type Def = GameDefinition<S, A, DobbleView, DobbleOptions>;
|
||||
|
||||
export const dobble: Def = {
|
||||
id: 'dobble',
|
||||
nameKo: '도블',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
stateVersion: 1,
|
||||
defaultOptions: dobbleOptionsSchema.parse({}),
|
||||
optionsSchema: dobbleOptionsSchema as unknown as Def['optionsSchema'],
|
||||
actionSchema: dobbleActionSchema as unknown as Def['actionSchema'],
|
||||
|
||||
playerCountError,
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
return setup(players, options, rng, now);
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase === 'ended') return fail('게임이 이미 끝났어요.');
|
||||
switch (a.type) {
|
||||
case 'tick':
|
||||
return ok;
|
||||
case 'resolve':
|
||||
return s.pendingWindow ? ok : fail('판정할 찾기가 없어요.');
|
||||
case 'leave':
|
||||
return s.status[actor] === 'playing' || s.status[actor] === 'escaped' ? ok : fail('이미 게임에서 빠졌어요.');
|
||||
case 'claim': {
|
||||
if (s.phase === 'countdown') return fail('곧 시작해요. 잠시만 기다려 주세요.');
|
||||
if (s.phase === 'roundEnd') return fail('다음 라운드를 준비하고 있어요.');
|
||||
const st = s.status[actor];
|
||||
if (st === 'forfeit' || st === 'left') return fail('게임에서 빠져서 찾을 수 없어요.');
|
||||
if (st === 'finished') return fail('이미 카드를 다 비웠어요.');
|
||||
if (st === 'escaped') return fail('이번 라운드는 탈출했어요. 다음 라운드를 기다려요.');
|
||||
if (!canClaim(s, actor)) return fail('지금은 찾을 카드가 없어요.');
|
||||
if (usesTarget(s.options)) {
|
||||
if (a.target === undefined) return fail('누구 카드에서 찾을지 먼저 골라 주세요.');
|
||||
if (a.target === actor) return fail(s.options.miniGame === 'poisonedGift' ? '내 카드에는 선물을 줄 수 없어요.' : '내 카드끼리는 비교할 수 없어요.');
|
||||
if (!s.order.includes(a.target)) return fail('그런 사람은 없어요.');
|
||||
const ts = s.status[a.target];
|
||||
if (ts === 'escaped') return fail('이미 이번 라운드를 탈출한 사람이에요.');
|
||||
if (ts !== 'playing' || !validTarget(s, actor, a.target)) return fail('게임에서 빠진 사람이에요.');
|
||||
} else if (a.target !== undefined) return fail('이 미니게임은 대상을 고르지 않아요.');
|
||||
// §5.1: the reference card moved on → "늦었어요" (no penalty).
|
||||
if (currentBase(s, a.target) !== a.base) return fail(s.options.miniGame === 'hotPotato' ? LATE_CHANGED : LATE_TAKEN);
|
||||
if (a.mine !== undefined && s.options.miniGame !== 'poisonedGift' && topOf(s, actor) !== a.mine) return fail(LATE_CHANGED);
|
||||
if (s.pendingWindow?.claims.some((c) => c.player === actor)) return fail('이미 눌렀어요. 판정을 기다려 주세요.');
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
const s = clone(prev);
|
||||
const ctx: Ctx = { now, events: [] };
|
||||
switch (a.type) {
|
||||
case 'tick':
|
||||
tick(s, actor, ctx);
|
||||
break;
|
||||
case 'resolve':
|
||||
if (s.pendingWindow && now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
|
||||
break;
|
||||
case 'claim':
|
||||
claim(s, actor, a, ctx);
|
||||
break;
|
||||
case 'leave':
|
||||
drop(s, actor, 'left', ctx);
|
||||
break;
|
||||
}
|
||||
return { state: s, events: ctx.events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const me = viewer && s.order.includes(viewer) ? viewer : null;
|
||||
const reveal = s.phase !== 'countdown';
|
||||
const pairs = me && s.phase === 'playing' ? pairsFor(s, me) : [];
|
||||
let hint: number[] = [];
|
||||
if (s.phase === 'playing' && s.hintUntil > 0) {
|
||||
const c0 = centerTop(s);
|
||||
const arts = me
|
||||
? pairsFor(s, me).map((pair) => answerArt(s, me, pair))
|
||||
: s.options.miniGame === 'poisonedGift' && c0 !== undefined
|
||||
? s.order.filter((q) => s.status[q] === 'playing' && topOf(s, q) !== undefined).map((q) => s.symbolArt[common(s, topOf(s, q)!, c0)]!)
|
||||
: [];
|
||||
hint = [...new Set(arts.filter((x): x is number => x !== null))];
|
||||
}
|
||||
const c = centerTop(s);
|
||||
return {
|
||||
options: s.options,
|
||||
me,
|
||||
isHost: viewer === s.host,
|
||||
phase: s.phase,
|
||||
phaseEndsAt: s.phaseEndsAt,
|
||||
round: s.round,
|
||||
rounds: s.options.miniGame === 'hotPotato' ? s.options.hotPotatoRounds : 1,
|
||||
seats: s.order.map((p) => {
|
||||
const t = topOf(s, p);
|
||||
const place = s.finishedOrder.indexOf(p);
|
||||
return {
|
||||
id: p,
|
||||
status: s.status[p]!,
|
||||
cards: s.piles[p]!.length,
|
||||
top: reveal && t !== undefined ? cardView(s, t) : null,
|
||||
penalty: s.penalty[p] ?? 0,
|
||||
lockedUntil: s.lockedUntil[p] ?? 0,
|
||||
correct: s.stats[p]!.correct,
|
||||
wrong: s.stats[p]!.wrong,
|
||||
rtt: s.rtt[p] ?? null,
|
||||
place: place >= 0 ? place + 1 : null,
|
||||
};
|
||||
}),
|
||||
center: reveal && c !== undefined ? cardView(s, c) : null,
|
||||
centerCount: s.center.length,
|
||||
drawCount: s.drawPile.length,
|
||||
unusedCount: s.unused.length,
|
||||
pairs,
|
||||
windowOpen: s.pendingWindow !== null,
|
||||
hint,
|
||||
hintUntil: s.hintUntil,
|
||||
lastHit: s.lastHit,
|
||||
feedback: me ? (s.feedback[me] ?? null) : null,
|
||||
lastRound: s.lastRound,
|
||||
suspects: viewer !== null && viewer === s.host ? [...s.suspect] : [],
|
||||
log: s.log.slice(-12),
|
||||
result: s.final,
|
||||
};
|
||||
},
|
||||
|
||||
/** No turns: everyone still in the game may claim at any time (§8.5). */
|
||||
activePlayers(s) {
|
||||
if (s.phase === 'ended') return [];
|
||||
return s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
return dueAt(s);
|
||||
},
|
||||
|
||||
/**
|
||||
* Everyone still in drives the timer (countdown, latency window, stall hint), rotated after every away
|
||||
* strike so that several disconnected players are all noticed in turn.
|
||||
*/
|
||||
timeoutPlayers(s) {
|
||||
if (s.phase === 'ended') return [];
|
||||
const ps = s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
|
||||
if (ps.length === 0) return [];
|
||||
const k = s.awayRot % ps.length;
|
||||
return [...ps.slice(k), ...ps.slice(0, k)];
|
||||
},
|
||||
|
||||
/** Disconnected players are timed out at most once per AWAY_STEP_MS (no busy loop), never before the window closes. */
|
||||
timeoutNotBefore(s) {
|
||||
const due = dueAt(s);
|
||||
if (due === null) return null;
|
||||
if (s.pendingWindow) return due;
|
||||
return Math.min(due, s.awayCheckAt);
|
||||
},
|
||||
|
||||
onTimeout(s) {
|
||||
return s.pendingWindow ? { type: 'resolve' } : { type: 'tick' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.phase === 'ended' || !s.order.includes(player)) return null;
|
||||
return s.status[player] === 'playing' || s.status[player] === 'escaped' ? { type: 'leave' } : null;
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
const f = s.final;
|
||||
if (!f) return null;
|
||||
const groups = new Map<number, PlayerId[]>();
|
||||
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
|
||||
const scores: Record<PlayerId, number> = {};
|
||||
for (const r of f.ranking) scores[r.id] = r.points;
|
||||
return {
|
||||
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
|
||||
scores,
|
||||
summary: f.summary,
|
||||
reason: 'normal',
|
||||
};
|
||||
},
|
||||
|
||||
/** Test helper: the right claim on every pair, a wrong one now and then, and the timer tick. */
|
||||
legalActions(s, p) {
|
||||
if (s.phase === 'ended' || !s.order.includes(p)) return [];
|
||||
const out: A[] = [{ type: 'tick' }];
|
||||
if (s.pendingWindow) out.push({ type: 'resolve' });
|
||||
const pairs = pairsFor(s, p);
|
||||
for (const pair of pairs) {
|
||||
const art = answerArt(s, p, pair)!;
|
||||
out.push({ type: 'claim', base: pair.base, symbol: art, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) });
|
||||
}
|
||||
const st = s.stats[p]!;
|
||||
const first = pairs[0];
|
||||
if (first && (st.correct + st.wrong) % 4 === 3) {
|
||||
const cc = comparedCards(s, p, first.target ?? undefined)!;
|
||||
const right = common(s, cc[0], cc[1]);
|
||||
const wrong = s.cards[cc[0]]!.find((x) => x !== right)!;
|
||||
out.push({ type: 'claim', base: first.base, symbol: s.symbolArt[wrong]!, ...(first.target ? { target: first.target } : {}) });
|
||||
}
|
||||
return out.filter((x) => dobble.validate(s, p, x).ok);
|
||||
},
|
||||
|
||||
/** Cards nobody may see: draw pile, box, well face-down piles, and everything dealt during a countdown. */
|
||||
secretsOf(s) {
|
||||
const hidden = [...s.drawPile, ...s.unused];
|
||||
for (const p of s.order) {
|
||||
const pile = s.piles[p]!;
|
||||
if (s.options.miniGame === 'well') hidden.push(...pile.slice(0, -1));
|
||||
if (s.phase === 'countdown' && pile.length) hidden.push(pile[pile.length - 1]!);
|
||||
}
|
||||
if (s.phase === 'countdown' && s.center.length) hidden.push(s.center[s.center.length - 1]!);
|
||||
return hidden.map(cardKey);
|
||||
},
|
||||
};
|
||||
272
packages/games/src/dobble/types.ts
Normal file
272
packages/games/src/dobble/types.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/** 도블(같은 그림 찾기) options, actions, state and view types (docs/games/dobble.md §2, §8). */
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
/** §3.2: 57 original icons (drawn in apps/web/src/games/dobble/art.ts), index = icon id. */
|
||||
export const SYMBOL_NAMES = [
|
||||
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
|
||||
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
|
||||
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
|
||||
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
|
||||
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
|
||||
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
|
||||
] as const;
|
||||
|
||||
export type MiniGame = 'tower' | 'well' | 'hotPotato' | 'poisonedGift';
|
||||
export const MINI_GAME_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
|
||||
|
||||
const lit = <T extends string | number | null>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
export const dobbleOptionsSchema = z.object({
|
||||
miniGame: z
|
||||
.enum(['tower', 'well', 'hotPotato', 'poisonedGift'])
|
||||
.default('tower')
|
||||
.meta({ title: '미니게임', labels: { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' } }),
|
||||
deckSize: lit<57 | 55>(57, 55)
|
||||
.default(57)
|
||||
.meta({ title: '카드 장수', labels: { 57: '57장(수학적으로 꽉 찬 덱)', 55: '55장(원작과 같은 장수)' } }),
|
||||
hotPotatoRounds: lit<3 | 5 | 7>(3, 5, 7)
|
||||
.default(5)
|
||||
.meta({ title: '뜨거운 감자 라운드 수', labels: { 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
|
||||
wrongLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000)
|
||||
.default(1000)
|
||||
.meta({ title: '틀린 그림을 눌렀을 때 잠금 시간', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }),
|
||||
latencyComp: z
|
||||
.enum(['off', 'light'])
|
||||
.default('off')
|
||||
.meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }),
|
||||
symbolStyle: z
|
||||
.enum(['easy', 'classic'])
|
||||
.default('classic')
|
||||
.meta({ title: '그림 크기·회전', labels: { easy: '쉽게(크기 같고 회전 없음)', classic: '원작처럼(크기·회전 무작위)' } }),
|
||||
showRtt: z.boolean().default(true).meta({ title: '각자 핑(연결 지연) 표시' }),
|
||||
wellEndAtFirst: z.boolean().default(false).meta({ title: '우물: 처음 다 비운 사람이 나오면 바로 끝내기' }),
|
||||
wrongGivesCard: z.boolean().default(false).meta({ title: '탑 쌓기: 틀리면 내 탑 맨 위 카드를 가운데 더미 맨 아래로' }),
|
||||
});
|
||||
export type DobbleOptions = z.infer<typeof dobbleOptionsSchema>;
|
||||
|
||||
export const dobbleActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('claim'),
|
||||
/** Reference card id the client was looking at: centre top (tower/well/gift) or the target's top (hot potato). */
|
||||
base: z.number().int().min(0).max(56),
|
||||
/** Gift / hot potato: whose card the symbol is matched on. */
|
||||
target: z.string().min(1).max(64).optional(),
|
||||
/** My own top card id the client saw (hot potato: someone may have just dumped their bundle on me). */
|
||||
mine: z.number().int().min(0).max(56).optional(),
|
||||
/** Icon id that was tapped. */
|
||||
symbol: z.number().int().min(0).max(56),
|
||||
/** Client-measured round trip (ms); feeds the per-player RTT average (§5.3), clamped. */
|
||||
rtt: z.number().min(0).max(10_000).optional(),
|
||||
}),
|
||||
]);
|
||||
/** Client claims plus system-only actions (server timer, latency window, leaving) that are not in the schema. */
|
||||
export type DobbleAction = z.infer<typeof dobbleActionSchema> | { type: 'tick' } | { type: 'resolve' } | { type: 'leave' };
|
||||
export type ClaimAction = Extract<DobbleAction, { type: 'claim' }>;
|
||||
|
||||
export interface LayoutItem {
|
||||
/** Symbol number 0..56 (projective-plane point); the screen shows `symbolArt[symbol]`. */
|
||||
symbol: number;
|
||||
/** Slot centre in the unit circle (−1..1) and slot radius. */
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
/** §3.3 classic: 0.6–1.2, easy: 1. */
|
||||
scale: number;
|
||||
/** Degrees, classic: 0–359, easy: 0. */
|
||||
rot: number;
|
||||
}
|
||||
|
||||
export interface CardLayout {
|
||||
template: number;
|
||||
items: LayoutItem[];
|
||||
}
|
||||
|
||||
export type SeatStatus = 'playing' | 'finished' | 'escaped' | 'forfeit' | 'left';
|
||||
export type Phase = 'countdown' | 'playing' | 'roundEnd' | 'ended';
|
||||
|
||||
export interface PendingClaim {
|
||||
player: PlayerId;
|
||||
target: PlayerId | 'center';
|
||||
base: number;
|
||||
mine: number | null;
|
||||
symbol: number;
|
||||
adjTs: number;
|
||||
arrived: number;
|
||||
}
|
||||
|
||||
export interface PendingWindow {
|
||||
openedAt: number;
|
||||
claims: PendingClaim[];
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
correct: number;
|
||||
wrong: number;
|
||||
/** Correct claims made < 200ms after the cards changed, in a row (§5.3 봇 의심). */
|
||||
fastHits: number;
|
||||
}
|
||||
|
||||
/** Per-player judgement of their latest claim (shown only to that player). */
|
||||
export interface Feedback {
|
||||
seq: number;
|
||||
kind: 'ok' | 'wrong' | 'late' | 'locked';
|
||||
/** Icon id. */
|
||||
art: number | null;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface LastHit {
|
||||
seq: number;
|
||||
player: PlayerId;
|
||||
target: PlayerId | 'center';
|
||||
art: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type LogEntry =
|
||||
| { n: number; t: 'hit'; p: PlayerId; target: PlayerId | 'center'; art: number }
|
||||
| { n: number; t: 'done'; p: PlayerId; place: number }
|
||||
| { n: number; t: 'round'; round: number; loser: PlayerId | null; cards: number }
|
||||
| { n: number; t: 'out'; p: PlayerId; why: 'forfeit' | 'left' }
|
||||
| { n: number; t: 'hint' };
|
||||
|
||||
export interface RankRow {
|
||||
rank: number;
|
||||
id: PlayerId;
|
||||
cards?: number;
|
||||
penalty?: number;
|
||||
points: number;
|
||||
status: SeatStatus;
|
||||
}
|
||||
|
||||
export interface DobbleFinal {
|
||||
miniGame: MiniGame;
|
||||
ranking: RankRow[];
|
||||
stats: Record<PlayerId, { correct: number; wrong: number }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface DobbleState {
|
||||
options: DobbleOptions;
|
||||
/** Room host at game start (players[0]); only they see the bot-suspicion marks (§5.3). */
|
||||
host: PlayerId;
|
||||
/** Card id → symbol numbers (8 each). Immutable after setup. */
|
||||
cards: number[][];
|
||||
/** Card id → layout. Immutable after setup. */
|
||||
layouts: CardLayout[];
|
||||
/** Symbol number → icon id (shuffled per game). Immutable after setup. */
|
||||
symbolArt: number[];
|
||||
order: PlayerId[];
|
||||
status: Record<PlayerId, SeatStatus>;
|
||||
/** Player piles, top = last. Well: the whole face-down pile, only the top is face up. */
|
||||
piles: Record<PlayerId, number[]>;
|
||||
/** Public centre pile, top = last. */
|
||||
center: number[];
|
||||
/** Hidden draw pile, next card = last. */
|
||||
drawPile: number[];
|
||||
/** Well leftovers, 55-deck removals, forfeited / left players' returned cards. */
|
||||
unused: number[];
|
||||
/** Hot potato: cards each round's loser keeps as penalty. */
|
||||
taken: Record<PlayerId, number[]>;
|
||||
lockedUntil: Record<PlayerId, number>;
|
||||
pendingWindow: PendingWindow | null;
|
||||
/** Hot potato round (1-based); other modes stay 1. */
|
||||
round: number;
|
||||
penalty: Record<PlayerId, number>;
|
||||
outThisRound: PlayerId[];
|
||||
finishedOrder: PlayerId[];
|
||||
phase: Phase;
|
||||
phaseEndsAt: number;
|
||||
/** Last time the public cards changed (stall timer, bot suspicion). */
|
||||
changedAt: number;
|
||||
/** §8.5 stall: the common symbol is shown until this time. */
|
||||
hintUntil: number;
|
||||
/** Throttles away detection (one early tick per away step). */
|
||||
awayCheckAt: number;
|
||||
/** Rotates `timeoutPlayers` after each away strike so several away players are all noticed. */
|
||||
awayRot: number;
|
||||
/** Consecutive early ticks per player (= the room timed them out while disconnected) and the last one. */
|
||||
away: Record<PlayerId, { strikes: number; last: number }>;
|
||||
rtt: Record<PlayerId, number | null>;
|
||||
stats: Record<PlayerId, Stats>;
|
||||
suspect: PlayerId[];
|
||||
feedback: Record<PlayerId, Feedback>;
|
||||
lastHit: LastHit | null;
|
||||
/** Hot potato: the latest finished round. */
|
||||
lastRound: { round: number; loser: PlayerId | null; cards: number } | null;
|
||||
seq: number;
|
||||
logNo: number;
|
||||
log: LogEntry[];
|
||||
final: DobbleFinal | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
export interface ItemView {
|
||||
/** Icon id. */
|
||||
art: number;
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
scale: number;
|
||||
rot: number;
|
||||
}
|
||||
|
||||
export interface CardView {
|
||||
id: number;
|
||||
/** Opaque public key (`secretsOf` uses the same form for hidden cards). */
|
||||
key: string;
|
||||
items: ItemView[];
|
||||
}
|
||||
|
||||
export interface SeatView {
|
||||
id: PlayerId;
|
||||
status: SeatStatus;
|
||||
/** Cards in the pile (tower: tower height, well: cards left, gift: poison, hot potato: bundle). */
|
||||
cards: number;
|
||||
top: CardView | null;
|
||||
penalty: number;
|
||||
lockedUntil: number;
|
||||
correct: number;
|
||||
wrong: number;
|
||||
rtt: number | null;
|
||||
/** Well finishing place (1-based). */
|
||||
place: number | null;
|
||||
}
|
||||
|
||||
/** One pair of cards the viewer may claim on (rules precomputed for the screen). */
|
||||
export interface ClaimPair {
|
||||
target: PlayerId | null;
|
||||
base: number;
|
||||
mine: number | null;
|
||||
}
|
||||
|
||||
export interface DobbleView {
|
||||
options: DobbleOptions;
|
||||
me: PlayerId | null;
|
||||
isHost: boolean;
|
||||
phase: Phase;
|
||||
phaseEndsAt: number;
|
||||
round: number;
|
||||
rounds: number;
|
||||
seats: SeatView[];
|
||||
center: CardView | null;
|
||||
centerCount: number;
|
||||
drawCount: number;
|
||||
unusedCount: number;
|
||||
/** Pairs the viewer can claim on right now (empty while they cannot claim). */
|
||||
pairs: ClaimPair[];
|
||||
windowOpen: boolean;
|
||||
/** §8.5 stall hint: icon ids common to the viewer's pairs, shown until `hintUntil`. */
|
||||
hint: number[];
|
||||
hintUntil: number;
|
||||
lastHit: LastHit | null;
|
||||
feedback: Feedback | null;
|
||||
lastRound: DobbleState['lastRound'];
|
||||
/** Host only (§5.3). */
|
||||
suspects: PlayerId[];
|
||||
log: LogEntry[];
|
||||
result: DobbleFinal | null;
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import { quoridor } from './quoridor';
|
||||
import { lasVegas } from './las-vegas';
|
||||
import { cockroachPoker } from './cockroach-poker';
|
||||
import { ninetyNine } from './ninety-nine';
|
||||
import { dobble } from './dobble';
|
||||
import { catchmind } from './catchmind';
|
||||
import { mafia } from './mafia';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -47,4 +50,7 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[lasVegas.id]: lasVegas,
|
||||
[cockroachPoker.id]: cockroachPoker,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
[dobble.id]: dobble,
|
||||
[catchmind.id]: catchmind,
|
||||
[mafia.id]: mafia,
|
||||
};
|
||||
|
||||
784
packages/games/src/mafia/index.ts
Normal file
784
packages/games/src/mafia/index.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
/**
|
||||
* 마피아 (docs/games/mafia.md). Pure and deterministic; state is JSON.
|
||||
*
|
||||
* The server runs the phases: every phase has `endsAt`; the room timer applies the system-only
|
||||
* `phaseTimeout` there (one "driver" player, `timeoutNotBefore` keeps away players from firing it
|
||||
* early). Chat lives in the state and the view filters it by channel permission (§5.6, §8.3).
|
||||
*/
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||
import {
|
||||
CHANNELS,
|
||||
EXTRA_ROLES,
|
||||
mafiaActionSchema,
|
||||
mafiaOptionsSchema,
|
||||
type Channel,
|
||||
type ChatView,
|
||||
type DiedBy,
|
||||
type ExtraRole,
|
||||
type MafiaAction,
|
||||
type MafiaFinal,
|
||||
type MafiaOptions,
|
||||
type MafiaState,
|
||||
type MafiaView,
|
||||
type MyView,
|
||||
type NightActionType,
|
||||
type NightView,
|
||||
type Phase,
|
||||
type PlayerState,
|
||||
type PrivateResult,
|
||||
type PublicEvent,
|
||||
type Role,
|
||||
type Team,
|
||||
type WinCause,
|
||||
} from './types';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export const ROLE_REVEAL_MS = 8_000;
|
||||
export const GREETING_MS = 60_000;
|
||||
export const ADJUST_MS = 15_000;
|
||||
/** `-15초` never leaves less than this (§5.2). */
|
||||
export const ADJUST_FLOOR_MS = 5_000;
|
||||
export const CHAT_MAX = 200;
|
||||
export const CHAT_GAP_MS = 700;
|
||||
/** Voting days in a row without any action → 자리 비움 탈락 (§8.5). */
|
||||
export const AWAY_DAYS = 2;
|
||||
const CHAT_KEEP = 400;
|
||||
const CHAT_VIEW = 150;
|
||||
const EVENTS_VIEW = 120;
|
||||
|
||||
export const ROLE_KO: Record<Role, string> = {
|
||||
mafia: '마피아',
|
||||
police: '경찰',
|
||||
doctor: '의사',
|
||||
citizen: '시민',
|
||||
soldier: '군인',
|
||||
politician: '정치인',
|
||||
medium: '영매',
|
||||
lover: '연인',
|
||||
};
|
||||
export const teamOf = (r: Role): Team => (r === 'mafia' ? 'mafia' : 'town');
|
||||
|
||||
/** §4 table: mafia count for `auto`. */
|
||||
const AUTO_MAFIA: Record<number, number> = { 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 3, 10: 3, 11: 3, 12: 3 };
|
||||
const EXTRA_SLOTS: Record<ExtraRole, number> = { soldier: 1, politician: 1, medium: 1, lovers: 2 };
|
||||
const EXTRA_OBJ: Record<ExtraRole, string> = { soldier: '군인을', politician: '정치인을', medium: '영매를', lovers: '연인을' };
|
||||
|
||||
export function mafiaCountFor(o: MafiaOptions, n: number): number {
|
||||
return o.mafiaCount === 'auto' ? (AUTO_MAFIA[n] ?? Math.max(1, Math.floor(n / 4))) : o.mafiaCount;
|
||||
}
|
||||
|
||||
/** Why this composition cannot start (lobby refusal, §4.2), or null. */
|
||||
export function compositionError(o: MafiaOptions, n: number): string | null {
|
||||
if (n < 4 || n > 12) return '마피아는 4~12명이 할 수 있어요.';
|
||||
const m = mafiaCountFor(o, n);
|
||||
if (m >= n / 2) return `${n}명일 때 마피아는 ${Math.ceil(n / 2) - 1}명까지 정할 수 있어요.`;
|
||||
let citizens = n - m - 2;
|
||||
for (const r of EXTRA_ROLES) {
|
||||
if (!o.extraRoles[r]) continue;
|
||||
if (citizens < EXTRA_SLOTS[r]) return `인원이 부족해서 ${EXTRA_OBJ[r]} 넣을 수 없어요(시민 자리 ${EXTRA_SLOTS[r]}개 필요).`;
|
||||
citizens -= EXTRA_SLOTS[r];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Roles for `n` players before shuffling (§4). */
|
||||
export function rolesFor(o: MafiaOptions, n: number): Role[] {
|
||||
const err = compositionError(o, n);
|
||||
if (err) throw new Error(`mafia: ${err}`);
|
||||
const m = mafiaCountFor(o, n);
|
||||
const out: Role[] = [...Array<Role>(m).fill('mafia'), 'police', 'doctor'];
|
||||
if (o.extraRoles.soldier) out.push('soldier');
|
||||
if (o.extraRoles.politician) out.push('politician');
|
||||
if (o.extraRoles.medium) out.push('medium');
|
||||
if (o.extraRoles.lovers) out.push('lover', 'lover');
|
||||
while (out.length < n) out.push('citizen');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chat filter (kidSafeChat)
|
||||
|
||||
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', 'fuck', 'shit', 'bitch'];
|
||||
const LINK = /(https?:\/\/|www\.|\b[a-z0-9-]+\.(com|net|org|kr|io|gg|me|ly|co|xyz|app|dev|site|link)\b)/i;
|
||||
const PHONE = /(01[016789]|0\d{1,2})[-\s.]?\d{3,4}[-\s.]?\d{4}/;
|
||||
|
||||
export function cleanChat(text: string): string {
|
||||
return text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Korean reason a message is refused, or null. */
|
||||
export function chatProblem(text: string, kidSafe: boolean): string | null {
|
||||
const len = [...text].length;
|
||||
if (len === 0) return '메시지를 입력해 주세요.';
|
||||
if (len > CHAT_MAX) return `메시지는 ${CHAT_MAX}자까지 보낼 수 있어요.`;
|
||||
if (!kidSafe) return null;
|
||||
const squashed = text.normalize('NFC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
|
||||
if (BANNED.some((w) => squashed.includes(w))) return '고운 말을 써 주세요.';
|
||||
if (LINK.test(text)) return '링크는 보낼 수 없어요.';
|
||||
if (PHONE.test(text)) return '전화번호 같은 개인 정보는 보낼 수 없어요.';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const P = (s: MafiaState, id: PlayerId): PlayerState | undefined => s.players.find((p) => p.id === id);
|
||||
const alive = (s: MafiaState) => s.players.filter((p) => p.alive);
|
||||
const isAlive = (s: MafiaState, id: PlayerId | null) => !!id && !!P(s, id)?.alive;
|
||||
type NightPhase = Extract<Phase, { kind: 'night' }>;
|
||||
|
||||
function discussionMs(s: MafiaState): number {
|
||||
const o = s.options;
|
||||
const lo = o.discussionMin;
|
||||
const hi = Math.max(o.discussionMin, o.discussionMax);
|
||||
return Math.min(hi, Math.max(lo, alive(s).length * o.discussionPerAlive)) * 1000;
|
||||
}
|
||||
|
||||
function pushEvent(s: MafiaState, now: number, e: DistributiveOmit<PublicEvent, 'n' | 'at' | 'day'>, day = s.day): void {
|
||||
s.events.push({ ...e, n: s.events.length + 1, at: now, day } as PublicEvent);
|
||||
}
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
||||
|
||||
function startPhase(s: MafiaState, now: number, kind: 'greeting' | 'discussion' | 'vote' | 'night'): void {
|
||||
switch (kind) {
|
||||
case 'greeting':
|
||||
s.phase = { kind, startedAt: now, endsAt: now + GREETING_MS, ready: [] };
|
||||
break;
|
||||
case 'discussion':
|
||||
s.phase = { kind, startedAt: now, endsAt: now + discussionMs(s), adjustedBy: [], ready: [] };
|
||||
break;
|
||||
case 'vote':
|
||||
s.phase = { kind, startedAt: now, endsAt: now + s.options.voteSeconds * 1000, votes: {} };
|
||||
break;
|
||||
case 'night':
|
||||
s.phase = { kind, startedAt: now, endsAt: now + s.options.nightSeconds * 1000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null };
|
||||
break;
|
||||
}
|
||||
pushEvent(s, now, { t: 'phase', kind });
|
||||
}
|
||||
|
||||
function eliminate(s: MafiaState, now: number, p: PlayerState, by: DiedBy): void {
|
||||
p.alive = false;
|
||||
p.diedDay = s.day;
|
||||
p.diedBy = by;
|
||||
const reveal = s.options.deathReveal;
|
||||
pushEvent(s, now, { t: 'death', p: p.id, by, team: reveal === 'none' ? null : teamOf(p.role), role: reveal === 'role' ? p.role : null });
|
||||
}
|
||||
|
||||
function summaryOf(winner: Team, cause: WinCause, day: number): string {
|
||||
if (winner === 'town') {
|
||||
if (cause === 'vote') return `시민 팀 승리! ${day}일차 낮 투표로 마지막 마피아를 찾아냈어요.`;
|
||||
if (cause === 'disconnect') return `시민 팀 승리! ${day}일차에 마지막 마피아가 자리를 비워 탈락했어요.`;
|
||||
return '시민 팀 승리! 마피아가 모두 탈락했어요.';
|
||||
}
|
||||
if (cause === 'vote') return `마피아 팀 승리! ${day}일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.`;
|
||||
if (cause === 'night') return `마피아 팀 승리! ${day}일차 아침, 마피아 수가 시민 수와 같아졌어요.`;
|
||||
return `마피아 팀 승리! ${day}일차에 자리 비움 탈락으로 마피아 수가 시민 수와 같아졌어요.`;
|
||||
}
|
||||
|
||||
/** §6: checked after an execution, after the morning news and after an away/leave elimination. */
|
||||
function checkWin(s: MafiaState, now: number, cause: WinCause): boolean {
|
||||
const living = alive(s);
|
||||
const m = living.filter((p) => p.role === 'mafia').length;
|
||||
const winner: Team | null = m === 0 ? 'town' : m >= living.length - m ? 'mafia' : null;
|
||||
if (!winner) return false;
|
||||
s.winner = winner;
|
||||
s.phase = { kind: 'ended' };
|
||||
pushEvent(s, now, { t: 'end', winner });
|
||||
const players = s.players.map((p) => ({
|
||||
id: p.id,
|
||||
role: p.role,
|
||||
team: teamOf(p.role),
|
||||
alive: p.alive,
|
||||
diedDay: p.diedDay,
|
||||
diedBy: p.diedBy,
|
||||
won: teamOf(p.role) === winner,
|
||||
}));
|
||||
const order = (won: boolean) => [...players.filter((p) => p.won === won && p.alive), ...players.filter((p) => p.won === won && !p.alive)].map((p) => p.id);
|
||||
let mvp: PlayerId | null = null;
|
||||
for (const p of s.players) if (teamOf(p.role) === 'town' && (s.mvpVotes[p.id] ?? 0) > (mvp ? s.mvpVotes[mvp]! : 0)) mvp = p.id;
|
||||
s.final = { winner, days: s.day, cause, players, ranking: [...order(true), ...order(false)], mvp, summary: summaryOf(winner, cause, s.day) };
|
||||
return true;
|
||||
}
|
||||
|
||||
/** End of a voting day: away detection (§8.5), then win check. Returns true when the game ended. */
|
||||
function endDay(s: MafiaState, now: number): boolean {
|
||||
const away: PlayerState[] = [];
|
||||
for (const p of alive(s)) {
|
||||
p.idleDays = p.activeToday ? 0 : p.idleDays + 1;
|
||||
p.activeToday = false;
|
||||
if (p.idleDays >= AWAY_DAYS) away.push(p);
|
||||
}
|
||||
for (const p of away) eliminate(s, now, p, 'disconnect');
|
||||
return away.length > 0 && checkWin(s, now, 'disconnect');
|
||||
}
|
||||
|
||||
function resolveVote(s: MafiaState, now: number): PlayerId | null {
|
||||
const ph = s.phase as Extract<Phase, { kind: 'vote' }>;
|
||||
const tally: Record<PlayerId, number> = {};
|
||||
const votes: Record<PlayerId, PlayerId | 'abstain'> = {};
|
||||
let abstain = 0;
|
||||
for (const p of alive(s)) {
|
||||
const w = p.role === 'politician' ? 2 : 1;
|
||||
const v = ph.votes[p.id];
|
||||
if (!v || v === 'abstain' || !isAlive(s, v)) {
|
||||
abstain += w;
|
||||
votes[p.id] = 'abstain';
|
||||
} else {
|
||||
tally[v] = (tally[v] ?? 0) + w;
|
||||
votes[p.id] = v;
|
||||
}
|
||||
}
|
||||
const max = Math.max(0, ...Object.values(tally));
|
||||
const tops = Object.keys(tally).filter((k) => tally[k] === max);
|
||||
const accused = max >= 1 && tops.length === 1 ? tops[0]! : null;
|
||||
s.trialVoters = accused ? Object.keys(votes).filter((k) => votes[k] === accused) : [];
|
||||
pushEvent(s, now, { t: 'vote', tally, abstain, votes: s.options.openVote ? votes : null, accused, tie: tops.length > 1 });
|
||||
return accused;
|
||||
}
|
||||
|
||||
/** Returns true when the game ended. */
|
||||
function resolveConfirm(s: MafiaState, now: number): boolean {
|
||||
const ph = s.phase as Extract<Phase, { kind: 'confirm' }>;
|
||||
let yes = 0;
|
||||
let no = 0;
|
||||
for (const p of alive(s)) {
|
||||
const b = ph.ballots[p.id];
|
||||
if (!b || p.id === ph.accused) continue;
|
||||
const w = p.role === 'politician' ? 2 : 1;
|
||||
if (b === 'yes') yes += w;
|
||||
else no += w;
|
||||
}
|
||||
const acc = P(s, ph.accused)!;
|
||||
if (yes <= no || !acc.alive) {
|
||||
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'spared' });
|
||||
return false;
|
||||
}
|
||||
if (acc.role === 'politician') {
|
||||
acc.publicRole = true;
|
||||
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'politician' });
|
||||
return false;
|
||||
}
|
||||
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'executed' });
|
||||
eliminate(s, now, acc, 'vote');
|
||||
if (acc.role === 'mafia') {
|
||||
for (const v of s.trialVoters) if (teamOf(P(s, v)!.role) === 'town') s.mvpVotes[v] = (s.mvpVotes[v] ?? 0) + 1;
|
||||
}
|
||||
return checkWin(s, now, 'vote');
|
||||
}
|
||||
|
||||
/** Night resolution in the §5.5 order. Returns true when the game ended. */
|
||||
function resolveNight(s: MafiaState, now: number, rng: Rng): boolean {
|
||||
const ph = s.phase as NightPhase;
|
||||
let target = isAlive(s, ph.mafiaTarget) ? ph.mafiaTarget : null;
|
||||
const mafiaAlive = alive(s).some((p) => p.role === 'mafia');
|
||||
if (!target && s.options.mafiaMustKill === 'random' && mafiaAlive) {
|
||||
const pool = alive(s).filter((p) => p.role !== 'mafia');
|
||||
if (pool.length) target = pool[rng.int(pool.length)]!.id;
|
||||
}
|
||||
if (!target) pushEvent(s, now, { t: 'quiet' });
|
||||
else if (target === ph.heal) pushEvent(s, now, { t: 'healed' });
|
||||
else {
|
||||
const t = P(s, target)!;
|
||||
if (t.role === 'soldier' && !t.soldierUsed) {
|
||||
t.soldierUsed = true;
|
||||
t.publicRole = true;
|
||||
pushEvent(s, now, { t: 'soldier', p: t.id });
|
||||
} else eliminate(s, now, t, 'mafia');
|
||||
}
|
||||
s.lastHealTarget = ph.heal;
|
||||
// Private results, delivered at the end of the night (§5.5.5) even if the police/medium died tonight.
|
||||
const police = s.players.find((p) => p.role === 'police');
|
||||
if (police && ph.investigate) {
|
||||
const r = P(s, ph.investigate)!.role;
|
||||
addResult(s, police.id, 'investigate', ph.investigate, s.options.policeRevealRole ? r : r === 'mafia' ? 'mafia' : 'notMafia');
|
||||
}
|
||||
const medium = s.players.find((p) => p.role === 'medium');
|
||||
if (medium && ph.mediumPeek) addResult(s, medium.id, 'peek', ph.mediumPeek, P(s, ph.mediumPeek)!.role);
|
||||
s.day += 1;
|
||||
return checkWin(s, now, 'night');
|
||||
}
|
||||
|
||||
function addResult(s: MafiaState, to: PlayerId, kind: PrivateResult['kind'], target: PlayerId, result: PrivateResult['result']): void {
|
||||
s.privateResults.push({ id: `res-${s.day}-${kind}-${s.privateResults.length + 1}`, to, day: s.day, kind, target, result });
|
||||
}
|
||||
|
||||
/** The phase timer ran out (or everyone pressed 토론 끝내기). */
|
||||
function advance(s: MafiaState, now: number, rng: Rng): void {
|
||||
const ph = s.phase;
|
||||
switch (ph.kind) {
|
||||
case 'roleReveal':
|
||||
startPhase(s, now, s.options.firstPhase === 'day' ? 'greeting' : 'night');
|
||||
return;
|
||||
case 'greeting':
|
||||
startPhase(s, now, 'night');
|
||||
return;
|
||||
case 'discussion':
|
||||
startPhase(s, now, 'vote');
|
||||
return;
|
||||
case 'vote': {
|
||||
const accused = resolveVote(s, now);
|
||||
if (accused) {
|
||||
s.phase = { kind: 'defense', startedAt: now, endsAt: now + s.options.defenseSeconds * 1000, accused };
|
||||
pushEvent(s, now, { t: 'phase', kind: 'defense' });
|
||||
return;
|
||||
}
|
||||
if (!endDay(s, now)) startPhase(s, now, 'night');
|
||||
return;
|
||||
}
|
||||
case 'defense':
|
||||
s.phase = { kind: 'confirm', startedAt: now, endsAt: now + s.options.confirmSeconds * 1000, accused: ph.accused, ballots: {} };
|
||||
pushEvent(s, now, { t: 'phase', kind: 'confirm' });
|
||||
return;
|
||||
case 'confirm':
|
||||
if (resolveConfirm(s, now)) return;
|
||||
if (!endDay(s, now)) startPhase(s, now, 'night');
|
||||
return;
|
||||
case 'night':
|
||||
if (!resolveNight(s, now, rng)) startPhase(s, now, 'discussion');
|
||||
return;
|
||||
case 'ended':
|
||||
throw new Error('mafia: advance after the end');
|
||||
}
|
||||
}
|
||||
|
||||
/** A player leaves mid-game: eliminated at once (like 자리 비움), then the phase is repaired. */
|
||||
function leave(s: MafiaState, now: number, rng: Rng, p: PlayerState): void {
|
||||
eliminate(s, now, p, 'disconnect');
|
||||
if (checkWin(s, now, 'disconnect')) return;
|
||||
const ph = s.phase;
|
||||
switch (ph.kind) {
|
||||
case 'greeting':
|
||||
case 'discussion':
|
||||
ph.ready = ph.ready.filter((x) => x !== p.id);
|
||||
if (ph.ready.length > 0 && alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
|
||||
return;
|
||||
case 'vote':
|
||||
delete ph.votes[p.id];
|
||||
return;
|
||||
case 'defense':
|
||||
case 'confirm':
|
||||
if (ph.kind === 'confirm') delete ph.ballots[p.id];
|
||||
if (ph.accused === p.id && !endDay(s, now)) startPhase(s, now, 'night');
|
||||
return;
|
||||
case 'night':
|
||||
if (ph.mafiaTarget === p.id) {
|
||||
ph.mafiaTarget = null;
|
||||
ph.mafiaPickedBy = null;
|
||||
}
|
||||
if (ph.investigate === p.id) ph.investigate = null;
|
||||
if (ph.heal === p.id) ph.heal = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- permissions
|
||||
|
||||
function nightActionOf(r: Role): NightActionType | null {
|
||||
return r === 'mafia' ? 'mafiaTarget' : r === 'police' ? 'investigate' : r === 'doctor' ? 'heal' : r === 'medium' ? 'mediumPeek' : null;
|
||||
}
|
||||
|
||||
/** Legal targets for a night action. */
|
||||
function nightTargets(s: MafiaState, me: PlayerState, action: NightActionType): PlayerId[] {
|
||||
switch (action) {
|
||||
case 'mafiaTarget':
|
||||
return alive(s).filter((p) => p.role !== 'mafia').map((p) => p.id);
|
||||
case 'investigate':
|
||||
return alive(s).filter((p) => p.id !== me.id).map((p) => p.id);
|
||||
case 'heal':
|
||||
return alive(s)
|
||||
.filter((p) => (p.id !== me.id || s.options.doctorSelfHeal) && (s.options.doctorRepeat || p.id !== s.lastHealTarget))
|
||||
.map((p) => p.id);
|
||||
case 'mediumPeek':
|
||||
return s.players.filter((p) => !p.alive && p.id !== me.id).map((p) => p.id);
|
||||
}
|
||||
}
|
||||
|
||||
const NIGHT_PROMPT: Record<NightActionType, string> = {
|
||||
mafiaTarget: '오늘 밤 할 일: 마피아가 노릴 사람을 골라 주세요',
|
||||
investigate: '오늘 밤 할 일: 조사할 사람을 골라 주세요',
|
||||
heal: '오늘 밤 할 일: 지켜 줄 사람을 골라 주세요',
|
||||
mediumPeek: '오늘 밤 할 일: 직업을 알아볼 탈락자를 골라 주세요',
|
||||
};
|
||||
|
||||
function canRead(s: MafiaState, viewer: PlayerState | null, ch: Channel): boolean {
|
||||
if (ch === 'day') return true;
|
||||
if (s.winner && s.options.revealChatsAtEnd) return true;
|
||||
if (!viewer) return false;
|
||||
switch (ch) {
|
||||
case 'mafia':
|
||||
return viewer.role === 'mafia' || (!viewer.alive && s.options.deadSeeAll);
|
||||
case 'dead':
|
||||
return !viewer.alive || (viewer.role === 'medium' && s.phase.kind === 'night');
|
||||
case 'lovers':
|
||||
return viewer.role === 'lover';
|
||||
}
|
||||
}
|
||||
|
||||
/** Why `p` cannot write in `ch` now (Korean), or null (§5.6). */
|
||||
function writeBlock(s: MafiaState, p: PlayerState, ch: Channel): string | null {
|
||||
const k = s.phase.kind;
|
||||
if (k === 'ended') return '게임이 끝났어요.';
|
||||
switch (ch) {
|
||||
case 'day':
|
||||
if (!p.alive) return '탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.';
|
||||
if (k === 'roleReveal') return '직업을 확인하는 중이에요. 잠시 기다려 주세요.';
|
||||
if (k === 'night') return '밤에는 낮 채팅을 쓸 수 없어요.';
|
||||
if (k === 'defense' && s.phase.accused !== p.id) return '최후의 반론 중에는 지목된 사람만 말할 수 있어요.';
|
||||
return null;
|
||||
case 'mafia':
|
||||
if (p.role !== 'mafia') return '마피아만 쓸 수 있어요.';
|
||||
if (!p.alive) return '탈락한 마피아는 읽기만 할 수 있어요.';
|
||||
return k === 'night' ? null : '마피아 채팅은 밤에만 쓸 수 있어요.';
|
||||
case 'dead':
|
||||
if (!p.alive) return null;
|
||||
if (p.role !== 'medium') return '탈락한 사람만 쓸 수 있어요.';
|
||||
return k === 'night' ? null : '영매는 밤에만 죽은 사람과 이야기할 수 있어요.';
|
||||
case 'lovers':
|
||||
if (p.role !== 'lover') return '연인만 쓸 수 있어요.';
|
||||
if (!p.alive) return '탈락하면 연인 채팅은 읽기만 할 수 있어요.';
|
||||
return k === 'night' ? null : '연인 채팅은 밤에만 쓸 수 있어요.';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- validate
|
||||
|
||||
function validate(s: MafiaState, actor: PlayerId, a: MafiaAction, ctx?: { now: number }): Validation {
|
||||
const p = P(s, actor);
|
||||
if (!p) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.winner) return fail('게임이 이미 끝났어요.');
|
||||
const ph = s.phase;
|
||||
if (ph.kind === 'ended') return fail('게임이 이미 끝났어요.');
|
||||
switch (a.type) {
|
||||
case 'phaseTimeout':
|
||||
return ctx && ctx.now < ph.endsAt ? fail('아직 시간이 남았어요.') : ok;
|
||||
case 'leave':
|
||||
return p.alive ? ok : fail('이미 탈락했어요.');
|
||||
case 'chat': {
|
||||
if (!(CHANNELS as readonly string[]).includes(a.channel)) return fail('채팅방을 골라 주세요.');
|
||||
const block = writeBlock(s, p, a.channel);
|
||||
if (block) return fail(block);
|
||||
const why = chatProblem(cleanChat(a.text), s.options.kidSafeChat);
|
||||
if (why) return fail(why);
|
||||
if (ctx && ctx.now - (s.lastChatAt[actor] ?? -Infinity) < CHAT_GAP_MS) return fail('조금 천천히 보내 주세요.');
|
||||
return ok;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (!p.alive) return fail('탈락한 사람은 할 수 없어요.');
|
||||
switch (a.type) {
|
||||
case 'adjustTime':
|
||||
if (ph.kind !== 'discussion') return fail('토론 시간에만 시간을 바꿀 수 있어요.');
|
||||
if (!s.options.timeAdjust) return fail('이 방은 토론 시간을 바꿀 수 없어요.');
|
||||
if (ph.adjustedBy.includes(actor)) return fail('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
|
||||
return a.delta === 15 || a.delta === -15 ? ok : fail('15초씩만 바꿀 수 있어요.');
|
||||
case 'readyEarly':
|
||||
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') return fail('지금은 토론 시간이 아니에요.');
|
||||
return ph.ready.includes(actor) ? fail('이미 토론 끝내기를 눌렀어요.') : ok;
|
||||
case 'vote':
|
||||
if (ph.kind !== 'vote') return fail('지금은 지목 투표 시간이 아니에요.');
|
||||
if (a.target === 'abstain') return ok;
|
||||
if (a.target === actor) return fail('나 자신은 지목할 수 없어요.');
|
||||
return isAlive(s, a.target) ? ok : fail('살아 있는 사람만 지목할 수 있어요.');
|
||||
case 'confirm':
|
||||
if (ph.kind !== 'confirm') return fail('지금은 찬반 투표 시간이 아니에요.');
|
||||
if (ph.accused === actor) return fail('지목된 사람은 찬반 투표를 할 수 없어요.');
|
||||
return a.choice === 'yes' || a.choice === 'no' ? ok : fail('찬성이나 반대를 골라 주세요.');
|
||||
case 'mafiaTarget':
|
||||
case 'investigate':
|
||||
case 'heal':
|
||||
case 'mediumPeek': {
|
||||
if (ph.kind !== 'night') return fail('밤에만 할 수 있어요.');
|
||||
if (nightActionOf(p.role) !== a.type) return fail('내 직업으로는 할 수 없는 행동이에요.');
|
||||
if (a.type === 'mafiaTarget' && a.target === null) return ok;
|
||||
const t = a.target === null ? undefined : P(s, a.target);
|
||||
if (!t) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (nightTargets(s, p, a.type).includes(t.id)) return ok;
|
||||
if (a.type === 'mediumPeek') return fail(t.alive ? '탈락한 사람만 고를 수 있어요.' : '나 자신은 고를 수 없어요.');
|
||||
if (!t.alive) return fail('살아 있는 사람만 고를 수 있어요.');
|
||||
if (a.type === 'mafiaTarget') return fail('마피아끼리는 고를 수 없어요.');
|
||||
if (a.type === 'heal') return t.id === actor ? fail('이 방은 의사가 자기 자신을 지킬 수 없어요.') : fail('같은 사람을 이틀 연속 지킬 수 없어요.');
|
||||
return fail('나 자신은 조사할 수 없어요.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
function roleKnown(s: MafiaState, p: PlayerState, viewer: PlayerState | null): boolean {
|
||||
if (s.winner) return true;
|
||||
if (p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
|
||||
if (!viewer) return false;
|
||||
if (viewer.id === p.id) return true;
|
||||
if (viewer.role === 'mafia' && p.role === 'mafia') return true;
|
||||
if (viewer.role === 'lover' && p.role === 'lover') return true;
|
||||
return !viewer.alive && s.options.deadSeeAll;
|
||||
}
|
||||
|
||||
function nightView(s: MafiaState, me: PlayerState): NightView {
|
||||
const ph = s.phase as NightPhase;
|
||||
const action = nightActionOf(me.role);
|
||||
if (!action) return { action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null };
|
||||
const choice = action === 'mafiaTarget' ? ph.mafiaTarget : action === 'investigate' ? ph.investigate : action === 'heal' ? ph.heal : ph.mediumPeek;
|
||||
const targets = nightTargets(s, me, action);
|
||||
return {
|
||||
action,
|
||||
prompt: action === 'mediumPeek' && targets.length === 0 ? '오늘 밤 할 일: 아직 탈락한 사람이 없어요. 잠시 기다려 주세요' : NIGHT_PROMPT[action],
|
||||
targets,
|
||||
choice,
|
||||
pickedBy: action === 'mafiaTarget' ? ph.mafiaPickedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
function myView(s: MafiaState, me: PlayerState): MyView {
|
||||
const ph = s.phase;
|
||||
const mates = me.role === 'mafia' || me.role === 'lover' ? s.players.filter((p) => p.role === me.role && p.id !== me.id).map((p) => p.id) : [];
|
||||
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
|
||||
return {
|
||||
role: me.role,
|
||||
team: teamOf(me.role),
|
||||
alive: me.alive,
|
||||
mates,
|
||||
vote: ph.kind === 'vote' && me.alive ? (ph.votes[me.id] ?? null) : null,
|
||||
voteTargets: ph.kind === 'vote' && me.alive ? alive(s).filter((p) => p.id !== me.id).map((p) => p.id) : [],
|
||||
ballot: ph.kind === 'confirm' && me.alive ? (ph.ballots[me.id] ?? null) : null,
|
||||
canConfirm: ph.kind === 'confirm' && me.alive && ph.accused !== me.id,
|
||||
canAdjust: ph.kind === 'discussion' && me.alive && s.options.timeAdjust && !ph.adjustedBy.includes(me.id),
|
||||
canReady: day && me.alive && !ph.ready.includes(me.id),
|
||||
ready: day && ph.ready.includes(me.id),
|
||||
night: ph.kind === 'night' && me.alive ? nightView(s, me) : null,
|
||||
results: s.privateResults.filter((r) => r.to === me.id),
|
||||
idleWarning: me.alive && !s.winner && me.idleDays >= AWAY_DAYS - 1 && !me.activeToday,
|
||||
};
|
||||
}
|
||||
|
||||
function view(s: MafiaState, viewer: PlayerId | null): MafiaView {
|
||||
const me = viewer ? (P(s, viewer) ?? null) : null;
|
||||
const ph = s.phase;
|
||||
const readable = CHANNELS.filter((ch) => canRead(s, me, ch));
|
||||
const chat: ChatView[] = s.chat
|
||||
.filter((m) => readable.includes(m.channel))
|
||||
.slice(-CHAT_VIEW)
|
||||
.map((m) => ({ key: `${m.channel}:${m.id}`, channel: m.channel, from: m.from, text: m.text, at: m.at, day: m.day }));
|
||||
const setup: Partial<Record<Role, number>> = {};
|
||||
for (const p of s.players) setup[p.role] = (setup[p.role] ?? 0) + 1;
|
||||
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
|
||||
return {
|
||||
me: me?.id ?? null,
|
||||
options: s.options,
|
||||
day: s.day,
|
||||
phase: {
|
||||
kind: ph.kind,
|
||||
startedAt: ph.kind === 'ended' ? 0 : ph.startedAt,
|
||||
endsAt: ph.kind === 'ended' ? null : ph.endsAt,
|
||||
accused: ph.kind === 'defense' || ph.kind === 'confirm' ? ph.accused : null,
|
||||
adjustedBy: ph.kind === 'discussion' ? [...ph.adjustedBy] : [],
|
||||
readyCount: day ? ph.ready.length : 0,
|
||||
readyNeeded: day ? alive(s).length : 0,
|
||||
},
|
||||
players: s.players.map((p) => {
|
||||
const known = roleKnown(s, p, me);
|
||||
const teamKnown = known || (!p.alive && s.options.deathReveal === 'team');
|
||||
return { id: p.id, alive: p.alive, diedDay: p.diedDay, diedBy: p.diedBy, role: known ? p.role : null, team: teamKnown ? teamOf(p.role) : null };
|
||||
}),
|
||||
setup,
|
||||
aliveCount: alive(s).length,
|
||||
events: s.events.slice(-EVENTS_VIEW),
|
||||
chat,
|
||||
channels: me ? readable.map((ch) => ({ channel: ch, canWrite: !writeBlock(s, me, ch), why: writeBlock(s, me, ch) })) : [],
|
||||
my: me ? myView(s, me) : null,
|
||||
winner: s.winner,
|
||||
final: s.final,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
function activePlayers(s: MafiaState): PlayerId[] {
|
||||
const ph = s.phase;
|
||||
if (ph.kind === 'ended') return [];
|
||||
if (ph.kind === 'defense') return [ph.accused];
|
||||
if (ph.kind === 'confirm') return alive(s).filter((p) => p.id !== ph.accused).map((p) => p.id);
|
||||
// Night: every living player, so the list never reveals who has a night role (§8.3).
|
||||
return alive(s).map((p) => p.id);
|
||||
}
|
||||
|
||||
type Def = GameDefinition<MafiaState, MafiaAction, MafiaView, MafiaOptions>;
|
||||
|
||||
export const mafia: Def = {
|
||||
id: 'mafia',
|
||||
nameKo: '마피아',
|
||||
minPlayers: 4,
|
||||
maxPlayers: 12,
|
||||
stateVersion: 1,
|
||||
defaultOptions: mafiaOptionsSchema.parse({}),
|
||||
optionsSchema: mafiaOptionsSchema as unknown as Def['optionsSchema'],
|
||||
actionSchema: mafiaActionSchema as unknown as Def['actionSchema'],
|
||||
ownChat: true,
|
||||
|
||||
playerCountError: compositionError,
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
const roles = rng.shuffle(rolesFor(options, players.length));
|
||||
const ps: PlayerState[] = players.map((id, i) => ({
|
||||
id,
|
||||
role: roles[i]!,
|
||||
alive: true,
|
||||
soldierUsed: false,
|
||||
publicRole: false,
|
||||
diedDay: null,
|
||||
diedBy: null,
|
||||
activeToday: false,
|
||||
idleDays: 0,
|
||||
}));
|
||||
return {
|
||||
options,
|
||||
players: ps,
|
||||
day: 1,
|
||||
phase: { kind: 'roleReveal', startedAt: now, endsAt: now + ROLE_REVEAL_MS },
|
||||
lastHealTarget: null,
|
||||
privateResults: [],
|
||||
chat: [],
|
||||
chatCount: { day: 0, mafia: 0, dead: 0, lovers: 0 },
|
||||
lastChatAt: {},
|
||||
events: [],
|
||||
trialVoters: [],
|
||||
mvpVotes: {},
|
||||
winner: null,
|
||||
seq: 0,
|
||||
final: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate,
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const before = s.events.length;
|
||||
s.seq += 1;
|
||||
const p = P(s, actor)!;
|
||||
const ph = s.phase;
|
||||
if (a.type !== 'phaseTimeout' && a.type !== 'leave') p.activeToday = true;
|
||||
switch (a.type) {
|
||||
case 'phaseTimeout':
|
||||
advance(s, now, rng);
|
||||
break;
|
||||
case 'leave':
|
||||
leave(s, now, rng, p);
|
||||
break;
|
||||
case 'chat': {
|
||||
s.chatCount[a.channel] += 1;
|
||||
s.chat.push({ id: s.chatCount[a.channel], channel: a.channel, from: actor, text: cleanChat(a.text), at: now, day: s.day });
|
||||
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
|
||||
s.lastChatAt[actor] = now;
|
||||
break;
|
||||
}
|
||||
case 'adjustTime':
|
||||
if (ph.kind !== 'discussion') throw new Error('mafia: adjustTime outside discussion');
|
||||
ph.adjustedBy.push(actor);
|
||||
ph.endsAt = a.delta > 0 ? ph.endsAt + ADJUST_MS : Math.max(ph.endsAt - ADJUST_MS, Math.min(ph.endsAt, now + ADJUST_FLOOR_MS));
|
||||
pushEvent(s, now, { t: 'adjust', p: actor, delta: a.delta });
|
||||
break;
|
||||
case 'readyEarly':
|
||||
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') throw new Error('mafia: readyEarly outside discussion');
|
||||
ph.ready.push(actor);
|
||||
if (alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
|
||||
break;
|
||||
case 'vote':
|
||||
if (ph.kind !== 'vote') throw new Error('mafia: vote outside the vote phase');
|
||||
ph.votes[actor] = a.target;
|
||||
break;
|
||||
case 'confirm':
|
||||
if (ph.kind !== 'confirm') throw new Error('mafia: confirm outside the confirm phase');
|
||||
ph.ballots[actor] = a.choice;
|
||||
break;
|
||||
case 'mafiaTarget':
|
||||
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
|
||||
ph.mafiaTarget = a.target;
|
||||
ph.mafiaPickedBy = a.target === null ? null : actor;
|
||||
break;
|
||||
case 'investigate':
|
||||
case 'heal':
|
||||
case 'mediumPeek':
|
||||
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
|
||||
ph[a.type] = a.target;
|
||||
break;
|
||||
}
|
||||
// Only public announcements become events (chat and night choices stay in the filtered view).
|
||||
const events: GameEvent[] = s.events.slice(before).map((e) => ({ ...e, type: e.t }));
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view,
|
||||
|
||||
activePlayers,
|
||||
|
||||
deadline(s) {
|
||||
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
|
||||
},
|
||||
|
||||
/** One player drives the phase clock (the deadline is shared by everybody). */
|
||||
timeoutPlayers(s) {
|
||||
return s.phase.kind === 'ended' ? [] : [s.players[0]!.id];
|
||||
},
|
||||
|
||||
/** An away driver must not end a phase early. */
|
||||
timeoutNotBefore(s) {
|
||||
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
|
||||
},
|
||||
|
||||
/** Phases end on the clock; a missing vote is an abstention and a missing night action is "no action" (§8.5). */
|
||||
onTimeout() {
|
||||
return { type: 'phaseTimeout' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
const p = P(s, player);
|
||||
if (!p || !p.alive || s.winner) return null;
|
||||
return { type: 'leave' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
const f = s.final;
|
||||
if (!f) return null;
|
||||
const won = (id: PlayerId) => f.players.find((p) => p.id === id)!.won;
|
||||
return {
|
||||
ranking: [f.ranking.filter(won), f.ranking.filter((id) => !won(id))],
|
||||
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
|
||||
summary: f.summary,
|
||||
reason: 'normal',
|
||||
};
|
||||
},
|
||||
|
||||
/** Test helper: every legal move plus the phase clock and one short message per writable channel. */
|
||||
legalActions(s, player) {
|
||||
const p = P(s, player);
|
||||
if (!p || s.winner || s.phase.kind === 'ended') return [];
|
||||
const out: MafiaAction[] = [{ type: 'phaseTimeout' }];
|
||||
for (const ch of CHANNELS) if (!writeBlock(s, p, ch)) out.push({ type: 'chat', channel: ch, text: `m${s.seq}-${player}-${ch}` });
|
||||
if (!p.alive) return out;
|
||||
const ph = s.phase;
|
||||
const me = myView(s, p);
|
||||
if (me.canReady) out.push({ type: 'readyEarly' });
|
||||
if (me.canAdjust) out.push({ type: 'adjustTime', delta: 15 }, { type: 'adjustTime', delta: -15 });
|
||||
if (ph.kind === 'vote') {
|
||||
out.push({ type: 'vote', target: 'abstain' });
|
||||
for (const t of me.voteTargets) out.push({ type: 'vote', target: t });
|
||||
}
|
||||
if (me.canConfirm) out.push({ type: 'confirm', choice: 'yes' }, { type: 'confirm', choice: 'no' });
|
||||
if (me.night?.action) {
|
||||
const type = me.night.action;
|
||||
for (const t of me.night.targets) out.push({ type, target: t } as MafiaAction);
|
||||
if (type === 'mafiaTarget') out.push({ type, target: null });
|
||||
}
|
||||
return out.filter((a) => validate(s, player, a).ok);
|
||||
},
|
||||
|
||||
/** Private result tokens: only their owner may ever see them. */
|
||||
secretsOf(s, player) {
|
||||
if (s.winner) return [];
|
||||
return s.privateResults.filter((r) => r.to === player).map((r) => r.id);
|
||||
},
|
||||
};
|
||||
|
||||
export const _internal = { canRead, writeBlock, roleKnown, nightTargets, discussionMs };
|
||||
883
packages/games/src/mafia/mafia.test.ts
Normal file
883
packages/games/src/mafia/mafia.test.ts
Normal file
@@ -0,0 +1,883 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
|
||||
import {
|
||||
AWAY_DAYS,
|
||||
CHANNELS,
|
||||
GREETING_MS,
|
||||
ROLE_REVEAL_MS,
|
||||
compositionError,
|
||||
mafia,
|
||||
rolesFor,
|
||||
teamOf,
|
||||
type Channel,
|
||||
type MafiaAction,
|
||||
type MafiaOptions,
|
||||
type MafiaState,
|
||||
type MafiaView,
|
||||
type PublicEvent,
|
||||
type Role,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const T0 = 1_000_000;
|
||||
const opts = (o: Record<string, unknown> = {}): MafiaOptions => mafia.optionsSchema.parse(o);
|
||||
const ids = (n: number) => Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
|
||||
/** Game with fixed roles (P0, P1, … in order), started at `T0`, still in the role-reveal phase. */
|
||||
function rig(roles: Role[], o: Record<string, unknown> = {}): MafiaState {
|
||||
const s = mafia.setup({ players: ids(roles.length), options: opts(o), rng: SeededRng.fromSeed(seedN(1)), now: T0 });
|
||||
s.players.forEach((p, i) => (p.role = roles[i]!));
|
||||
return s;
|
||||
}
|
||||
|
||||
const rng = SeededRng.fromSeed([9, 9, 9, 9]);
|
||||
function act(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): MafiaState {
|
||||
const at = now ?? (s.phase.kind === 'ended' ? T0 : Math.max(T0, s.phase.startedAt + 1));
|
||||
const v = mafia.validate(s, actor, a, { now: at });
|
||||
if (!v.ok) throw new Error(`${actor} ${JSON.stringify(a)} rejected: ${v.reason}`);
|
||||
return mafia.apply(s, actor, a, { rng, now: at }).state;
|
||||
}
|
||||
function reject(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): string {
|
||||
const v = mafia.validate(s, actor, a, now === undefined ? undefined : { now });
|
||||
if (v.ok) throw new Error(`${actor} ${JSON.stringify(a)} was accepted`);
|
||||
return v.reason;
|
||||
}
|
||||
/** The phase clock runs out. */
|
||||
function tick(s: MafiaState): MafiaState {
|
||||
if (s.phase.kind === 'ended') throw new Error('tick after the end');
|
||||
return act(s, s.players[0]!.id, { type: 'phaseTimeout' }, s.phase.endsAt);
|
||||
}
|
||||
/** Ticks until `kind` (stops early if the game ends). */
|
||||
function until(s: MafiaState, kind: MafiaState['phase']['kind']): MafiaState {
|
||||
for (let i = 0; i < 20 && s.phase.kind !== kind && s.phase.kind !== 'ended'; i++) s = tick(s);
|
||||
if (s.phase.kind !== kind) throw new Error(`never reached ${kind} (now ${s.phase.kind})`);
|
||||
return s;
|
||||
}
|
||||
/** Marks everyone active so the away rule does not interfere. */
|
||||
function busy(s: MafiaState): MafiaState {
|
||||
for (const p of s.players) p.activeToday = true;
|
||||
return s;
|
||||
}
|
||||
const pl = (s: MafiaState, id: string) => s.players.find((p) => p.id === id)!;
|
||||
const evs = (s: MafiaState, t: PublicEvent['t']) => s.events.filter((e) => e.t === t);
|
||||
const lastEv = <K extends PublicEvent['t']>(s: MafiaState, t: K) => evs(s, t).at(-1) as Extract<PublicEvent, { t: K }> | undefined;
|
||||
const say = (s: MafiaState, p: PlayerId, channel: Channel, text: string, now?: number) => act(s, p, { type: 'chat', channel, text }, now);
|
||||
|
||||
/** Plays a vote day: every alive player votes per `votes` (others abstain); returns the state after the vote phase. */
|
||||
function voteDay(s: MafiaState, votes: Record<string, string>): MafiaState {
|
||||
s = until(busy(s), 'vote');
|
||||
for (const [p, t] of Object.entries(votes)) s = act(s, p, { type: 'vote', target: t });
|
||||
return tick(s);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup (§4)
|
||||
|
||||
describe('직업 배분 (§4)', () => {
|
||||
const TABLE: Record<number, [number, number]> = { 4: [1, 1], 5: [1, 2], 6: [2, 2], 7: [2, 3], 8: [2, 4], 9: [3, 4], 10: [3, 5], 11: [3, 6], 12: [3, 7] };
|
||||
test('4~12인 자동 배분이 표와 일치', () => {
|
||||
for (let n = 4; n <= 12; n++) {
|
||||
const roles = rolesFor(opts(), n);
|
||||
const count = (r: Role) => roles.filter((x) => x === r).length;
|
||||
expect([count('mafia'), count('citizen')]).toEqual(TABLE[n]!);
|
||||
expect(count('police')).toBe(1);
|
||||
expect(count('doctor')).toBe(1);
|
||||
const s = mafia.setup({ players: ids(n), options: opts(), rng: SeededRng.fromSeed(seedN(n)), now: T0 });
|
||||
expect(s.players.map((p) => p.role).sort()).toEqual([...roles].sort());
|
||||
}
|
||||
});
|
||||
|
||||
test('추가 직업은 시민 자리만 대신하고, 모자라면 대기실에서 거부', () => {
|
||||
const all = { extraRoles: { soldier: true, politician: true, medium: true, lovers: true } };
|
||||
const r10 = rolesFor(opts(all), 10);
|
||||
expect(r10.filter((r) => r === 'mafia')).toHaveLength(3);
|
||||
expect(r10.filter((r) => r === 'citizen')).toHaveLength(0);
|
||||
expect(r10.filter((r) => r === 'lover')).toHaveLength(2);
|
||||
expect(compositionError(opts(all), 9)).toContain('연인을');
|
||||
expect(rolesFor(opts(all), 12).filter((r) => r === 'citizen')).toHaveLength(2);
|
||||
// 4명: 시민 1자리 → 연인(2자리)은 안 돼요.
|
||||
expect(compositionError(opts({ extraRoles: { lovers: true } }), 4)).toBe('인원이 부족해서 연인을 넣을 수 없어요(시민 자리 2개 필요).');
|
||||
expect(compositionError(opts({ extraRoles: { soldier: true } }), 4)).toBeNull();
|
||||
expect(compositionError(opts({ extraRoles: { soldier: true, medium: true } }), 4)).toContain('영매를 넣을 수 없어요');
|
||||
expect(mafia.playerCountError!(opts(all), 8)).toContain('연인을');
|
||||
expect(() => mafia.setup({ players: ids(4), options: opts({ extraRoles: { lovers: true } }), rng: SeededRng.fromSeed(seedN(1)), now: 0 })).toThrow();
|
||||
});
|
||||
|
||||
test('수동 마피아 수는 전체의 절반 미만', () => {
|
||||
expect(compositionError(opts({ mafiaCount: 2 }), 4)).toBe('4명일 때 마피아는 1명까지 정할 수 있어요.');
|
||||
expect(compositionError(opts({ mafiaCount: 2 }), 5)).toBeNull();
|
||||
expect(compositionError(opts({ mafiaCount: 3 }), 6)).toContain('2명까지');
|
||||
expect(compositionError(opts({ mafiaCount: 4 }), 9)).toBeNull();
|
||||
expect(mafia.playerCountError!(opts(), 3)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('셔플은 시드로 결정; 마피아는 동료를, 연인은 상대를 안다; 8초 후 첫 페이즈', () => {
|
||||
const o = opts({ extraRoles: { lovers: true } });
|
||||
const a = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
|
||||
const b = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
const orders = new Set<string>();
|
||||
for (let i = 1; i <= 20; i++) orders.add(mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(i)), now: T0 }).players.map((p) => p.role).join());
|
||||
expect(orders.size).toBeGreaterThan(10);
|
||||
const maf = a.players.filter((p) => p.role === 'mafia').map((p) => p.id);
|
||||
const v = mafia.view(a, maf[0]!);
|
||||
expect(v.my!.mates).toEqual([maf[1]!]);
|
||||
expect(v.players.filter((p) => p.role === 'mafia').map((p) => p.id)).toEqual(maf);
|
||||
const lovers = a.players.filter((p) => p.role === 'lover').map((p) => p.id);
|
||||
expect(mafia.view(a, lovers[0]!).my!.mates).toEqual([lovers[1]!]);
|
||||
expect(a.phase).toMatchObject({ kind: 'roleReveal', endsAt: T0 + ROLE_REVEAL_MS });
|
||||
expect(a.day).toBe(1);
|
||||
const g = tick(a);
|
||||
expect(g.phase).toMatchObject({ kind: 'greeting', endsAt: T0 + ROLE_REVEAL_MS + GREETING_MS });
|
||||
expect(tick(g).phase.kind).toBe('night');
|
||||
expect(tick(rig(['mafia', 'police', 'doctor', 'citizen'], { firstPhase: 'night' })).phase.kind).toBe('night');
|
||||
});
|
||||
|
||||
test('토론 시간 = clamp(생존자 × 값, 최소, 최대)', () => {
|
||||
const s = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
|
||||
expect(s.phase.kind === 'discussion' && s.phase.endsAt - s.phase.startedAt).toBe(75_000); // 5 × 15
|
||||
let t = until(rig(['mafia', 'police', 'doctor', 'citizen'], { discussionPerAlive: 10, discussionMin: 60 }), 'night');
|
||||
t = tick(t);
|
||||
expect(t.phase.kind === 'discussion' && t.phase.endsAt - t.phase.startedAt).toBe(60_000);
|
||||
let u = until(rig(['mafia', 'mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen'], { discussionPerAlive: 20, discussionMax: 120 }), 'night');
|
||||
u = tick(u);
|
||||
expect(u.phase.kind === 'discussion' && u.phase.endsAt - u.phase.startedAt).toBe(120_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- votes (§5.3, §5.4)
|
||||
|
||||
describe('지목 투표와 찬반 투표 (§5.3, §5.4)', () => {
|
||||
// P0 마피아, P1 경찰, P2 의사, P3~P5 시민 (2마피아 없이 6명: 마피아 1명이라 오래 버팀)
|
||||
const six = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
|
||||
const day2 = (roles = six(), o: Record<string, unknown> = {}) => until(busy(until(rig(roles, o), 'night')), 'discussion');
|
||||
|
||||
test('동률이면 처형 없이 밤으로, 전원 기권도 처형 없음', () => {
|
||||
let s = voteDay(day2(), { P0: 'P1', P1: 'P0', P2: 'P3', P3: 'P2' });
|
||||
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: true });
|
||||
expect(s.phase.kind).toBe('night');
|
||||
s = voteDay(day2(), { P0: 'abstain', P1: 'abstain' });
|
||||
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: false, abstain: 6 });
|
||||
expect(s.phase.kind).toBe('night');
|
||||
expect(s.players.every((p) => p.alive)).toBe(true);
|
||||
});
|
||||
|
||||
test('정치인 2표로 동률이 깨짐; 투표 변경은 마지막 선택이 유효', () => {
|
||||
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
|
||||
let s = until(day2(roles), 'vote');
|
||||
s = act(s, 'P1', { type: 'vote', target: 'P4' });
|
||||
s = act(s, 'P1', { type: 'vote', target: 'P0' });
|
||||
s = act(s, 'P3', { type: 'vote', target: 'P0' });
|
||||
s = act(s, 'P0', { type: 'vote', target: 'P5' });
|
||||
s = act(s, 'P2', { type: 'vote', target: 'P5' });
|
||||
s = tick(s);
|
||||
const v = lastEv(s, 'vote')!;
|
||||
expect(v.tally).toEqual({ P0: 3, P5: 2 });
|
||||
expect(v.accused).toBe('P0');
|
||||
expect(v.votes).toMatchObject({ P1: 'P0', P3: 'P0', P4: 'abstain' });
|
||||
expect(s.phase).toMatchObject({ kind: 'defense', accused: 'P0' });
|
||||
});
|
||||
|
||||
test('openVote 끄면 득표수만 공개; 자기 자신·죽은 사람 지목 불가', () => {
|
||||
let s = until(day2(six(), { openVote: false }), 'vote');
|
||||
expect(reject(s, 'P1', { type: 'vote', target: 'P1' })).toBe('나 자신은 지목할 수 없어요.');
|
||||
s = act(s, 'P1', { type: 'vote', target: 'P0' });
|
||||
s = tick(s);
|
||||
expect(lastEv(s, 'vote')).toMatchObject({ votes: null, tally: { P0: 1 }, accused: 'P0' });
|
||||
});
|
||||
|
||||
test('찬반 동률이면 생존; 찬성이 많으면 처형(공개는 deathReveal)', () => {
|
||||
let s = voteDay(day2(), { P1: 'P3' });
|
||||
s = tick(s); // defense → confirm
|
||||
expect(s.phase).toMatchObject({ kind: 'confirm', accused: 'P3' });
|
||||
expect(reject(s, 'P3', { type: 'confirm', choice: 'no' })).toBe('지목된 사람은 찬반 투표를 할 수 없어요.');
|
||||
s = act(s, 'P0', { type: 'confirm', choice: 'yes' });
|
||||
s = act(s, 'P1', { type: 'confirm', choice: 'no' });
|
||||
s = tick(s);
|
||||
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', yes: 1, no: 1, outcome: 'spared' });
|
||||
expect(pl(s, 'P3').alive).toBe(true);
|
||||
expect(s.phase.kind).toBe('night');
|
||||
|
||||
let t = voteDay(day2(), { P1: 'P3' });
|
||||
t = tick(t);
|
||||
t = act(t, 'P0', { type: 'confirm', choice: 'yes' });
|
||||
t = act(t, 'P1', { type: 'confirm', choice: 'yes' });
|
||||
t = act(t, 'P2', { type: 'confirm', choice: 'no' });
|
||||
t = tick(t);
|
||||
expect(lastEv(t, 'confirm')).toMatchObject({ yes: 2, no: 1, outcome: 'executed' });
|
||||
expect(lastEv(t, 'death')).toEqual(expect.objectContaining({ p: 'P3', by: 'vote', team: 'town', role: null }));
|
||||
expect(pl(t, 'P3')).toMatchObject({ alive: false, diedBy: 'vote', diedDay: 2 });
|
||||
});
|
||||
|
||||
test('정치인 피고가 찬반을 통과하면 정치인임이 공개되고 생존', () => {
|
||||
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
|
||||
let s = voteDay(day2(roles), { P1: 'P3', P0: 'P3' });
|
||||
s = tick(s);
|
||||
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
|
||||
s = tick(s);
|
||||
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', outcome: 'politician' });
|
||||
expect(pl(s, 'P3').alive).toBe(true);
|
||||
for (const viewer of ['P4', null]) expect(mafia.view(s, viewer).players.find((p) => p.id === 'P3')!.role).toBe('politician');
|
||||
});
|
||||
|
||||
test('최후의 반론 중에는 피고만 낮 채팅', () => {
|
||||
let s = voteDay(day2(), { P1: 'P3' });
|
||||
expect(s.phase.kind).toBe('defense');
|
||||
expect(reject(s, 'P1', { type: 'chat', channel: 'day', text: '안녕' })).toBe('최후의 반론 중에는 지목된 사람만 말할 수 있어요.');
|
||||
s = say(s, 'P3', 'day', '저는 시민이에요!');
|
||||
expect(s.chat.at(-1)).toMatchObject({ from: 'P3', channel: 'day', text: '저는 시민이에요!' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- night (§5.5)
|
||||
|
||||
describe('밤 (§5.5)', () => {
|
||||
const night1 = (roles: Role[], o: Record<string, unknown> = {}) => until(rig(roles, o), 'night');
|
||||
const five = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen'];
|
||||
|
||||
test('의사가 마피아 대상을 치료하면 사망 없음, 발표에 이름 없음', () => {
|
||||
let s = night1(five());
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
|
||||
s = act(s, 'P2', { type: 'heal', target: 'P3' });
|
||||
s = tick(s);
|
||||
expect(s.players.every((p) => p.alive)).toBe(true);
|
||||
const healed = lastEv(s, 'healed')!;
|
||||
expect(healed).toBeDefined();
|
||||
expect(JSON.stringify(healed)).not.toContain('P3');
|
||||
expect(s.phase.kind).toBe('discussion');
|
||||
expect(s.day).toBe(2);
|
||||
expect(s.lastHealTarget).toBe('P3');
|
||||
});
|
||||
|
||||
test('doctorSelfHeal / doctorRepeat 끄면 검증 거부', () => {
|
||||
let s = night1(five(), { doctorSelfHeal: false, doctorRepeat: false });
|
||||
expect(reject(s, 'P2', { type: 'heal', target: 'P2' })).toBe('이 방은 의사가 자기 자신을 지킬 수 없어요.');
|
||||
s = act(s, 'P2', { type: 'heal', target: 'P4' });
|
||||
s = until(busy(tick(s)), 'night');
|
||||
expect(reject(s, 'P2', { type: 'heal', target: 'P4' })).toBe('같은 사람을 이틀 연속 지킬 수 없어요.');
|
||||
expect(mafia.view(s, 'P2').my!.night!.targets).not.toContain('P4');
|
||||
s = act(s, 'P2', { type: 'heal', target: 'P3' });
|
||||
// 기본값(켜짐)이면 자기 자신·연속 모두 허용
|
||||
let t = night1(five());
|
||||
t = act(t, 'P2', { type: 'heal', target: 'P2' });
|
||||
t = until(busy(tick(t)), 'night');
|
||||
t = act(t, 'P2', { type: 'heal', target: 'P2' });
|
||||
expect(t.phase.kind === 'night' && t.phase.heal).toBe('P2');
|
||||
});
|
||||
|
||||
test('군인: 첫 공격은 버티고 공개, 두 번째 공격에 사망; 의사 치료가 겹치면 의사 우선', () => {
|
||||
const roles: Role[] = ['mafia', 'police', 'doctor', 'soldier', 'citizen', 'citizen'];
|
||||
let s = night1(roles);
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
|
||||
s = act(s, 'P2', { type: 'heal', target: 'P3' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: false, publicRole: false });
|
||||
expect(lastEv(s, 'healed')).toBeDefined();
|
||||
s = until(busy(s), 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: true, publicRole: true });
|
||||
expect(lastEv(s, 'soldier')).toMatchObject({ p: 'P3' });
|
||||
expect(mafia.view(s, null).players.find((p) => p.id === 'P3')!.role).toBe('soldier');
|
||||
s = until(busy(s), 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P3')).toMatchObject({ alive: false, diedBy: 'mafia', diedDay: 3 });
|
||||
});
|
||||
|
||||
test('마피아 2명이 다른 대상을 고르면 마지막 선택만 유효; 미선택 + noKill이면 사망 없음', () => {
|
||||
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
|
||||
let s = night1(roles);
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
|
||||
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
|
||||
expect(mafia.view(s, 'P0').my!.night).toMatchObject({ choice: 'P5', pickedBy: 'P1' });
|
||||
expect(reject(s, 'P0', { type: 'mafiaTarget', target: 'P1' })).toBe('마피아끼리는 고를 수 없어요.');
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P4').alive).toBe(true);
|
||||
expect(pl(s, 'P5')).toMatchObject({ alive: false, diedBy: 'mafia' });
|
||||
let t = night1(roles);
|
||||
t = act(t, 'P0', { type: 'mafiaTarget', target: 'P4' });
|
||||
t = act(t, 'P1', { type: 'mafiaTarget', target: null });
|
||||
t = tick(t);
|
||||
expect(t.players.every((p) => p.alive)).toBe(true);
|
||||
expect(lastEv(t, 'quiet')).toBeDefined();
|
||||
});
|
||||
|
||||
test('mafiaMustKill=random이면 대상 미선택 시 RNG로 비마피아 1명', () => {
|
||||
const s = tick(night1(['mafia', 'police', 'doctor', 'citizen', 'citizen'], { mafiaMustKill: 'random' }));
|
||||
const dead = s.players.filter((p) => !p.alive);
|
||||
expect(dead).toHaveLength(1);
|
||||
expect(dead[0]!.role).not.toBe('mafia');
|
||||
});
|
||||
|
||||
test('경찰이 대상을 3번 바꾸면 마감 때 마지막 1명 결과만 받는다', () => {
|
||||
let s = night1(five());
|
||||
for (const t of ['P3', 'P0', 'P4']) s = act(s, 'P1', { type: 'investigate', target: t });
|
||||
expect(s.privateResults).toHaveLength(0);
|
||||
expect(mafia.view(s, 'P1').my!.results).toEqual([]);
|
||||
s = tick(s);
|
||||
expect(s.privateResults).toHaveLength(1);
|
||||
expect(s.privateResults[0]).toMatchObject({ to: 'P1', target: 'P4', result: 'notMafia', kind: 'investigate', day: 1 });
|
||||
s = until(busy(s), 'night');
|
||||
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
|
||||
s = tick(s);
|
||||
expect(mafia.view(s, 'P1').my!.results.map((r) => r.result)).toEqual(['notMafia', 'mafia']);
|
||||
expect(reject(until(busy(s), 'night'), 'P1', { type: 'investigate', target: 'P1' })).toBe('나 자신은 조사할 수 없어요.');
|
||||
// policeRevealRole
|
||||
let r = night1(five(), { policeRevealRole: true });
|
||||
r = act(r, 'P1', { type: 'investigate', target: 'P2' });
|
||||
expect(tick(r).privateResults[0]!.result).toBe('doctor');
|
||||
});
|
||||
|
||||
test('경찰이 그 밤에 죽어도 결과는 전달; 영매는 탈락자 직업 확인', () => {
|
||||
const roles: Role[] = ['mafia', 'police', 'doctor', 'medium', 'citizen', 'citizen'];
|
||||
let s = night1(roles);
|
||||
expect(mafia.view(s, 'P3').my!.night).toMatchObject({ action: 'mediumPeek', targets: [] });
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P1' });
|
||||
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P1').alive).toBe(false);
|
||||
expect(mafia.view(s, 'P1').my!.results[0]).toMatchObject({ target: 'P0', result: 'mafia' });
|
||||
s = until(busy(s), 'night');
|
||||
expect(reject(s, 'P3', { type: 'mediumPeek', target: 'P4' })).toBe('탈락한 사람만 고를 수 있어요.');
|
||||
s = act(s, 'P3', { type: 'mediumPeek', target: 'P1' });
|
||||
s = tick(s);
|
||||
expect(mafia.view(s, 'P3').my!.results[0]).toMatchObject({ kind: 'peek', target: 'P1', result: 'police' });
|
||||
});
|
||||
|
||||
test('밤 행동은 자기 직업만, 밤에만', () => {
|
||||
let s = night1(five());
|
||||
expect(reject(s, 'P3', { type: 'investigate', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
|
||||
expect(reject(s, 'P1', { type: 'heal', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
|
||||
s = until(busy(s), 'discussion');
|
||||
expect(reject(s, 'P1', { type: 'investigate', target: 'P0' })).toBe('밤에만 할 수 있어요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- win (§6)
|
||||
|
||||
describe('승리 판정 (§6)', () => {
|
||||
test('처형 직후 마피아 = 비마피아면 즉시 마피아 승리(밤으로 넘어가지 않음)', () => {
|
||||
// 마피아 2, 시민 4 → 밤에 1명 → 2 대 3 → 시민 1명 처형 → 2 대 2
|
||||
let s = rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']);
|
||||
s = until(s, 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P5' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P5').alive).toBe(false);
|
||||
s = voteDay(s, { P0: 'P4', P1: 'P4', P2: 'P4' });
|
||||
s = tick(s);
|
||||
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
|
||||
s = tick(s);
|
||||
expect(s.winner).toBe('mafia');
|
||||
expect(s.phase.kind).toBe('ended');
|
||||
expect(lastEv(s, 'phase')!.kind).not.toBe('night');
|
||||
const res = mafia.result(s)!;
|
||||
expect(res.ranking).toEqual([['P0', 'P1'], ['P2', 'P3', 'P4', 'P5']]);
|
||||
expect(res.scores).toMatchObject({ P0: 1, P1: 1, P2: 0 });
|
||||
expect(res.summary).toBe('마피아 팀 승리! 2일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.');
|
||||
expect(mafia.activePlayers(s)).toEqual([]);
|
||||
expect(mafia.deadline!(s)).toBeNull();
|
||||
});
|
||||
|
||||
test('4인: 첫 밤 시민 사망 → 계속, 다음 낮 오처형 → 마피아 승리; 마피아 처형 → 시민 승리', () => {
|
||||
let s = until(rig(['mafia', 'police', 'doctor', 'citizen']), 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
|
||||
s = tick(s);
|
||||
expect(s.winner).toBeNull();
|
||||
expect(lastEv(s, 'death')).toMatchObject({ p: 'P3', by: 'mafia', team: 'town' });
|
||||
let a = voteDay(s, { P0: 'P1', P2: 'P1' });
|
||||
a = tick(a);
|
||||
a = act(a, 'P0', { type: 'confirm', choice: 'yes' });
|
||||
a = tick(a);
|
||||
expect(a.winner).toBe('mafia');
|
||||
let b = voteDay(s, { P1: 'P0', P2: 'P0' });
|
||||
b = tick(b);
|
||||
b = act(b, 'P1', { type: 'confirm', choice: 'yes' });
|
||||
b = act(b, 'P2', { type: 'confirm', choice: 'yes' });
|
||||
b = tick(b);
|
||||
expect(b.winner).toBe('town');
|
||||
expect(b.final!.summary).toBe('시민 팀 승리! 2일차 낮 투표로 마지막 마피아를 찾아냈어요.');
|
||||
expect(b.final!.mvp).toBe('P1');
|
||||
expect(mafia.result(b)!.ranking).toEqual([['P1', 'P2', 'P3'], ['P0']]);
|
||||
});
|
||||
|
||||
test('아침 발표 직후 판정', () => {
|
||||
let s = until(rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
|
||||
s = tick(s); // 2 vs 3
|
||||
s = voteDay(s, {});
|
||||
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
|
||||
s = tick(s); // 2 vs 2
|
||||
expect(s.winner).toBe('mafia');
|
||||
expect(s.final!.summary).toBe('마피아 팀 승리! 3일차 아침, 마피아 수가 시민 수와 같아졌어요.');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- chat (§5.6)
|
||||
|
||||
describe('채팅 권한 (§5.6)', () => {
|
||||
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
|
||||
const o = { extraRoles: { medium: true, lovers: true } };
|
||||
|
||||
test('밤에 시민 낮 채팅 거부, 죽은 사람 낮 채팅 거부, 레이트 리밋 초과 거부', () => {
|
||||
let s = until(rig(roles, o), 'greeting');
|
||||
const g0 = (s.phase as Extract<MafiaState['phase'], { kind: 'greeting' }>).startedAt;
|
||||
s = say(s, 'P7', 'day', '안녕하세요');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, g0 + 200)).toBe('조금 천천히 보내 주세요.');
|
||||
expect(mafia.validate(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, { now: g0 + 1 + 700 }).ok).toBe(true);
|
||||
s = tick(s);
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '밤인데' })).toBe('밤에는 낮 채팅을 쓸 수 없어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'mafia', text: '...' })).toBe('마피아만 쓸 수 있어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'dead', text: '...' })).toBe('탈락한 사람만 쓸 수 있어요.');
|
||||
s = say(s, 'P0', 'mafia', '누구 노릴까');
|
||||
s = say(s, 'P5', 'lovers', '자기야');
|
||||
s = say(s, 'P4', 'dead', '영매예요');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
|
||||
s = tick(s);
|
||||
expect(pl(s, 'P7').alive).toBe(false);
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '억울해' })).toBe('탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.');
|
||||
s = say(s, 'P7', 'dead', '억울해요');
|
||||
expect(reject(s, 'P0', { type: 'chat', channel: 'mafia', text: '낮' })).toBe('마피아 채팅은 밤에만 쓸 수 있어요.');
|
||||
expect(reject(s, 'P4', { type: 'chat', channel: 'dead', text: '낮' })).toBe('영매는 밤에만 죽은 사람과 이야기할 수 있어요.');
|
||||
expect(reject(s, 'P5', { type: 'chat', channel: 'lovers', text: '낮' })).toBe('연인 채팅은 밤에만 쓸 수 있어요.');
|
||||
});
|
||||
|
||||
test('고운 말 필터·링크·전화번호·길이', () => {
|
||||
const s = until(rig(roles, o), 'greeting');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '시 발' })).toBe('고운 말을 써 주세요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '여기 봐 https://a.b' })).toBe('링크는 보낼 수 없어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: 'example.com 들어와' })).toBe('링크는 보낼 수 없어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '010-1234-5678로 연락' })).toBe('전화번호 같은 개인 정보는 보낼 수 없어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '가'.repeat(201) })).toBe('메시지는 200자까지 보낼 수 있어요.');
|
||||
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: ' ' })).toBe('메시지를 입력해 주세요.');
|
||||
const off = until(rig(roles, { ...o, kidSafeChat: false }), 'greeting');
|
||||
expect(mafia.validate(off, 'P7', { type: 'chat', channel: 'day', text: 'example.com' }).ok).toBe(true);
|
||||
expect(say(s, 'P7', 'day', '가'.repeat(200)).chat.at(-1)!.text).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- views (§8.3)
|
||||
|
||||
/** Independent re-statement of who may know whose role (§8.3). */
|
||||
function mayKnowRole(s: MafiaState, owner: PlayerId, viewer: PlayerId | null): boolean {
|
||||
const p = pl(s, owner);
|
||||
if (s.winner || p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
|
||||
if (!viewer) return false;
|
||||
const v = pl(s, viewer);
|
||||
if (viewer === owner) return true;
|
||||
if (v.role === 'mafia' && p.role === 'mafia') return true;
|
||||
if (v.role === 'lover' && p.role === 'lover') return true;
|
||||
return !v.alive && s.options.deadSeeAll;
|
||||
}
|
||||
function mayRead(s: MafiaState, ch: Channel, viewer: PlayerId | null): boolean {
|
||||
if (ch === 'day' || (s.winner && s.options.revealChatsAtEnd)) return true;
|
||||
if (!viewer) return false;
|
||||
const v = pl(s, viewer);
|
||||
if (ch === 'mafia') return v.role === 'mafia' || (!v.alive && s.options.deadSeeAll);
|
||||
if (ch === 'lovers') return v.role === 'lover';
|
||||
return !v.alive || (v.role === 'medium' && s.phase.kind === 'night');
|
||||
}
|
||||
|
||||
/** Structural + string leak check of one viewer's serialized view. */
|
||||
function checkView(s: MafiaState, viewer: PlayerId | null): MafiaView {
|
||||
const v = mafia.view(s, viewer);
|
||||
const json = JSON.stringify(v);
|
||||
for (const p of v.players) {
|
||||
if (p.role !== null && !mayKnowRole(s, p.id, viewer)) throw new Error(`role of ${p.id} leaked to ${viewer}`);
|
||||
if (p.role !== null && p.role !== pl(s, p.id).role) throw new Error('wrong role shown');
|
||||
if (p.team !== null && p.role === null && !(!pl(s, p.id).alive && s.options.deathReveal === 'team')) throw new Error(`team of ${p.id} leaked to ${viewer}`);
|
||||
}
|
||||
for (const m of s.chat) {
|
||||
const visible = v.chat.some((c) => c.key === `${m.channel}:${m.id}`);
|
||||
if (!mayRead(s, m.channel, viewer)) {
|
||||
if (visible || json.includes(JSON.stringify(m.text))) throw new Error(`${m.channel} message "${m.text}" leaked to ${viewer}`);
|
||||
}
|
||||
}
|
||||
for (const r of s.privateResults) if (r.to !== viewer && json.includes(`"${r.id}"`)) throw new Error(`private result ${r.id} leaked to ${viewer}`);
|
||||
if (v.my && viewer) {
|
||||
const me = pl(s, viewer);
|
||||
const n = v.my.night;
|
||||
if (n && s.phase.kind === 'night') {
|
||||
const ph = s.phase;
|
||||
const own = me.role === 'mafia' ? ph.mafiaTarget : me.role === 'police' ? ph.investigate : me.role === 'doctor' ? ph.heal : me.role === 'medium' ? ph.mediumPeek : null;
|
||||
if (n.choice !== own) throw new Error(`night choice of someone else shown to ${viewer}`);
|
||||
if (me.role !== 'mafia' && n.pickedBy !== null) throw new Error('mafia picker leaked');
|
||||
}
|
||||
if (v.my.mates.some((m) => pl(s, m).role !== me.role)) throw new Error('wrong mates');
|
||||
}
|
||||
if (viewer === null && v.my !== null) throw new Error('spectator has my');
|
||||
return v;
|
||||
}
|
||||
|
||||
function keyShape(x: unknown): unknown {
|
||||
if (Array.isArray(x)) return 'array';
|
||||
if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, keyShape((x as Record<string, unknown>)[k])]));
|
||||
return x === null ? 'null|value' : 'null|value';
|
||||
}
|
||||
|
||||
describe('화면 정보와 누출 (§8.3)', () => {
|
||||
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
|
||||
const o = { extraRoles: { medium: true, lovers: true } };
|
||||
|
||||
function busyGame(extra: Record<string, unknown> = {}): MafiaState {
|
||||
let s = until(rig(roles, { ...o, ...extra }), 'night');
|
||||
s = say(s, 'P0', 'mafia', 'SECRET-MAFIA-1');
|
||||
s = say(s, 'P5', 'lovers', 'SECRET-LOVE-1');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
|
||||
s = act(s, 'P2', { type: 'investigate', target: 'P0' });
|
||||
s = act(s, 'P3', { type: 'heal', target: 'P8' });
|
||||
s = tick(s);
|
||||
s = say(s, 'P7', 'dead', 'SECRET-DEAD-1');
|
||||
return s;
|
||||
}
|
||||
|
||||
test('생존 시민·관전자 view에 남의 직업, 비공개 채널, 밤 대상, 결과가 없다', () => {
|
||||
const s = busyGame();
|
||||
for (const viewer of [...s.players.map((p) => p.id), null]) checkView(s, viewer);
|
||||
const citizen = JSON.stringify(mafia.view(s, 'P8'));
|
||||
for (const secret of ['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1', s.privateResults[0]!.id]) expect(citizen).not.toContain(secret);
|
||||
const spec = mafia.view(s, null);
|
||||
expect(spec.players.every((p) => p.role === null)).toBe(true);
|
||||
expect(spec.channels).toEqual([]);
|
||||
expect(spec.chat.every((c) => c.channel === 'day')).toBe(true);
|
||||
// 마피아는 마피아 채팅(언제든), 연인은 연인 채팅, 죽은 사람은 죽은 사람 채팅
|
||||
expect(mafia.view(s, 'P1').chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
|
||||
expect(mafia.view(s, 'P6').chat.map((c) => c.text)).toContain('SECRET-LOVE-1');
|
||||
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
|
||||
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
|
||||
expect(mafia.view(s, 'P2').my!.results).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('영매는 낮에는 dead 메시지를 받지 않고 밤에만 본다', () => {
|
||||
let s = busyGame();
|
||||
expect(s.phase.kind).toBe('discussion');
|
||||
expect(JSON.stringify(mafia.view(s, 'P4'))).not.toContain('SECRET-DEAD-1');
|
||||
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day']);
|
||||
s = until(busy(s), 'night');
|
||||
const v = mafia.view(s, 'P4');
|
||||
expect(v.chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
|
||||
expect(v.channels.find((c) => c.channel === 'dead')).toMatchObject({ canWrite: true });
|
||||
s = say(s, 'P4', 'dead', '영매가 왔어요');
|
||||
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('영매가 왔어요');
|
||||
expect(JSON.stringify(mafia.view(s, 'P8'))).not.toContain('영매가 왔어요');
|
||||
});
|
||||
|
||||
test('deadSeeAll: 죽은 사람은 모든 직업과 마피아 채팅; 게임 끝나면 모두 공개', () => {
|
||||
const s = busyGame({ deadSeeAll: true });
|
||||
const v = checkView(s, 'P7');
|
||||
expect(v.players.every((p) => p.role !== null)).toBe(true);
|
||||
expect(v.chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
|
||||
const end = structuredClone(s);
|
||||
end.winner = 'town';
|
||||
end.phase = { kind: 'ended' };
|
||||
const sv = mafia.view(end, null);
|
||||
expect(sv.players.every((p) => p.role !== null)).toBe(true);
|
||||
expect(sv.chat.map((c) => c.text)).toEqual(expect.arrayContaining(['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1']));
|
||||
end.options = { ...end.options, revealChatsAtEnd: false };
|
||||
expect(mafia.view(end, null).chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
|
||||
});
|
||||
|
||||
test('밤 화면 view 형태는 시민·경찰·의사가 같다(행동 정보는 my.night 값으로만)', () => {
|
||||
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
|
||||
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
|
||||
s = act(s, 'P2', { type: 'heal', target: 'P3' });
|
||||
const shape = (p: string) => JSON.stringify(keyShape(mafia.view(s, p)));
|
||||
expect(shape('P1')).toBe(shape('P3'));
|
||||
expect(shape('P2')).toBe(shape('P3'));
|
||||
expect(mafia.view(s, 'P3').my!.night).toEqual({ action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null });
|
||||
expect(mafia.view(s, 'P1').my!.night!.prompt).toBe('오늘 밤 할 일: 조사할 사람을 골라 주세요');
|
||||
// 밤에 기다리는 사람 목록도 직업을 드러내지 않는다
|
||||
expect(mafia.activePlayers(s)).toEqual(['P0', 'P1', 'P2', 'P3', 'P4']);
|
||||
// 밤 행동은 이벤트로 나가지 않는다
|
||||
const out = mafia.apply(s, 'P1', { type: 'investigate', target: 'P4' }, { rng, now: s.phase.kind === 'night' ? s.phase.startedAt + 5 : 0 });
|
||||
expect(out.events).toEqual([]);
|
||||
});
|
||||
|
||||
test('채널 탭: 읽을 수 있는 탭만, 쓸 수 없으면 이유', () => {
|
||||
const s = until(rig(roles, o), 'night');
|
||||
expect(mafia.view(s, 'P0').channels.map((c) => c.channel)).toEqual(['day', 'mafia']);
|
||||
expect(mafia.view(s, 'P5').channels.map((c) => c.channel)).toEqual(['day', 'lovers']);
|
||||
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day', 'dead']);
|
||||
expect(mafia.view(s, 'P8').channels).toEqual([{ channel: 'day', canWrite: false, why: '밤에는 낮 채팅을 쓸 수 없어요.' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- time, away, leave (§5.2, §8.5)
|
||||
|
||||
describe('시간 조절·자리 비움·나가기 (§5.2, §8.5)', () => {
|
||||
const day2 = () => until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
|
||||
|
||||
test('timeAdjust: 1인 1일 1회, -15초로 5초 미만 내려가지 않음', () => {
|
||||
let s = day2();
|
||||
const ph0 = s.phase as Extract<MafiaState['phase'], { kind: 'discussion' }>;
|
||||
const end0 = ph0.endsAt;
|
||||
s = act(s, 'P1', { type: 'adjustTime', delta: 15 });
|
||||
expect((s.phase as typeof ph0).endsAt).toBe(end0 + 15_000);
|
||||
expect(reject(s, 'P1', { type: 'adjustTime', delta: -15 })).toBe('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
|
||||
expect(lastEv(s, 'adjust')).toMatchObject({ p: 'P1', delta: 15 });
|
||||
// 끝나기 8초 전에 -15 → 5초 남김
|
||||
s = act(s, 'P2', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 8_000);
|
||||
const now = end0 + 15_000 - 8_000;
|
||||
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
|
||||
// 이미 5초 미만이면 그대로
|
||||
s = act(s, 'P3', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 3_000);
|
||||
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
|
||||
expect(mafia.view(s, null).phase.adjustedBy).toEqual(['P1', 'P2', 'P3']);
|
||||
// 다음 날에는 다시 가능
|
||||
s = until(until(busy(s), 'night'), 'discussion');
|
||||
expect(mafia.validate(s, 'P1', { type: 'adjustTime', delta: 15 }).ok).toBe(true);
|
||||
const off = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen'], { timeAdjust: false }), 'night')), 'discussion');
|
||||
expect(reject(off, 'P1', { type: 'adjustTime', delta: 15 })).toBe('이 방은 토론 시간을 바꿀 수 없어요.');
|
||||
});
|
||||
|
||||
test('토론 끝내기: 생존자 전원이 누르면 즉시 지목 투표', () => {
|
||||
let s = day2();
|
||||
for (const p of ['P0', 'P1', 'P2', 'P3']) s = act(s, p, { type: 'readyEarly' });
|
||||
expect(s.phase.kind).toBe('discussion');
|
||||
expect(mafia.view(s, 'P4').phase).toMatchObject({ readyCount: 4, readyNeeded: 5 });
|
||||
expect(reject(s, 'P0', { type: 'readyEarly' })).toBe('이미 토론 끝내기를 눌렀어요.');
|
||||
s = act(s, 'P4', { type: 'readyEarly' });
|
||||
expect(s.phase.kind).toBe('vote');
|
||||
});
|
||||
|
||||
test('2번의 낮 동안 아무것도 안 하면 자리 비움 탈락, 그 결과로 승리 조건이면 종료', () => {
|
||||
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
|
||||
const keep = (x: MafiaState) => {
|
||||
for (const p of x.players) if (p.id !== 'P0') p.activeToday = true;
|
||||
return x;
|
||||
};
|
||||
s = until(keep(tick(s)), 'night'); // 2일차: P0 idle 1
|
||||
expect(pl(s, 'P0').idleDays).toBe(1);
|
||||
expect(mafia.view(tick(s), 'P0').my!.idleWarning).toBe(true);
|
||||
s = until(keep(tick(s)), 'ended'); // 3일차 끝: P0(마피아) 자리 비움 → 시민 승리
|
||||
expect(pl(s, 'P0')).toMatchObject({ alive: false, diedBy: 'disconnect', idleDays: AWAY_DAYS });
|
||||
expect(s.winner).toBe('town');
|
||||
expect(s.final!.cause).toBe('disconnect');
|
||||
// 한 번이라도 행동하면 다시 0
|
||||
let t = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
|
||||
t = until(keep(tick(t)), 'vote');
|
||||
t = act(t, 'P0', { type: 'vote', target: 'abstain' });
|
||||
t = until(keep(t), 'night');
|
||||
expect(pl(t, 'P0').idleDays).toBe(0);
|
||||
});
|
||||
|
||||
test('나가기: 즉시 탈락(공개 규칙 적용), 피고가 나가면 밤으로, 승리 판정', () => {
|
||||
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'], { deathReveal: 'role' }), 'night');
|
||||
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
|
||||
s = act(s, 'P4', { type: 'leave' });
|
||||
expect(lastEv(s, 'death')).toMatchObject({ p: 'P4', by: 'disconnect', role: 'citizen' });
|
||||
expect(s.phase.kind === 'night' && s.phase.mafiaTarget).toBeNull();
|
||||
s = voteDay(tick(s), { P1: 'P5' });
|
||||
expect(s.phase.kind).toBe('defense');
|
||||
s = act(s, 'P5', { type: 'leave' });
|
||||
expect(s.phase.kind).toBe('night');
|
||||
expect(mafia.onLeave!(s, 'P5')).toBeNull();
|
||||
expect(mafia.onLeave!(s, 'P0')).toEqual({ type: 'leave' });
|
||||
s = act(s, 'P0', { type: 'leave' });
|
||||
expect(s.winner).toBe('town');
|
||||
expect(mafia.onLeave!(s, 'P1')).toBeNull();
|
||||
});
|
||||
|
||||
test('phaseTimeout은 시스템 전용이고 마감 전에는 거부', () => {
|
||||
const s = rig(['mafia', 'police', 'doctor', 'citizen']);
|
||||
expect(mafia.actionSchema.safeParse({ type: 'phaseTimeout' }).success).toBe(false);
|
||||
expect(mafia.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||
expect(reject(s, 'P0', { type: 'phaseTimeout' }, T0 + 100)).toBe('아직 시간이 남았어요.');
|
||||
expect(mafia.validate(s, 'P0', { type: 'phaseTimeout' }, { now: T0 + ROLE_REVEAL_MS }).ok).toBe(true);
|
||||
expect(mafia.timeoutPlayers!(s)).toEqual(['P0']);
|
||||
expect(mafia.timeoutNotBefore!(s)).toBe(T0 + ROLE_REVEAL_MS);
|
||||
expect(mafia.deadline!(s)).toBe(T0 + ROLE_REVEAL_MS);
|
||||
expect(reject(s, 'X', { type: 'readyEarly' })).toBe('이 게임의 플레이어가 아니에요.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('스키마', () => {
|
||||
test('옵션에 한국어 메타, 기본값', () => {
|
||||
const shape = (mafia.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
|
||||
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
|
||||
const extra = (shape.extraRoles as unknown as { unwrap(): { shape: Record<string, { meta(): { title?: string } | undefined }> } }).unwrap().shape;
|
||||
for (const [k, v] of Object.entries(extra)) expect(v.meta()?.title, k).toBeTruthy();
|
||||
expect(mafia.defaultOptions).toMatchObject({ mafiaCount: 'auto', firstPhase: 'day', discussionPerAlive: 15, discussionMin: 60, discussionMax: 180, voteSeconds: 20, defenseSeconds: 20, confirmSeconds: 10, nightSeconds: 30, deathReveal: 'team', openVote: true, kidSafeChat: true });
|
||||
expect(mafia.defaultOptions.extraRoles).toEqual({ soldier: false, politician: false, medium: false, lovers: false });
|
||||
expect(mafia.ownChat).toBe(true);
|
||||
expect(mafia.actionSchema.safeParse({ type: 'chat', channel: 'system', text: 'x' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- properties
|
||||
|
||||
const PRESETS: Record<string, unknown>[] = [
|
||||
{},
|
||||
{ extraRoles: { soldier: true, medium: true }, deathReveal: 'role', mafiaMustKill: 'random' },
|
||||
{ extraRoles: { lovers: true, politician: true }, openVote: false, firstPhase: 'night', deadSeeAll: true },
|
||||
{ deathReveal: 'none', doctorSelfHeal: false, doctorRepeat: false, policeRevealRole: true, timeAdjust: false },
|
||||
{ extraRoles: { soldier: true, politician: true, medium: true, lovers: true }, mafiaMustKill: 'random', revealChatsAtEnd: false },
|
||||
];
|
||||
|
||||
/** Fits the preset's extra roles to the player count (drops the ones that do not fit). */
|
||||
function presetFor(i: number, n: number): MafiaOptions {
|
||||
const o = opts(PRESETS[i % PRESETS.length]!);
|
||||
for (const r of ['lovers', 'politician', 'medium', 'soldier'] as const) if (compositionError(o, n)) o.extraRoles = { ...o.extraRoles, [r]: false };
|
||||
return o;
|
||||
}
|
||||
|
||||
function invariants(s: MafiaState, initialRoles: string): void {
|
||||
if (s.players.map((p) => p.role).sort().join() !== initialRoles) throw new Error('roles changed');
|
||||
for (const p of s.players) {
|
||||
if (p.alive !== (p.diedBy === null) || p.alive !== (p.diedDay === null)) throw new Error(`${p.id} death fields out of sync`);
|
||||
if (p.soldierUsed && p.role !== 'soldier') throw new Error('soldierUsed on non-soldier');
|
||||
}
|
||||
const living = s.players.filter((p) => p.alive);
|
||||
const m = living.filter((p) => p.role === 'mafia').length;
|
||||
if (!s.winner) {
|
||||
if (m === 0 || m >= living.length - m) throw new Error('win condition met but game goes on');
|
||||
if (s.phase.kind === 'ended') throw new Error('ended without winner');
|
||||
if (mafia.activePlayers(s).length === 0) throw new Error('nobody to act');
|
||||
} else {
|
||||
if (s.phase.kind !== 'ended' || !s.final) throw new Error('winner without end');
|
||||
if ((s.winner === 'town') !== (m === 0)) throw new Error('wrong winner');
|
||||
}
|
||||
for (const ch of CHANNELS) {
|
||||
const msgs = s.chat.filter((c) => c.channel === ch);
|
||||
if (msgs.some((c, i) => i > 0 && c.id !== msgs[i - 1]!.id + 1)) throw new Error('chat ids not consecutive');
|
||||
if (msgs.length && msgs.at(-1)!.id !== s.chatCount[ch]) throw new Error('chat count out of sync');
|
||||
}
|
||||
if (s.events.some((e, i) => e.n !== i + 1)) throw new Error('event numbers out of order');
|
||||
}
|
||||
|
||||
interface FastReport {
|
||||
steps: number;
|
||||
finished: boolean;
|
||||
trace: string;
|
||||
final: MafiaState;
|
||||
}
|
||||
|
||||
/** Random play with cheaper scheduled checks; dead players chat too and players sometimes leave. */
|
||||
function fastPlayout(players: PlayerId[], options: MafiaOptions, 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 = mafia.setup({ players, options, rng: r, now });
|
||||
const roles = s.players.map((p) => p.role).sort().join();
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
invariants(s, roles);
|
||||
if (step % 7 === 0) for (const v of [null, players[(step / 7) % players.length]!]) checkView(s, v);
|
||||
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
|
||||
for (const p of mafia.timeoutPlayers!(s)) {
|
||||
const v = mafia.validate(s, p, mafia.onTimeout(s, p));
|
||||
if (!v.ok) throw new Error(`onTimeout rejected: ${v.reason}`);
|
||||
}
|
||||
if (mafia.result(s)) {
|
||||
for (const v of [...players, null]) checkView(s, v);
|
||||
return { steps: step, finished: true, trace: trace.join(';'), final: s };
|
||||
}
|
||||
const pick = chooser.int(10);
|
||||
let actor: PlayerId;
|
||||
let action: MafiaAction;
|
||||
if (pick === 0 && chooser.int(30) === 0) {
|
||||
const living = s.players.filter((p) => p.alive);
|
||||
actor = living[chooser.int(living.length)]!.id;
|
||||
action = { type: 'leave' };
|
||||
} else {
|
||||
const pool = pick < 2 ? players : mafia.activePlayers(s);
|
||||
actor = pool[chooser.int(pool.length)]!;
|
||||
const legal = mafia.legalActions!(s, actor);
|
||||
action = legal.length ? legal[chooser.int(legal.length)]! : mafia.onTimeout(s, actor);
|
||||
}
|
||||
const v = mafia.validate(s, actor, action);
|
||||
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
|
||||
now += 1000;
|
||||
const out = mafia.apply(s, actor, action, { rng: r, now });
|
||||
// Events are broadcast to everyone: never chat text, night choices or private results.
|
||||
if (out.events.length) {
|
||||
const ej = JSON.stringify(out.events);
|
||||
for (const m of out.state.chat) if (m.channel !== 'day' && ej.includes(JSON.stringify(m.text))) throw new Error('event leaks chat');
|
||||
for (const pr of out.state.privateResults) if (ej.includes(pr.id)) throw new Error('event leaks a private result');
|
||||
}
|
||||
s = out.state;
|
||||
trace.push(`${actor}:${JSON.stringify(action)}`);
|
||||
}
|
||||
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
|
||||
}
|
||||
|
||||
describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
|
||||
test('1,000 games finish; roles conserved, no leaks, onTimeout always legal, deterministic', () => {
|
||||
const wins = { town: 0, mafia: 0 };
|
||||
const causes: Record<string, number> = {};
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const n = 4 + (i % 9);
|
||||
const players = ids(n);
|
||||
const options = presetFor(i, n);
|
||||
const a = fastPlayout(players, options, seedN(i), 4000);
|
||||
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`);
|
||||
wins[a.final.winner!]++;
|
||||
causes[a.final.final!.cause] = (causes[a.final.final!.cause] ?? 0) + 1;
|
||||
const res = mafia.result(a.final)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||
if (i % 50 === 0) {
|
||||
const b = fastPlayout(players, options, seedN(i), 4000);
|
||||
expect(b.trace).toBe(a.trace);
|
||||
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||
}
|
||||
}
|
||||
expect(wins.town).toBeGreaterThan(50);
|
||||
expect(wins.mafia).toBeGreaterThan(50);
|
||||
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
|
||||
}, 60_000);
|
||||
|
||||
test.each([4, 6, 9, 12])('randomPlayout with the engine per-step leak / round-trip checks: %i players', (n) => {
|
||||
const players = ids(n);
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const options = presetFor(i + n, n);
|
||||
const roles = (st: MafiaState) => st.players.map((p) => p.role).sort().join();
|
||||
let initial: string | null = null;
|
||||
const a = randomPlayout(mafia, {
|
||||
players,
|
||||
options,
|
||||
seed: seedN(3000 + i * n),
|
||||
maxSteps: 5000,
|
||||
maxApplyMs: 30,
|
||||
invariant: (st) => invariants(st as MafiaState, (initial ??= roles(st as MafiaState))),
|
||||
});
|
||||
expect(a.finished).toBe(true);
|
||||
if (i === 1) {
|
||||
const b = randomPlayout(mafia, { players, options, seed: seedN(3000 + i * n), maxSteps: 5000, maxApplyMs: 30 });
|
||||
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('onTimeout은 모든 상태에서 합법 행동', () => {
|
||||
for (let seed = 1; seed <= 30; seed++) {
|
||||
const n = 4 + (seed % 9);
|
||||
let s = mafia.setup({ players: ids(n), options: presetFor(seed, n), rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
|
||||
const r = SeededRng.fromSeed(seedN(seed + 500));
|
||||
while (!s.winner) {
|
||||
for (const p of [...s.players.map((x) => x.id)]) expect(mafia.validate(s, p, mafia.onTimeout(s, p)).ok).toBe(true);
|
||||
const ph = s.phase as Exclude<MafiaState['phase'], { kind: 'ended' }>;
|
||||
expect(mafia.validate(s, 'P0', mafia.onTimeout(s, 'P0'), { now: ph.endsAt }).ok).toBe(true);
|
||||
s = mafia.apply(s, 'P0', mafia.onTimeout(s, 'P0'), { rng: r, now: ph.endsAt }).state;
|
||||
}
|
||||
// Only timeouts: nobody acts, so away eliminations end the game.
|
||||
expect(s.final!.cause === 'disconnect' || s.final!.cause === 'night').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('같은 시드 + 같은 행동(채팅 포함) 기록을 다시 적용하면 같은 최종 상태', () => {
|
||||
const players = ids(8);
|
||||
const options = presetFor(2, 8);
|
||||
const rep = randomPlayout(mafia, { players, options, seed: seedN(77), maxSteps: 5000, maxApplyMs: 30 });
|
||||
expect(rep.trace.some((t) => (t.action as MafiaAction).type === 'chat')).toBe(true);
|
||||
const replayRng = SeededRng.fromSeed(seedN(77));
|
||||
let now = 1_700_000_000_000;
|
||||
let s = mafia.setup({ players, options, rng: replayRng, now });
|
||||
for (const { actor, action } of rep.trace) s = mafia.apply(s, actor, action as MafiaAction, { rng: replayRng, now: (now += 1000) }).state;
|
||||
expect(JSON.stringify(s)).toBe(JSON.stringify(rep.finalState));
|
||||
});
|
||||
|
||||
test('팀 판정 도우미', () => {
|
||||
expect(teamOf('mafia')).toBe('mafia');
|
||||
for (const r of ['police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as Role[]) expect(teamOf(r)).toBe('town');
|
||||
});
|
||||
});
|
||||
287
packages/games/src/mafia/types.ts
Normal file
287
packages/games/src/mafia/types.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/** 마피아 options, actions, state and view types (docs/games/mafia.md §2, §8). */
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
const lit = <T extends number>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
const secLabels = (...vals: number[]) => Object.fromEntries(vals.map((v) => [v, `${v}초`]));
|
||||
|
||||
export const ROLES = ['mafia', 'police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as const;
|
||||
export type Role = (typeof ROLES)[number];
|
||||
export type Team = 'town' | 'mafia';
|
||||
export const EXTRA_ROLES = ['soldier', 'politician', 'medium', 'lovers'] as const;
|
||||
export type ExtraRole = (typeof EXTRA_ROLES)[number];
|
||||
|
||||
export const CHANNELS = ['day', 'mafia', 'dead', 'lovers'] as const;
|
||||
/** Channels players write in; `system` lines are the public announcements (`events`). */
|
||||
export type Channel = (typeof CHANNELS)[number];
|
||||
|
||||
export const mafiaOptionsSchema = z.object({
|
||||
mafiaCount: z
|
||||
.union([z.literal('auto'), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
|
||||
.default('auto')
|
||||
.meta({ title: '마피아 수', labels: { auto: '자동(인원에 맞게)', 1: '1명', 2: '2명', 3: '3명', 4: '4명' } }),
|
||||
extraRoles: z
|
||||
.object({
|
||||
soldier: z.boolean().default(false).meta({ title: '군인(마피아 공격을 한 번 버텨요)' }),
|
||||
politician: z.boolean().default(false).meta({ title: '정치인(표가 2표, 투표로 탈락하지 않아요)' }),
|
||||
medium: z.boolean().default(false).meta({ title: '영매(밤에 죽은 사람과 대화해요)' }),
|
||||
lovers: z.boolean().default(false).meta({ title: '연인(2명, 밤에 둘만 대화해요)' }),
|
||||
})
|
||||
.default({ soldier: false, politician: false, medium: false, lovers: false })
|
||||
.meta({ title: '추가 직업(시민 자리를 대신해요)' }),
|
||||
firstPhase: z
|
||||
.enum(['day', 'night'])
|
||||
.default('day')
|
||||
.meta({ title: '시작 페이즈', labels: { day: '인사하는 낮부터(투표 없음)', night: '밤부터' } }),
|
||||
discussionPerAlive: lit(10, 15, 20)
|
||||
.default(15)
|
||||
.meta({ title: '토론 시간(살아 있는 사람 1명당)', labels: secLabels(10, 15, 20) }),
|
||||
discussionMin: lit(30, 45, 60, 90, 120)
|
||||
.default(60)
|
||||
.meta({ title: '토론 시간 최소', labels: secLabels(30, 45, 60, 90, 120) }),
|
||||
discussionMax: lit(60, 120, 180, 240, 300)
|
||||
.default(180)
|
||||
.meta({ title: '토론 시간 최대', labels: secLabels(60, 120, 180, 240, 300) }),
|
||||
timeAdjust: z.boolean().default(true).meta({ title: '하루 한 번 토론 시간 늘리기·줄이기(±15초)' }),
|
||||
voteSeconds: lit(10, 15, 20, 30, 45, 60)
|
||||
.default(20)
|
||||
.meta({ title: '지목 투표 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
|
||||
defenseSeconds: lit(10, 15, 20, 30, 45, 60)
|
||||
.default(20)
|
||||
.meta({ title: '최후의 반론 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
|
||||
confirmSeconds: lit(5, 10, 15, 20, 30)
|
||||
.default(10)
|
||||
.meta({ title: '찬반 투표 시간', labels: secLabels(5, 10, 15, 20, 30) }),
|
||||
nightSeconds: lit(15, 20, 30, 45, 60, 90)
|
||||
.default(30)
|
||||
.meta({ title: '밤 시간', labels: secLabels(15, 20, 30, 45, 60, 90) }),
|
||||
deathReveal: z
|
||||
.enum(['none', 'team', 'role'])
|
||||
.default('team')
|
||||
.meta({ title: '탈락한 사람 공개', labels: { none: '공개 안 함', team: '마피아인지만', role: '직업까지' } }),
|
||||
openVote: z.boolean().default(true).meta({ title: '지목 투표에서 누가 누구를 골랐는지 공개' }),
|
||||
doctorSelfHeal: z.boolean().default(true).meta({ title: '의사가 자기 자신 지키기' }),
|
||||
doctorRepeat: z.boolean().default(true).meta({ title: '의사가 같은 사람을 이틀 연속 지키기' }),
|
||||
mafiaMustKill: z
|
||||
.enum(['noKill', 'random'])
|
||||
.default('noKill')
|
||||
.meta({ title: '마피아가 아무도 안 골랐을 때', labels: { noKill: '아무도 탈락하지 않음', random: '무작위로 1명 탈락' } }),
|
||||
policeRevealRole: z.boolean().default(false).meta({ title: '(하우스 룰) 경찰이 마피아 여부 대신 직업을 알아요' }),
|
||||
deadSeeAll: z.boolean().default(false).meta({ title: '탈락한 사람이 모든 직업과 마피아 채팅 보기' }),
|
||||
kidSafeChat: z.boolean().default(true).meta({ title: '고운 말 필터(욕설·링크·전화번호 막기)' }),
|
||||
revealChatsAtEnd: z.boolean().default(true).meta({ title: '게임이 끝나면 모든 채팅 공개' }),
|
||||
});
|
||||
export type MafiaOptions = z.infer<typeof mafiaOptionsSchema>;
|
||||
|
||||
const pid = z.string().min(1).max(64);
|
||||
export const mafiaActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('chat'), channel: z.enum(CHANNELS), text: z.string().max(400) }),
|
||||
z.object({ type: z.literal('adjustTime'), delta: z.union([z.literal(15), z.literal(-15)]) }),
|
||||
z.object({ type: z.literal('readyEarly') }),
|
||||
z.object({ type: z.literal('vote'), target: pid }),
|
||||
z.object({ type: z.literal('confirm'), choice: z.enum(['yes', 'no']) }),
|
||||
z.object({ type: z.literal('mafiaTarget'), target: pid.nullable() }),
|
||||
z.object({ type: z.literal('investigate'), target: pid }),
|
||||
z.object({ type: z.literal('heal'), target: pid }),
|
||||
z.object({ type: z.literal('mediumPeek'), target: pid }),
|
||||
]);
|
||||
export type MafiaClientAction = z.infer<typeof mafiaActionSchema>;
|
||||
/** Client actions plus the system-only ones (phase timer, leaving) that are not in the schema. */
|
||||
export type MafiaAction = MafiaClientAction | { type: 'phaseTimeout' } | { type: 'leave' };
|
||||
export type NightActionType = 'mafiaTarget' | 'investigate' | 'heal' | 'mediumPeek';
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
export type DiedBy = 'vote' | 'mafia' | 'disconnect';
|
||||
|
||||
export interface PlayerState {
|
||||
id: PlayerId;
|
||||
role: Role;
|
||||
alive: boolean;
|
||||
soldierUsed: boolean;
|
||||
/** Soldier who survived an attack / politician saved from a vote: role is public. */
|
||||
publicRole: boolean;
|
||||
diedDay: number | null;
|
||||
diedBy: DiedBy | null;
|
||||
/** Did anything since the last day ended (away detection, §8.5). */
|
||||
activeToday: boolean;
|
||||
/** Voting days in a row without any action. */
|
||||
idleDays: number;
|
||||
}
|
||||
|
||||
export type Phase =
|
||||
| { kind: 'roleReveal'; startedAt: number; endsAt: number }
|
||||
| { kind: 'greeting'; startedAt: number; endsAt: number; ready: PlayerId[] }
|
||||
| { kind: 'discussion'; startedAt: number; endsAt: number; adjustedBy: PlayerId[]; ready: PlayerId[] }
|
||||
| { kind: 'vote'; startedAt: number; endsAt: number; votes: Record<PlayerId, PlayerId | 'abstain'> }
|
||||
| { kind: 'defense'; startedAt: number; endsAt: number; accused: PlayerId }
|
||||
| { kind: 'confirm'; startedAt: number; endsAt: number; accused: PlayerId; ballots: Record<PlayerId, 'yes' | 'no'> }
|
||||
| {
|
||||
kind: 'night';
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
mafiaTarget: PlayerId | null;
|
||||
mafiaPickedBy: PlayerId | null;
|
||||
investigate: PlayerId | null;
|
||||
heal: PlayerId | null;
|
||||
mediumPeek: PlayerId | null;
|
||||
}
|
||||
| { kind: 'ended' };
|
||||
export type PhaseKind = Phase['kind'];
|
||||
|
||||
export interface ChatMessage {
|
||||
/** Running number inside its channel (a global counter would let others count hidden messages). */
|
||||
id: number;
|
||||
channel: Channel;
|
||||
from: PlayerId;
|
||||
text: string;
|
||||
at: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/** Public announcement (the `system` channel). */
|
||||
export type PublicEvent = { n: number; at: number; day: number } & (
|
||||
| { t: 'phase'; kind: Exclude<PhaseKind, 'ended'> }
|
||||
| { t: 'adjust'; p: PlayerId; delta: 15 | -15 }
|
||||
| { t: 'vote'; tally: Record<PlayerId, number>; abstain: number; votes: Record<PlayerId, PlayerId | 'abstain'> | null; accused: PlayerId | null; tie: boolean }
|
||||
| { t: 'confirm'; accused: PlayerId; yes: number; no: number; outcome: 'executed' | 'spared' | 'politician' }
|
||||
| { t: 'death'; p: PlayerId; by: DiedBy; team: Team | null; role: Role | null }
|
||||
| { t: 'healed' }
|
||||
| { t: 'soldier'; p: PlayerId }
|
||||
| { t: 'quiet' }
|
||||
| { t: 'end'; winner: Team }
|
||||
);
|
||||
export type PublicEventKind = PublicEvent['t'];
|
||||
|
||||
export interface PrivateResult {
|
||||
/** Unique token (also the leak-test secret). */
|
||||
id: string;
|
||||
to: PlayerId;
|
||||
day: number;
|
||||
kind: 'investigate' | 'peek';
|
||||
target: PlayerId;
|
||||
/** Police: 'mafia' / 'notMafia' (or the role with `policeRevealRole`); medium: the role. */
|
||||
result: Role | 'notMafia';
|
||||
}
|
||||
|
||||
export type WinCause = 'vote' | 'night' | 'disconnect';
|
||||
|
||||
export interface MafiaFinal {
|
||||
winner: Team;
|
||||
days: number;
|
||||
cause: WinCause;
|
||||
players: { id: PlayerId; role: Role; team: Team; alive: boolean; diedDay: number | null; diedBy: DiedBy | null; won: boolean }[];
|
||||
/** Winning team (alive first) → losing team (alive first). */
|
||||
ranking: PlayerId[];
|
||||
/** Town player whose day votes sent the most mafia out (optional MVP, §6). */
|
||||
mvp: PlayerId | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface MafiaState {
|
||||
options: MafiaOptions;
|
||||
players: PlayerState[];
|
||||
day: number;
|
||||
phase: Phase;
|
||||
lastHealTarget: PlayerId | null;
|
||||
privateResults: PrivateResult[];
|
||||
/** Every message of every channel; the view filters by channel permission. */
|
||||
chat: ChatMessage[];
|
||||
chatCount: Record<Channel, number>;
|
||||
lastChatAt: Record<PlayerId, number>;
|
||||
events: PublicEvent[];
|
||||
/** Voters (vote phase) for the current accused — for the MVP count. */
|
||||
trialVoters: PlayerId[];
|
||||
mvpVotes: Record<PlayerId, number>;
|
||||
winner: Team | null;
|
||||
/** Applied actions. */
|
||||
seq: number;
|
||||
final: MafiaFinal | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
export interface PlayerView {
|
||||
id: PlayerId;
|
||||
alive: boolean;
|
||||
diedDay: number | null;
|
||||
diedBy: DiedBy | null;
|
||||
/** Only when the viewer may know it (own role, team mates, public reveals, after the game). */
|
||||
role: Role | null;
|
||||
team: Team | null;
|
||||
}
|
||||
|
||||
export interface ChannelView {
|
||||
channel: Channel;
|
||||
/** Writable right now. */
|
||||
canWrite: boolean;
|
||||
/** Korean reason when not writable. */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
export interface ChatView {
|
||||
key: string;
|
||||
channel: Channel;
|
||||
from: PlayerId;
|
||||
text: string;
|
||||
at: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
export interface NightView {
|
||||
/** null = no night action (same fields for every role, so the screen does not give the role away). */
|
||||
action: NightActionType | null;
|
||||
prompt: string;
|
||||
targets: PlayerId[];
|
||||
choice: PlayerId | null;
|
||||
/** Mafia: who picked the shared target. */
|
||||
pickedBy: PlayerId | null;
|
||||
}
|
||||
|
||||
export interface MyView {
|
||||
role: Role;
|
||||
team: Team;
|
||||
alive: boolean;
|
||||
/** Mafia: the other mafia; lovers: the partner. */
|
||||
mates: PlayerId[];
|
||||
vote: PlayerId | 'abstain' | null;
|
||||
voteTargets: PlayerId[];
|
||||
ballot: 'yes' | 'no' | null;
|
||||
canConfirm: boolean;
|
||||
canAdjust: boolean;
|
||||
canReady: boolean;
|
||||
ready: boolean;
|
||||
/** Alive at night: always an object with the same keys. */
|
||||
night: NightView | null;
|
||||
results: PrivateResult[];
|
||||
/** One more idle voting day eliminates me (§8.5). */
|
||||
idleWarning: boolean;
|
||||
}
|
||||
|
||||
export interface MafiaView {
|
||||
me: PlayerId | null;
|
||||
options: MafiaOptions;
|
||||
day: number;
|
||||
phase: {
|
||||
kind: PhaseKind;
|
||||
startedAt: number;
|
||||
endsAt: number | null;
|
||||
accused: PlayerId | null;
|
||||
/** Discussion: who used ±15초 today (public). */
|
||||
adjustedBy: PlayerId[];
|
||||
readyCount: number;
|
||||
readyNeeded: number;
|
||||
};
|
||||
players: PlayerView[];
|
||||
/** Starting role counts (public composition). */
|
||||
setup: Partial<Record<Role, number>>;
|
||||
aliveCount: number;
|
||||
events: PublicEvent[];
|
||||
chat: ChatView[];
|
||||
/** Tabs the viewer may read. */
|
||||
channels: ChannelView[];
|
||||
my: MyView | null;
|
||||
winner: Team | null;
|
||||
final: MafiaFinal | null;
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
|
||||
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
|
||||
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
|
||||
{ id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true },
|
||||
{ id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, minutes: '10~20', level: '쉬움', blurb: '한 사람이 그림을 그리면 나머지가 채팅으로 정답을 맞혀요!', available: true },
|
||||
{ id: 'mafia', nameKo: '마피아', minPlayers: 4, maxPlayers: 12, minutes: '10~25', level: '보통', blurb: '낮에는 토론하고 밤에는 몰래! 숨은 마피아를 찾아요 · 채팅 추리', available: true },
|
||||
];
|
||||
|
||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||
|
||||
@@ -75,6 +75,8 @@ export type ServerMessage =
|
||||
deadline: number | null;
|
||||
cs?: number;
|
||||
}
|
||||
/** Events of streaming actions (e.g. drawing), relayed without a full view (docs/games/catchmind.md §5.5). */
|
||||
| { t: 'stream'; seq: number; events: unknown[]; cs?: number }
|
||||
| { t: 'reject'; cs: number; reason: string }
|
||||
| { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string }
|
||||
| { t: 'emote'; from: string; id: string }
|
||||
|
||||
Reference in New Issue
Block a user