M8: 그림 맞히기 규칙·화면·테스트
- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
(재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -670,10 +698,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 +719,15 @@ export class Room {
|
||||
g.state = next.state;
|
||||
g.rng = rng.state();
|
||||
g.deadlineFloor = null;
|
||||
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 +804,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 +844,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 +871,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 +890,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
|
||||
|
||||
1244
apps/web/src/games/catchmind/index.tsx
Normal file
1244
apps/web/src/games/catchmind/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
'las-vegas': () => import('./las-vegas'),
|
||||
'cockroach-poker': () => import('./cockroach-poker'),
|
||||
'ninety-nine': () => import('./ninety-nine'),
|
||||
catchmind: () => import('./catchmind'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -22,6 +22,7 @@ export const GAME_ICON: Record<string, string> = {
|
||||
'las-vegas': '⚅',
|
||||
'cockroach-poker': '🐞',
|
||||
'ninety-nine': '💯',
|
||||
catchmind: '🖍️',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
e2e/games.e2e.ts
141
e2e/games.e2e.ts
@@ -641,6 +641,119 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 그림 맞히기: 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;
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
@@ -1606,6 +1719,34 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
|
||||
},
|
||||
},
|
||||
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)`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
@@ -50,9 +50,17 @@ export interface GameDefinition<S, A, V, O> {
|
||||
/** 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?: { now: number }): 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 +70,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);
|
||||
});
|
||||
789
packages/games/src/catchmind/index.ts
Normal file
789
packages/games/src/catchmind/index.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
/** 그림 맞히기 (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'],
|
||||
|
||||
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"}
|
||||
]
|
||||
@@ -22,6 +22,7 @@ import { quoridor } from './quoridor';
|
||||
import { lasVegas } from './las-vegas';
|
||||
import { cockroachPoker } from './cockroach-poker';
|
||||
import { ninetyNine } from './ninety-nine';
|
||||
import { catchmind } from './catchmind';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[lasVegas.id]: lasVegas,
|
||||
[cockroachPoker.id]: cockroachPoker,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
[catchmind.id]: catchmind,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
|
||||
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
|
||||
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
|
||||
{ id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, minutes: '10~20', 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