import type { ServerWebSocket } from "bun"; import { rooms, wsToPlayer, Room, Player, genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod, } from "./rooms"; type WS = ServerWebSocket; const tx = (ws: WS, msg: object) => { try { ws.send(JSON.stringify(msg)); } catch {} }; const er = (ws: WS, m: string) => tx(ws, { type: "error", message: m }); const modOf = (ws: WS): Room | null => { for (const r of rooms.values()) if (r.modWs === ws) return r; return null; }; export function handleMessage(ws: WS, raw: string) { let msg: any; try { msg = JSON.parse(raw); } catch { return; } const type: string = msg?.type ?? ""; /* ── CREATE ROOM ── */ if (type === "create_room") { const s = msg.settings ?? {}; const numTeams = Math.max(2, Math.min(64, (s.numTeams ?? 2) | 0)); const teamNames: string[] = []; for (let i = 0; i < numTeams; i++) teamNames.push(s.teamNames?.[i] ? sanitize(s.teamNames[i], 32) : greekName(i)); const room: Room = { id: genId(6), moderatorSecret: genId(24), modWs: ws, settings: { mode: s.mode === "teams" ? "teams" : "individual", numTeams, teamNames, playerPickTeam: s.playerPickTeam === true, showBuzzOrder: s.showBuzzOrder !== false, buzzerLockout: s.buzzerLockout !== false, timerSeconds: Math.min(600, Math.max(0, (s.timerSeconds ?? 0) | 0)), }, players: new Map(), buzzerState: freshBuzzer(), locked: false, teamLocked: false, }; rooms.set(room.id, room); tx(ws, { type: "room_created", roomId: room.id, modSecret: room.moderatorSecret, room: publicRoom(room) }); return; } /* ── MOD REJOIN ── */ if (type === "mod_rejoin") { const room = rooms.get(sanitize(msg.roomId, 10)); if (!room || room.moderatorSecret !== sanitize(msg.modSecret, 32)) { er(ws, "Invalid credentials"); return; } room.modWs = ws; tx(ws, { type: "mod_joined", room: publicRoom(room) }); broadcast(room, { type: "room_update", room: publicRoom(room) }, ws); return; } /* ── JOIN ROOM ── */ if (type === "join_room") { if (msg.modSecret) { er(ws, "Unauthorized"); return; } const room = rooms.get(sanitize(msg.roomId ?? "", 10)); if (!room) { er(ws, "Room not found"); return; } if (room.locked) { er(ws, "Room is locked"); return; } const name = sanitize(msg.playerName ?? "Player", 24) || "Player"; const existingId = sanitize(msg.playerId ?? "", 12); let player: Player | undefined; if (existingId && room.players.has(existingId)) { player = room.players.get(existingId)!; player.ws = ws; player.isConnected = true; player.name = name; } else { player = { id: genId(), name, teamIndex: null, ws, isConnected: true }; room.players.set(player.id, player); } wsToPlayer.set(ws, { roomId: room.id, playerId: player.id }); tx(ws, { type: "joined", playerId: player.id, room: publicRoom(room) }); broadcast(room, { type: "room_update", room: publicRoom(room) }, ws); return; } /* ── PICK TEAM ── */ if (type === "pick_team") { const ctx = wsToPlayer.get(ws); if (!ctx) return; const room = rooms.get(ctx.roomId); if (!room) return; if (room.teamLocked || !room.settings.playerPickTeam || room.settings.mode !== "teams") { er(ws, "Team selection not allowed"); return; } const p = room.players.get(ctx.playerId); if (!p) return; const ti = typeof msg.teamIndex === "number" ? msg.teamIndex : null; p.teamIndex = ti !== null ? Math.max(0, Math.min(room.settings.numTeams - 1, ti | 0)) : null; broadcast(room, { type: "room_update", room: publicRoom(room) }); return; } /* ── BUZZ ── */ if (type === "buzz") { const ctx = wsToPlayer.get(ws); if (!ctx) return; const room = rooms.get(ctx.roomId); if (!room) return; const bz = room.buzzerState; if (!bz.roundOpen) { tx(ws, { type: "buzz_rejected", reason: "Round not open" }); return; } if (bz.buzzOrder.includes(ctx.playerId)) { tx(ws, { type: "buzz_rejected", reason: "Already buzzed" }); return; } if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) { tx(ws, { type: "buzz_rejected", reason: "Locked out" }); return; } bz.buzzOrder.push(ctx.playerId); bz.buzzTimes.set(ctx.playerId, Date.now()); const p = room.players.get(ctx.playerId)!; const pubOrder = room.settings.showBuzzOrder ? bz.buzzOrder : [bz.buzzOrder[0]]; broadcast(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: pubOrder, room: publicRoom(room) }); toMod(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: bz.buzzOrder, buzzTimes: Object.fromEntries(bz.buzzTimes), room: publicRoom(room) }); return; } /* ── MOD ONLY ── */ const room = modOf(ws); if (!room) { er(ws, "Not authorized"); return; } switch (type) { case "open_round": room.buzzerState = freshBuzzer(); room.buzzerState.roundOpen = true; broadcast(room, { type: "round_open", room: publicRoom(room) }); break; case "close_round": room.buzzerState.roundOpen = false; broadcast(room, { type: "round_closed", room: publicRoom(room) }); break; case "reset_buzzer": room.buzzerState = freshBuzzer(); broadcast(room, { type: "buzzer_reset", room: publicRoom(room) }); break; case "update_settings": { const s = msg.settings ?? {}; const st = room.settings; if (s.mode === "individual" || s.mode === "teams") st.mode = s.mode; if (typeof s.numTeams === "number") { const newN = Math.max(2, Math.min(64, s.numTeams | 0)); // grow team names with greek names if needed while (st.teamNames.length < newN) st.teamNames.push(greekName(st.teamNames.length)); st.numTeams = newN; } if (Array.isArray(s.teamNames)) st.teamNames = s.teamNames.slice(0, st.numTeams).map((n: unknown) => sanitize(n, 32)); if (typeof s.playerPickTeam === "boolean") st.playerPickTeam = s.playerPickTeam; if (typeof s.showBuzzOrder === "boolean") st.showBuzzOrder = s.showBuzzOrder; if (typeof s.buzzerLockout === "boolean") st.buzzerLockout = s.buzzerLockout; if (typeof s.timerSeconds === "number") st.timerSeconds = Math.min(600, Math.max(0, s.timerSeconds | 0)); broadcast(room, { type: "room_update", room: publicRoom(room) }); tx(ws, { type: "settings_updated", room: publicRoom(room) }); break; } case "assign_team": { const p = room.players.get(sanitize(msg.playerId, 12)); if (p) { p.teamIndex = typeof msg.teamIndex === "number" ? Math.max(0, Math.min(room.settings.numTeams - 1, msg.teamIndex | 0)) : null; broadcast(room, { type: "room_update", room: publicRoom(room) }); } break; } case "kick_player": { const p = room.players.get(sanitize(msg.playerId, 12)); if (p) { if (p.ws) { try { p.ws.send(JSON.stringify({ type: "kicked" })); } catch {} wsToPlayer.delete(p.ws); } room.players.delete(p.id); broadcast(room, { type: "room_update", room: publicRoom(room) }); } break; } case "lock_room": room.locked = msg.locked === true; broadcast(room, { type: "room_update", room: publicRoom(room) }); break; case "lock_teams": room.teamLocked = msg.locked === true; broadcast(room, { type: "room_update", room: publicRoom(room) }); break; case "reset_teams": for (const p of room.players.values()) p.teamIndex = null; broadcast(room, { type: "room_update", room: publicRoom(room) }); break; case "end_room": broadcast(room, { type: "room_ended" }); for (const p of room.players.values()) if (p.ws) wsToPlayer.delete(p.ws); room.modWs = null; rooms.delete(room.id); break; } } export function handleClose(ws: WS) { for (const room of rooms.values()) { if (room.modWs === ws) { room.modWs = null; broadcast(room, { type: "mod_disconnected" }); return; } } const ctx = wsToPlayer.get(ws); if (ctx) { wsToPlayer.delete(ws); const room = rooms.get(ctx.roomId); if (room) { const p = room.players.get(ctx.playerId); if (p) { p.isConnected = false; p.ws = null; } broadcast(room, { type: "room_update", room: publicRoom(room) }); } } }