version 2

This commit is contained in:
KeshavAnandCode
2026-03-20 18:14:27 -05:00
parent 14dd3fcb75
commit 1075b59d2d
5 changed files with 2334 additions and 1227 deletions

View File

@@ -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<unknown>;
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) });
}
}
}
}