- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
/** WebSocket protocol, docs/03-realtime-protocol.md. */
|
|
import { z } from 'zod';
|
|
|
|
export const PROTOCOL = 1;
|
|
export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
|
|
export const CHAT_MAX = 200;
|
|
|
|
const code = z.string().regex(/^\d{6}$/);
|
|
const userId = z.string().min(1).max(64);
|
|
|
|
export const ClientMessage = z.discriminatedUnion('t', [
|
|
z.object({ t: z.literal('join'), code, as: z.enum(['player', 'spectator']).default('player') }),
|
|
z.object({ t: z.literal('leave') }),
|
|
z.object({ t: z.literal('seat'), seat: z.number().int().min(0).max(31).optional() }),
|
|
z.object({ t: z.literal('unseat') }),
|
|
z.object({ t: z.literal('ready'), ready: z.boolean() }),
|
|
z.object({
|
|
t: z.literal('config'),
|
|
gameId: z.string().max(40).optional(),
|
|
options: z.unknown().optional(),
|
|
maxPlayers: z.number().int().min(1).max(32).optional(),
|
|
visibility: z.enum(['private', 'public']).optional(),
|
|
turnSeconds: z.number().int().min(5).max(600).nullable().optional(),
|
|
allowSpectators: z.boolean().optional(),
|
|
chatEnabled: z.boolean().optional(),
|
|
chatFilter: z.boolean().optional(),
|
|
}),
|
|
z.object({ t: z.literal('start') }),
|
|
z.object({ t: z.literal('kick'), userId }),
|
|
z.object({ t: z.literal('host'), userId }),
|
|
z.object({ t: z.literal('act'), cs: z.number().int().min(1), a: z.unknown() }),
|
|
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
|
|
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
|
|
z.object({ t: z.literal('rematch') }),
|
|
z.object({ t: z.literal('sync') }),
|
|
z.object({ t: z.literal('ping'), ts: z.number() }),
|
|
]);
|
|
export type ClientMessage = z.infer<typeof ClientMessage>;
|
|
|
|
export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const;
|
|
export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = {
|
|
thumbs: '👍',
|
|
laugh: '😂',
|
|
wow: '😮',
|
|
sad: '😢',
|
|
clap: '👏',
|
|
nice: '잘했어요!',
|
|
hurry: '조금만 서둘러 주세요',
|
|
gg: '좋은 게임이었어요',
|
|
};
|
|
|
|
export interface PublicUser {
|
|
id: string;
|
|
nickname: string;
|
|
avatar: string | null;
|
|
kind: 'guest' | 'member';
|
|
}
|
|
|
|
export interface SeatView {
|
|
user: PublicUser;
|
|
ready: boolean;
|
|
connected: boolean;
|
|
away: boolean;
|
|
}
|
|
|
|
export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken';
|
|
|
|
export interface RoomView {
|
|
code: string;
|
|
hostId: string;
|
|
gameId: string;
|
|
options: unknown;
|
|
maxPlayers: number;
|
|
minPlayers: number;
|
|
visibility: 'private' | 'public';
|
|
turnSeconds: number | null;
|
|
allowSpectators: boolean;
|
|
chatEnabled: boolean;
|
|
chatFilter: boolean;
|
|
seats: (SeatView | null)[];
|
|
spectators: PublicUser[];
|
|
status: RoomStatus;
|
|
gameNo: number;
|
|
rematchVotes: string[];
|
|
sessionStats: Record<string, { played: number; wins: number }>;
|
|
seedHash: string | null;
|
|
/** Seed revealed after the game for fairness check. */
|
|
lastSeed: string | null;
|
|
}
|
|
|
|
export interface GameResultView {
|
|
ranking: string[][];
|
|
scores?: Record<string, number>;
|
|
summary: string;
|
|
reason: string;
|
|
}
|
|
|
|
export type ServerMessage =
|
|
| { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null }
|
|
| { t: 'room'; seq: number; room: RoomView }
|
|
| {
|
|
t: 'state';
|
|
seq: number;
|
|
view: unknown;
|
|
events: unknown[];
|
|
active: string[];
|
|
deadline: number | null;
|
|
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 }
|
|
| { t: 'result'; seq: number; result: GameResultView }
|
|
| { t: 'notice'; code: string; message: string }
|
|
| { t: 'error'; code: string; message: string }
|
|
| { t: 'pong'; ts: number; serverTime: number }
|
|
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
|