Files
joke-app/packages/shared/src/protocol.ts
EJClaw 6ee8da18f1 M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- 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>
2026-10-04 04:18:29 +09:00

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' };