diff --git a/bun.lock b/bun.lock index f4cddbb..df860b1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,30 +5,11 @@ "": { "name": "buzzer", "dependencies": { - "@hono/node-server": "^1.19.11", - "hono": "^4.12.8", - }, - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", + "hono": "^4.7.7", }, }, }, "packages": { - "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], - - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], - - "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], - "hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/src/public/index.html b/src/public/index.html index f69a957..fc58a38 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1,953 +1,2168 @@ + - - -BUZZER//SYS - - - -
-
-
- -
// QUIZ CONTROL SYS
-
-
-
-
-
- OFFLINE -
-
-
- - -
-
-

BUZZER

-

REAL-TIME QUIZ CONTROL SYSTEM

-
-
-
-

// HOST SESSION

-

Create a room and act as moderator. Control the buzzer, manage teams, and track scores.

- -
-
-

// JOIN SESSION

-

Join an existing room with a room code. Enter your name and start buzzing.

-
- - -
-
- - -
- -
-
-
- -
-
- - -
-
- - - - -
- -
- -
-
-
BUZZER
-
PLAYERS
-
SETTINGS
-
- - -
- -
-

// BUZZ ORDER

-
No buzzes yet — open a round to begin.
-
-
-
- - - - - - -
-
- - -
-
-
ROOM CODE
-
──────
-
-
- -
- -
WAITING FOR ROUND TO OPEN
-
- -
-
-

// SCOREBOARD

-
No players yet.
-
-
-

// BUZZ FEED

