diff --git a/src/ws-handler.ts b/src/ws-handler.ts new file mode 100644 index 0000000..75cfb08 --- /dev/null +++ b/src/ws-handler.ts @@ -0,0 +1,289 @@ +import type { ServerWebSocket } from "bun"; +import { + rooms, wsToPlayer, + Room, Player, + generateId, defaultSettings, freshBuzzerState, sanitize, + getPublicRoom, broadcastToRoom, sendToMod, +} from "./rooms"; + +type WS = ServerWebSocket; + +function send(ws: WS, msg: object) { + try { ws.send(JSON.stringify(msg)); } catch {} +} + +function err(ws: WS, message: string) { + send(ws, { type: "error", message }); +} + +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 roomId = generateId(6); + const modSecret = generateId(24); + const room: Room = { + id: roomId, + moderatorSecret: modSecret, + modWs: ws, + settings: defaultSettings(), + players: new Map(), + buzzerState: freshBuzzerState(), + locked: false, + createdAt: Date.now(), + }; + rooms.set(roomId, room); + send(ws, { type: "room_created", roomId, modSecret, room: getPublicRoom(room) }); + return; + } + + // ── MOD REJOIN ──────────────────────────────────────────────────────────── + if (type === "mod_rejoin") { + const { roomId, modSecret } = msg; + const room = rooms.get(sanitize(roomId, 12)); + if (!room || room.moderatorSecret !== sanitize(modSecret, 32)) { + err(ws, "Invalid moderator credentials"); return; + } + room.modWs = ws; + send(ws, { type: "mod_joined", room: getPublicRoom(room) }); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }, ws); + return; + } + + // ── JOIN ROOM (player) ──────────────────────────────────────────────────── + if (type === "join_room") { + const roomId = sanitize(msg.roomId ?? "", 12); + const room = rooms.get(roomId); + if (!room) { err(ws, "Room not found"); return; } + if (room.locked) { err(ws, "Room is locked"); return; } + + const name = sanitize(msg.playerName ?? "Player", 24) || "Player"; + const existingId = sanitize(msg.playerId ?? "", 12); + + // Block anyone trying to present the mod secret as a player + if (msg.modSecret) { err(ws, "Unauthorized"); return; } + + 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 { + const playerId = generateId(); + player = { + id: playerId, name, + teamIndex: null, score: 0, + ws, isConnected: true, + joinedAt: Date.now(), + }; + room.players.set(playerId, player); + } + + wsToPlayer.set(ws, { roomId, playerId: player.id }); + send(ws, { type: "joined", playerId: player.id, room: getPublicRoom(room) }); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }, ws); + return; + } + + // ── BUZZ ────────────────────────────────────────────────────────────────── + if (type === "buzz") { + const ctx = wsToPlayer.get(ws); + if (!ctx) { err(ws, "Not a player"); return; } + const room = rooms.get(ctx.roomId); + if (!room) return; + const bz = room.buzzerState; + + if (!bz.roundOpen) { send(ws, { type: "buzz_rejected", reason: "Round not open" }); return; } + if (bz.buzzOrder.includes(ctx.playerId) && !room.settings.allowRebuzz) { + send(ws, { type: "buzz_rejected", reason: "Already buzzed" }); return; + } + if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) { + send(ws, { type: "buzz_rejected", reason: "Buzzer locked out" }); return; + } + + bz.buzzOrder.push(ctx.playerId); + bz.buzzTimes.set(ctx.playerId, Date.now()); + bz.active = true; + + const player = room.players.get(ctx.playerId)!; + const publicOrder = room.settings.showOrder ? bz.buzzOrder : [bz.buzzOrder[0]]; + + broadcastToRoom(room, { + type: "buzz_event", + playerId: ctx.playerId, + playerName: player.name, + teamIndex: player.teamIndex, + buzzOrder: publicOrder, + room: getPublicRoom(room), + }); + // mod gets full order + timestamps always + sendToMod(room, { + type: "buzz_event", + playerId: ctx.playerId, + playerName: player.name, + teamIndex: player.teamIndex, + buzzOrder: bz.buzzOrder, + buzzTimes: Object.fromEntries(bz.buzzTimes), + room: getPublicRoom(room), + }); + return; + } + + // ── MOD-ONLY FROM HERE ──────────────────────────────────────────────────── + // find room where this ws is the mod + let room: Room | undefined; + for (const r of rooms.values()) { + if (r.modWs === ws) { room = r; break; } + } + if (!room) { err(ws, "Not authorized"); return; } + + switch (type) { + case "open_round": { + room.buzzerState = freshBuzzerState(); + room.buzzerState.roundOpen = true; + broadcastToRoom(room, { type: "round_open", room: getPublicRoom(room) }); + send(ws, { type: "round_open", room: getPublicRoom(room) }); + break; + } + case "close_round": { + room.buzzerState.roundOpen = false; + broadcastToRoom(room, { type: "round_closed", room: getPublicRoom(room) }); + send(ws, { type: "round_closed", room: getPublicRoom(room) }); + break; + } + case "reset_buzzer": { + room.buzzerState = freshBuzzerState(); + broadcastToRoom(room, { type: "buzzer_reset", room: getPublicRoom(room) }); + send(ws, { type: "buzzer_reset", room: getPublicRoom(room) }); + break; + } + case "update_settings": { + const s = msg.settings ?? {}; + const st = room.settings; + if (typeof s.teamMode === "boolean") st.teamMode = s.teamMode; + if (typeof s.numTeams === "number") st.numTeams = Math.min(8, Math.max(2, s.numTeams | 0)); + if (Array.isArray(s.teamNames)) st.teamNames = s.teamNames.slice(0, 8).map((n: unknown) => sanitize(n, 32)); + if (typeof s.allowRebuzz === "boolean") st.allowRebuzz = s.allowRebuzz; + if (typeof s.buzzerLockout === "boolean") st.buzzerLockout = s.buzzerLockout; + if (typeof s.showOrder === "boolean") st.showOrder = s.showOrder; + if (typeof s.pointsCorrect === "number") st.pointsCorrect = Math.min(10000, Math.max(0, s.pointsCorrect | 0)); + if (typeof s.pointsIncorrect === "number") st.pointsIncorrect = Math.min(10000, Math.max(0, s.pointsIncorrect | 0)); + if (typeof s.pointsNeg === "boolean") st.pointsNeg = s.pointsNeg; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "settings_updated", room: getPublicRoom(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(7, msg.teamIndex | 0)) : null; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + } + break; + } + case "adjust_score": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p && typeof msg.delta === "number") { + p.score = Math.max(-99999, Math.min(99999, p.score + (msg.delta | 0))); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + } + break; + } + case "set_score": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p && typeof msg.score === "number") { + p.score = Math.max(-99999, Math.min(99999, msg.score | 0)); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + } + break; + } + case "mark_correct": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p) { + p.score += room.settings.pointsCorrect; + room.buzzerState = freshBuzzerState(); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + } + break; + } + case "mark_incorrect": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p && room.settings.pointsNeg) { + p.score -= room.settings.pointsIncorrect; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(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 {} } + room.players.delete(p.id); + wsToPlayer.delete(p.ws!); + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + } + break; + } + case "lock_room": { + room.locked = msg.locked === true; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + break; + } + case "reset_scores": { + for (const p of room.players.values()) p.score = 0; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + break; + } + case "reset_teams": { + for (const p of room.players.values()) p.teamIndex = null; + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + send(ws, { type: "room_update", room: getPublicRoom(room) }); + break; + } + case "end_room": { + broadcastToRoom(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) { + // mod disconnect? + for (const room of rooms.values()) { + if (room.modWs === ws) { + room.modWs = null; + broadcastToRoom(room, { type: "mod_disconnected" }); + return; + } + } + // player disconnect + 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; } + broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); + sendToMod(room, { type: "room_update", room: getPublicRoom(room) }); + } + } +}