/** 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; 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; seedHash: string | null; /** Seed revealed after the game for fairness check. */ lastSeed: string | null; } export interface GameResultView { ranking: string[][]; scores?: Record; 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' };