-
-
-
-
-
- - - - - - - - -
- - + function updateLiveTeamName(idx, val) { + if (!room) return; + const names = [...room.settings.teamNames]; + names[idx] = val.trim() || 'Team ' + (idx + 1); + ws_send({ type: 'update_settings', settings: { teamNames: names } }); + } + + function pushSetting(key, val) { + ws_send({ type: 'update_settings', settings: { [key]: val } }); + } + + // ══════════════════════════════════════════════════════ + // MOD TIMER + // ══════════════════════════════════════════════════════ + function modTimerLoad() { + modTimerRemaining = parseInt(document.getElementById('mod-timer-set').value) || 30; + modTimerRunning = false; + clearInterval(modTimerInterval); + document.getElementById('btn-timer-startstop').textContent = 'START'; + renderModTimerDisplay(); + } + function modTimerReset() { + clearInterval(modTimerInterval); + modTimerRunning = false; + modTimerRemaining = parseInt(document.getElementById('mod-timer-set').value) || 30; + document.getElementById('btn-timer-startstop').textContent = 'START'; + renderModTimerDisplay(); + // also reset player timers via broadcast happens via room_update on open_round + } + function modTimerToggle() { + if (modTimerRunning) { + clearInterval(modTimerInterval); modTimerRunning = false; + document.getElementById('btn-timer-startstop').textContent = 'START'; + } else { + if (modTimerRemaining <= 0) modTimerLoad(); + modTimerRunning = true; + document.getElementById('btn-timer-startstop').textContent = 'PAUSE'; + modTimerInterval = setInterval(() => { + modTimerRemaining--; + renderModTimerDisplay(); + broadcastTimer(modTimerRemaining); + if (modTimerRemaining <= 0) { + clearInterval(modTimerInterval); modTimerRunning = false; + document.getElementById('btn-timer-startstop').textContent = 'START'; + ws_send({ type: 'close_round' }); + toast('TIME UP — round closed', 'warn'); + } + }, 1000); + } + } + function renderModTimerDisplay() { + const el = document.getElementById('mod-timer-disp'); + const s = modTimerRemaining; + el.textContent = Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60); + el.className = 'timer-display' + (s <= 5 ? 'danger' : s <= 10 ? 'warn' : ''); + } + // We broadcast timer state via a simple ws message for player display + function broadcastTimer(s) { + ws_send({ type: '__timer__', seconds: s }); // mod echoes to server — but we handle client-side only for now + } + + // ══════════════════════════════════════════════════════ + // PLAYER RENDER + // ══════════════════════════════════════════════════════ + function renderPlayer() { + if (!room) return; + document.getElementById('p-code').textContent = room.id; + const me = room.players.find(p => p.id === myId); + document.getElementById('p-namelbl').textContent = me?.name ?? ''; + renderTeamPicker(); + renderPlayerBuzzer(); + renderRoster(); + } + + function renderTeamPicker() { + if (!room) return; + const s = room.settings; + const picker = document.getElementById('p-team-picker'); + if (s.mode !== 'teams' || !s.playerPickTeam || room.teamLocked) { + picker.style.display = 'none'; return; + } + picker.style.display = 'block'; + const me = room.players.find(p => p.id === myId); + const grid = document.getElementById('p-team-grid'); + grid.innerHTML = ''; + for (let i = 0; i < s.numTeams; i++) { + const members = room.players.filter(p => p.teamIndex === i); + const color = COLORS[i]; + const isMine = me?.teamIndex === i; + const btn = document.createElement('button'); + btn.className = 'team-btn' + (isMine ? ' mine' : ''); + btn.style.borderColor = isMine ? color : 'var(--border)'; + btn.style.color = isMine ? color : 'var(--text)'; + btn.innerHTML = `
${esc(s.teamNames[i] ?? 'Team ' + (i + 1))}
${members.length} player${members.length !== 1 ? 's' : ''}
`; + btn.onclick = () => ws_send({ type: 'pick_team', teamIndex: i }); + grid.appendChild(btn); + } + } + + function renderPlayerBuzzer() { + if (!room) return; + const btn = document.getElementById('buzz-btn'); + const sts = document.getElementById('buzz-status'); + const bz = room.buzzerState; + const already = bz.buzzOrder.includes(myId); + const isFirst = bz.buzzOrder[0] === myId; + if (!bz.roundOpen) { + btn.className = 's-closed'; btn.disabled = true; sts.textContent = 'WAITING FOR ROUND'; + } else if (isFirst) { + btn.className = 's-first'; btn.disabled = false; sts.textContent = '⚡ YOU BUZZED FIRST!'; + } else if (already) { + const pos = bz.buzzOrder.indexOf(myId) + 1; + btn.className = 's-buzzed'; btn.disabled = true; sts.textContent = 'BUZZED — #' + pos + ' IN ORDER'; + } else if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) { + btn.className = 's-locked'; btn.disabled = true; sts.textContent = 'BUZZER LOCKED OUT'; + } else { + btn.className = 's-open'; btn.disabled = false; sts.textContent = 'ROUND OPEN — BUZZ!'; + } + } + + function renderRoster() { + if (!room) return; + const el = document.getElementById('p-roster'); + if (room.players.length === 0) { el.innerHTML = '
No players yet.
'; return; } + el.innerHTML = ''; + room.players.forEach(p => { + const isMe = p.id === myId; + const color = p.teamIndex !== null ? COLORS[p.teamIndex] : 'var(--g)'; + const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : ''; + const row = document.createElement('div'); + row.className = 'roster-row' + (isMe ? ' roster-me' : ''); + row.innerHTML = ` +
+
${esc(p.name)}${isMe ? ' (YOU)' : ''}
+ ${teamName ? `
${esc(teamName)}
` : ''} + `; + el.appendChild(row); + }); + } + + function addFeed(evt) { + const feed = document.getElementById('p-feed'); + const isFirst = evt.buzzOrder?.[0] === evt.playerId; + const color = evt.teamIndex !== null ? COLORS[evt.teamIndex] : 'var(--g)'; + const teamStr = (room?.settings.mode === 'teams' && evt.teamIndex !== null) ? ` [${esc(room.settings.teamNames[evt.teamIndex] ?? '')}]` : ''; + const div = document.createElement('div'); + div.className = 'feed-entry' + (isFirst ? ' first' : ''); + div.style.borderColor = isFirst ? 'var(--yellow)' : color; + div.innerHTML = `${esc(evt.playerName)}${teamStr} buzzed${isFirst ? ' — FIRST!' : ''}`; + feed.prepend(div); + while (feed.children.length > 20) feed.removeChild(feed.lastChild); + } + + function doBuzz() { ws_send({ type: 'buzz' }); } + + // ══════════════════════════════════════════════════════ + // TABS + // ══════════════════════════════════════════════════════ + function setTab(name, el) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('on')); + el.classList.add('on'); + ['buzzer', 'players', 'teams', 'settings'].forEach(t => { + const te = document.getElementById('tab-' + t); + if (te) te.style.display = t === name ? 'block' : 'none'; + }); + if (name === 'players') renderModPlayerList(); + if (name === 'teams') renderModTeams(); + } + + // ══════════════════════════════════════════════════════ + // MODALS / TOAST / UTIL + // ══════════════════════════════════════════════════════ + function openModal(id) { document.getElementById(id).classList.add('on'); } + function closeModal(id) { document.getElementById(id).classList.remove('on'); } + function copyCode() { + if (!room) return; + navigator.clipboard.writeText(room.id).then(() => toast('Code copied', 'ok')); + } + function toast(msg, type = '') { + const el = document.createElement('div'); + el.className = 'toast ' + type; el.textContent = msg; + document.getElementById('toasts').appendChild(el); + setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 2700); + } + function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ══════════════════════════════════════════════════════ + // KEYBOARD — SPACE TO BUZZ + // ══════════════════════════════════════════════════════ + document.addEventListener('keydown', e => { + if (role !== 'player') return; + if (e.code === 'Space' || e.key === ' ') { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + const btn = document.getElementById('buzz-btn'); + if (!btn.disabled) { btn.click(); btn.style.transform = 'scale(.93)'; setTimeout(() => btn.style.transform = '', 120); } + } + }); + + // close modals on backdrop click + document.querySelectorAll('.modal-bg').forEach(bg => { + bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('on'); }); + }); + + // ══════════════════════════════════════════════════════ + // INIT + // ══════════════════════════════════════════════════════ + window.addEventListener('DOMContentLoaded', () => { + const m = loadMod(); + if (m) { document.getElementById('rejoin-bar').style.display = 'block'; document.getElementById('m-rejoin-code').textContent = m.id; } + showScr('s-land'); + connect(null); + // init seg-mode display + segSelect('seg-mode', document.querySelector('#seg-mode .seg-opt.active')); + }); + - + + \ No newline at end of file diff --git a/src/rooms.ts b/src/rooms.ts index a648464..cab977b 100644 --- a/src/rooms.ts +++ b/src/rooms.ts @@ -1,28 +1,25 @@ +import type { ServerWebSocket } from "bun"; + export interface RoomSettings { - teamMode: boolean; + mode: "individual" | "teams"; numTeams: number; teamNames: string[]; - allowRebuzz: boolean; - buzzerLockout: boolean; - showOrder: boolean; - pointsCorrect: number; - pointsIncorrect: number; - pointsNeg: boolean; + playerPickTeam: boolean; // players can self-assign teams + showBuzzOrder: boolean; // show full order vs first only + buzzerLockout: boolean; // lock after first buzz + timerSeconds: number; // 0 = no timer } export interface Player { id: string; name: string; teamIndex: number | null; - score: number; ws: ServerWebSocket | null; isConnected: boolean; - joinedAt: number; } export interface BuzzerState { roundOpen: boolean; - active: boolean; buzzOrder: string[]; buzzTimes: Map; } @@ -34,85 +31,55 @@ export interface Room { settings: RoomSettings; players: Map; buzzerState: BuzzerState; - locked: boolean; - createdAt: number; + locked: boolean; // room locked to new joins + teamLocked: boolean; // team selection locked } -import type { ServerWebSocket } from "bun"; - -export const rooms = new Map(); +export const rooms = new Map(); export const wsToPlayer = new Map, { roomId: string; playerId: string }>(); -export function generateId(len = 8): string { - const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - let out = ""; - for (let i = 0; i < len; i++) out += chars[Math.floor(Math.random() * chars.length)]; - return out; +export function genId(len = 8): string { + const c = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let s = ""; + for (let i = 0; i < len; i++) s += c[Math.floor(Math.random() * c.length)]; + return s; } -export function defaultSettings(): RoomSettings { +export function sanitize(v: unknown, max = 32): string { + return String(v ?? "").trim().replace(/[<>"'`]/g, "").slice(0, max); +} + +export function freshBuzzer(): BuzzerState { + return { roundOpen: false, buzzOrder: [], buzzTimes: new Map() }; +} + +export function publicRoom(room: Room) { return { - teamMode: false, - numTeams: 2, - teamNames: ["ALPHA", "BETA", "GAMMA", "DELTA", "EPSILON", "ZETA", "ETA", "THETA"], - allowRebuzz: false, - buzzerLockout: true, - showOrder: true, - pointsCorrect: 100, - pointsIncorrect: 0, - pointsNeg: false, - }; -} - -export function freshBuzzerState(): BuzzerState { - return { roundOpen: false, active: false, buzzOrder: [], buzzTimes: new Map() }; -} - -export function sanitize(str: unknown, maxLen = 32): string { - return String(str ?? "") - .trim() - .replace(/[<>"'`]/g, "") - .slice(0, maxLen); -} - -export function getPublicRoom(room: Room) { - return { - id: room.id, - settings: room.settings, - locked: room.locked, - modOnline: room.modWs !== null, + id: room.id, + settings: room.settings, + locked: room.locked, + teamLocked: room.teamLocked, + modOnline: room.modWs !== null, buzzerState: { roundOpen: room.buzzerState.roundOpen, - active: room.buzzerState.active, buzzOrder: room.buzzerState.buzzOrder, }, - players: Array.from(room.players.values()).map((p) => ({ - id: p.id, - name: p.name, - teamIndex: p.teamIndex, - score: p.score, + players: Array.from(room.players.values()).map(p => ({ + id: p.id, + name: p.name, + teamIndex: p.teamIndex, isConnected: p.isConnected, })), }; } -export function broadcastToRoom( - room: Room, - msg: object, - exclude?: ServerWebSocket -) { - const data = JSON.stringify(msg); - for (const p of room.players.values()) { - if (p.ws && p.isConnected && p.ws !== exclude) { - try { p.ws.send(data); } catch {} - } - } - if (room.modWs && room.modWs !== exclude) { - try { room.modWs.send(data); } catch {} - } +export function broadcast(room: Room, msg: object, exclude?: ServerWebSocket) { + const d = JSON.stringify(msg); + for (const p of room.players.values()) + if (p.ws && p.isConnected && p.ws !== exclude) try { p.ws.send(d); } catch {} + if (room.modWs && room.modWs !== exclude) try { room.modWs.send(d); } catch {} } -export function sendToMod(room: Room, msg: object) { - if (!room.modWs) return; - try { room.modWs.send(JSON.stringify(msg)); } catch {} -} +export function toMod(room: Room, msg: object) { + if (room.modWs) try { room.modWs.send(JSON.stringify(msg)); } catch {} +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index f4f3600..4fa2c1e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,38 +6,19 @@ const HTML = readFileSync("./src/public/index.html", "utf-8"); const server = Bun.serve({ port: 3000, fetch(req, server) { - const url = new URL(req.url); - - if (url.pathname === "/ws") { - const ok = server.upgrade(req); - if (!ok) return new Response("WebSocket upgrade failed", { status: 400 }); + if (new URL(req.url).pathname === "/ws") { + if (!server.upgrade(req)) return new Response("WS upgrade failed", { status: 400 }); return undefined as any; } - - return new Response(HTML, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); }, websocket: { perMessageDeflate: true, - maxPayloadLength: 64 * 1024, - message(ws, msg) { - if (typeof msg !== "string") return; - handleMessage(ws, msg); - }, - close(ws) { - handleClose(ws); - }, + maxPayloadLength: 32 * 1024, + message(ws, msg) { if (typeof msg === "string") handleMessage(ws, msg); }, + close(ws) { handleClose(ws); }, open(_ws) {}, }, }); -console.log(`\x1b[32m ██████╗ ██╗ ██╗███████╗███████╗███████╗██████╗ \x1b[0m`); -console.log(`\x1b[32m ██╔══██╗██║ ██║╚══███╔╝╚══███╔╝██╔════╝██╔══██╗\x1b[0m`); -console.log(`\x1b[32m ██████╔╝██║ ██║ ███╔╝ ███╔╝ █████╗ ██████╔╝\x1b[0m`); -console.log(`\x1b[32m ██╔══██╗██║ ██║ ███╔╝ ███╔╝ ██╔══╝ ██╔══██╗\x1b[0m`); -console.log(`\x1b[32m ██████╔╝╚██████╔╝███████╗███████╗███████╗██║ ██║\x1b[0m`); -console.log(`\x1b[32m ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝\x1b[0m`); -console.log(`\x1b[2m ─────────────────────────────────────────────────\x1b[0m`); -console.log(`\x1b[32m ONLINE\x1b[0m → \x1b[36mhttp://localhost:${server.port}\x1b[0m`); -console.log(`\x1b[2m ─────────────────────────────────────────────────\x1b[0m\n`); +console.log(`\x1b[32m[BUZZER]\x1b[0m → \x1b[36mhttp://localhost:${server.port}\x1b[0m`); \ No newline at end of file diff --git a/src/ws-handler.ts b/src/ws-handler.ts index 75cfb08..abe04f4 100644 --- a/src/ws-handler.ts +++ b/src/ws-handler.ts @@ -2,275 +2,239 @@ import type { ServerWebSocket } from "bun"; import { rooms, wsToPlayer, Room, Player, - generateId, defaultSettings, freshBuzzerState, sanitize, - getPublicRoom, broadcastToRoom, sendToMod, + genId, sanitize, freshBuzzer, publicRoom, broadcast, toMod, } 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 }); -} +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 ────────────────────────────────────────────────────────── + /* ── CREATE ROOM ── */ if (type === "create_room") { - const roomId = generateId(6); - const modSecret = generateId(24); + const s = msg.settings ?? {}; + const numTeams = Math.min(8, Math.max(2, (s.numTeams ?? 2) | 0)); + const defaultNames = ["ALPHA","BETA","GAMMA","DELTA","EPSILON","ZETA","ETA","THETA"]; + const teamNames: string[] = []; + for (let i = 0; i < numTeams; i++) + teamNames.push(sanitize(s.teamNames?.[i] ?? defaultNames[i], 32)); + const room: Room = { - id: roomId, - moderatorSecret: modSecret, + id: genId(6), + moderatorSecret: genId(24), modWs: ws, - settings: defaultSettings(), - players: new Map(), - buzzerState: freshBuzzerState(), - locked: false, - createdAt: Date.now(), + settings: { + mode: s.mode === "teams" ? "teams" : "individual", + numTeams, + teamNames, + playerPickTeam: s.playerPickTeam === true, + showBuzzOrder: s.showBuzzOrder !== false, + buzzerLockout: s.buzzerLockout !== false, + timerSeconds: Math.min(300, Math.max(0, (s.timerSeconds ?? 0) | 0)), + }, + players: new Map(), + buzzerState: freshBuzzer(), + locked: false, + teamLocked: false, }; - rooms.set(roomId, room); - send(ws, { type: "room_created", roomId, modSecret, room: getPublicRoom(room) }); + rooms.set(room.id, room); + tx(ws, { type: "room_created", roomId: room.id, modSecret: room.moderatorSecret, room: publicRoom(room) }); return; } - // ── MOD REJOIN ──────────────────────────────────────────────────────────── + /* ── 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; + const room = rooms.get(sanitize(msg.roomId, 10)); + if (!room || room.moderatorSecret !== sanitize(msg.modSecret, 32)) { + er(ws, "Invalid credentials"); return; } room.modWs = ws; - send(ws, { type: "mod_joined", room: getPublicRoom(room) }); - broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }, ws); + tx(ws, { type: "mod_joined", room: publicRoom(room) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }, ws); return; } - // ── JOIN ROOM (player) ──────────────────────────────────────────────────── + /* ── JOIN ROOM ── */ 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; } + 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); - - // 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; + 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); + player = { id: genId(), name, teamIndex: null, ws, isConnected: true }; + room.players.set(player.id, 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); + 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; } - // ── BUZZ ────────────────────────────────────────────────────────────────── + /* ── PICK TEAM (player) ── */ + 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) { err(ws, "Not a player"); return; } + if (!ctx) 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 (!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) { - send(ws, { type: "buzz_rejected", reason: "Buzzer locked out" }); return; + tx(ws, { type: "buzz_rejected", reason: "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]]; + const p = room.players.get(ctx.playerId)!; + const pubOrder = room.settings.showBuzzOrder ? bz.buzzOrder : [bz.buzzOrder[0]]; - broadcastToRoom(room, { + broadcast(room, { type: "buzz_event", - playerId: ctx.playerId, - playerName: player.name, - teamIndex: player.teamIndex, - buzzOrder: publicOrder, - room: getPublicRoom(room), + playerId: ctx.playerId, + playerName: p.name, + teamIndex: p.teamIndex, + buzzOrder: pubOrder, + room: publicRoom(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), + 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 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; } + /* ── MOD ONLY ── */ + const room = modOf(ws); + if (!room) { er(ws, "Not authorized"); return; } switch (type) { - case "open_round": { - room.buzzerState = freshBuzzerState(); + case "open_round": + room.buzzerState = freshBuzzer(); room.buzzerState.roundOpen = true; - broadcastToRoom(room, { type: "round_open", room: getPublicRoom(room) }); - send(ws, { type: "round_open", room: getPublicRoom(room) }); + broadcast(room, { type: "round_open", room: publicRoom(room) }); break; - } - case "close_round": { + + case "close_round": room.buzzerState.roundOpen = false; - broadcastToRoom(room, { type: "round_closed", room: getPublicRoom(room) }); - send(ws, { type: "round_closed", room: getPublicRoom(room) }); + broadcast(room, { type: "round_closed", room: publicRoom(room) }); break; - } - case "reset_buzzer": { - room.buzzerState = freshBuzzerState(); - broadcastToRoom(room, { type: "buzzer_reset", room: getPublicRoom(room) }); - send(ws, { type: "buzzer_reset", room: getPublicRoom(room) }); + + 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 (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) }); + if (s.mode === "individual" || s.mode === "teams") st.mode = s.mode; + 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.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(300, 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(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) }); + p.teamIndex = typeof msg.teamIndex === "number" + ? Math.max(0, Math.min(7, 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 {} } + if (p.ws) { + try { p.ws.send(JSON.stringify({ type: "kicked" })); } catch {} + wsToPlayer.delete(p.ws); + } 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) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }); } break; } - case "lock_room": { + + case "lock_room": room.locked = msg.locked === true; - broadcastToRoom(room, { type: "room_update", room: getPublicRoom(room) }); - send(ws, { type: "room_update", room: getPublicRoom(room) }); + broadcast(room, { type: "room_update", room: publicRoom(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) }); + + case "lock_teams": + room.teamLocked = msg.locked === true; + broadcast(room, { type: "room_update", room: publicRoom(room) }); break; - } - case "reset_teams": { + + 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) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }); break; - } - case "end_room": { - broadcastToRoom(room, { type: "room_ended" }); - for (const p of room.players.values()) { - if (p.ws) wsToPlayer.delete(p.ws); - } + + 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) { - // mod disconnect? + // mod disconnect for (const room of rooms.values()) { if (room.modWs === ws) { room.modWs = null; - broadcastToRoom(room, { type: "mod_disconnected" }); + broadcast(room, { type: "mod_disconnected" }); return; } } @@ -282,8 +246,7 @@ export function handleClose(ws: WS) { 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) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }); } } -} +} \ No newline at end of file