Integrate buzzer functionality into Bun server and update workflows
Reverts Express server setup for the buzzer, migrates all functionality to a Bun server, and updates the workflow to execute the Bun server directly. Replit-Commit-Author: Agent Replit-Commit-Session-Id: f3ac8eb3-f610-4678-ab6e-ebf900098be4 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: c1942b0a-0cf4-4ca9-9b5f-f80287c102f2 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/d4b7863b-f7b2-425c-a9b5-ad7bd1885e9d/f3ac8eb3-f610-4678-ab6e-ebf900098be4/N1qS6qo Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
export interface RoomSettings {
|
||||
mode: "individual" | "teams";
|
||||
numTeams: number;
|
||||
teamNames: string[];
|
||||
playerPickTeam: boolean;
|
||||
showBuzzOrder: boolean;
|
||||
buzzerLockout: boolean;
|
||||
timerSeconds: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
teamIndex: number | null;
|
||||
ws: WebSocket | null;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export interface BuzzerState {
|
||||
roundOpen: boolean;
|
||||
buzzOrder: string[];
|
||||
buzzTimes: Map<string, number>;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
moderatorSecret: string;
|
||||
modWs: WebSocket | null;
|
||||
settings: RoomSettings;
|
||||
players: Map<string, Player>;
|
||||
buzzerState: BuzzerState;
|
||||
locked: boolean;
|
||||
teamLocked: boolean;
|
||||
}
|
||||
|
||||
export const rooms = new Map<string, Room>();
|
||||
export const wsToPlayer = new Map<WebSocket, { roomId: string; playerId: string }>();
|
||||
|
||||
const GREEK = ["Alpha","Beta","Gamma","Delta","Epsilon","Zeta","Eta","Theta","Iota","Kappa","Lambda","Mu","Nu","Xi","Omicron","Pi","Rho","Sigma","Tau","Upsilon","Phi","Chi","Psi","Omega"];
|
||||
export function greekName(i: number): string {
|
||||
const cycle = Math.floor(i / GREEK.length);
|
||||
const name = GREEK[i % GREEK.length];
|
||||
return cycle === 0 ? name : `${name} ${toRoman(cycle + 1)}`;
|
||||
}
|
||||
function toRoman(n: number): string {
|
||||
const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1];
|
||||
const syms = ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"];
|
||||
let out = "";
|
||||
for (let i = 0; i < vals.length; i++) while (n >= vals[i]) { out += syms[i]; n -= vals[i]; }
|
||||
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 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 {
|
||||
id: room.id,
|
||||
settings: room.settings,
|
||||
locked: room.locked,
|
||||
teamLocked: room.teamLocked,
|
||||
modOnline: room.modWs !== null,
|
||||
buzzerState: {
|
||||
roundOpen: room.buzzerState.roundOpen,
|
||||
buzzOrder: room.buzzerState.buzzOrder,
|
||||
},
|
||||
players: Array.from(room.players.values()).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
teamIndex: p.teamIndex,
|
||||
isConnected: p.isConnected,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function broadcast(room: Room, msg: object, exclude?: WebSocket) {
|
||||
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 toMod(room: Room, msg: object) {
|
||||
if (room.modWs) try { room.modWs.send(JSON.stringify(msg)); } catch {}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import {
|
||||
rooms, wsToPlayer,
|
||||
Room, Player,
|
||||
genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
|
||||
} from "./buzzer-rooms";
|
||||
|
||||
const tx = (ws: WebSocket, msg: object) => { try { ws.send(JSON.stringify(msg)); } catch {} };
|
||||
const er = (ws: WebSocket, m: string) => tx(ws, { type: "error", message: m });
|
||||
const modOf = (ws: WebSocket): Room | null => { for (const r of rooms.values()) if (r.modWs === ws) return r; return null; };
|
||||
|
||||
export function handleMessage(ws: WebSocket, raw: string) {
|
||||
let msg: any;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
const type: string = msg?.type ?? "";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
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: WebSocket) {
|
||||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,9 @@
|
||||
import type { Express } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { handleMessage, handleClose } from "./buzzer-ws";
|
||||
|
||||
export async function registerRoutes(
|
||||
httpServer: Server,
|
||||
app: Express
|
||||
_app: Express
|
||||
): Promise<Server> {
|
||||
// Serve buzzer static files
|
||||
const publicDir = path.resolve(process.cwd(), "src/public");
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.send(fs.readFileSync(path.join(publicDir, "index.html"), "utf-8"));
|
||||
});
|
||||
|
||||
app.get("/styles.css", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/css");
|
||||
res.send(fs.readFileSync(path.join(publicDir, "styles.css"), "utf-8"));
|
||||
});
|
||||
|
||||
app.get("/script.js", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/javascript");
|
||||
res.send(fs.readFileSync(path.join(publicDir, "script.js"), "utf-8"));
|
||||
});
|
||||
|
||||
// WebSocket server for buzzer
|
||||
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (data) => {
|
||||
if (typeof data === "string") {
|
||||
handleMessage(ws, data);
|
||||
} else {
|
||||
handleMessage(ws, data.toString());
|
||||
}
|
||||
});
|
||||
ws.on("close", () => handleClose(ws));
|
||||
ws.on("error", () => { try { ws.close(); } catch {} });
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user