vibe coding is the best

This commit is contained in:
KeshavAnandCode
2026-03-20 18:22:15 -05:00
parent 1075b59d2d
commit 8854e13671
6 changed files with 1988 additions and 1960 deletions

File diff suppressed because it is too large Load Diff

625
src/public/script.js Normal file
View File

@@ -0,0 +1,625 @@
// ══════════════════════════════════════════════════════
// GREEK ALPHABET (mirrors server for display)
// ══════════════════════════════════════════════════════
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"];
function toRoman(n) {
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;
}
function greekName(i) {
const cycle = Math.floor(i / GREEK.length);
return cycle === 0 ? GREEK[i % GREEK.length] : `${GREEK[i % GREEK.length]} ${toRoman(cycle + 1)}`;
}
// ══════════════════════════════════════════════════════
// COLORS — HSL wheel, infinite unique colors
// ══════════════════════════════════════════════════════
function teamColor(i) {
const hue = (i * 137.508) % 360; // golden angle spacing
return `hsl(${hue},90%,58%)`;
}
// ══════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════
let ws = null, role = null, room = null, myId = null;
let reconnTimer = null, reconnAttempts = 0;
let modTimerInterval = null, modTimerRemaining = 0, modTimerRunning = false;
let playerTimerRemaining = 0, playerTimerInterval = null;
// ══════════════════════════════════════════════════════
// STORAGE
// ══════════════════════════════════════════════════════
const saveMod = (id, s) => localStorage.setItem('mod', JSON.stringify({ id, s }));
const loadMod = () => { try { return JSON.parse(localStorage.getItem('mod') || 'null'); } catch { return null; } };
const clearMod = () => { localStorage.removeItem('mod'); document.getElementById('rejoin-bar').style.display = 'none'; };
const savePlay = (rid, pid, name) => localStorage.setItem('play', JSON.stringify({ rid, pid, name }));
const loadPlay = () => { try { return JSON.parse(localStorage.getItem('play') || 'null'); } catch { return null; } };
// ══════════════════════════════════════════════════════
// WEBSOCKET
// ══════════════════════════════════════════════════════
function connect(onOpen) {
if (ws && ws.readyState === WebSocket.OPEN) { onOpen?.(); return; }
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { setConn(true); reconnAttempts = 0; onOpen?.(); };
ws.onmessage = e => { try { handle(JSON.parse(e.data)); } catch { } };
ws.onclose = () => { setConn(false); schedReconn(); };
ws.onerror = () => ws.close();
}
function ws_send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
function schedReconn() {
clearTimeout(reconnTimer);
const d = Math.min(8000, 500 * Math.pow(1.5, reconnAttempts++));
reconnTimer = setTimeout(() => {
if (role === 'mod') {
const m = loadMod(); if (m) connect(() => ws_send({ type: 'mod_rejoin', roomId: m.id, modSecret: m.s }));
} else if (role === 'player' && myId) {
const p = loadPlay(); if (p) connect(() => ws_send({ type: 'join_room', roomId: p.rid, playerName: p.name, playerId: p.pid }));
} else connect(null);
}, d);
}
// ══════════════════════════════════════════════════════
// MESSAGES
// ══════════════════════════════════════════════════════
function handle(msg) {
switch (msg.type) {
case 'room_created':
saveMod(msg.roomId, msg.modSecret);
room = msg.room; role = 'mod';
showScr('s-mod'); renderMod(); renderModSettings();
toast('Room ' + msg.roomId + ' created', 'ok');
break;
case 'mod_joined':
room = msg.room; role = 'mod';
showScr('s-mod'); renderMod(); renderModSettings();
toast('Rejoined as moderator', 'ok');
break;
case 'joined':
myId = msg.playerId; room = msg.room; role = 'player';
savePlay(room.id, myId, document.getElementById('ji-name').value || loadPlay()?.name || '');
showScr('s-player'); renderPlayer();
break;
case 'room_update':
room = msg.room;
if (role === 'mod') renderMod(); else renderPlayer();
break;
case 'settings_updated':
room = msg.room;
if (role === 'mod') { renderMod(); renderModSettings(); } else renderPlayer();
break;
case 'round_open':
room = msg.room;
if (role === 'mod') renderMod();
else { renderPlayerBuzzer(); startPlayerTimer(); toast('ROUND OPEN', 'ok'); }
break;
case 'round_closed':
room = msg.room;
if (role === 'mod') renderMod();
else { renderPlayerBuzzer(); stopPlayerTimer(); toast('ROUND CLOSED', 'warn'); }
break;
case 'buzzer_reset':
room = msg.room;
if (role === 'mod') renderMod(); else renderPlayerBuzzer();
break;
case 'buzz_event':
room = msg.room;
if (role === 'mod') renderModBuzz(msg); else { renderPlayerBuzzer(); addFeed(msg); }
break;
case 'buzz_rejected':
toast(msg.reason, 'warn'); break;
case 'kicked':
toast('Removed from room', 'err');
role = null; room = null; myId = null; localStorage.removeItem('play');
showScr('s-land'); break;
case 'room_ended':
toast('Room ended', 'warn');
role = null; room = null; myId = null;
clearMod(); localStorage.removeItem('play');
showScr('s-land'); break;
case 'mod_disconnected':
toast('Moderator disconnected', 'warn'); break;
case 'error':
toast(msg.message, 'err'); break;
}
}
// ══════════════════════════════════════════════════════
// SCREENS
// ══════════════════════════════════════════════════════
function showScr(id) {
document.querySelectorAll('.scr').forEach(s => { s.classList.remove('on'); s.style.display = 'none'; });
const el = document.getElementById(id);
el.style.display = 'flex'; el.classList.add('on');
const chip = document.getElementById('hdr-room');
if (room?.id) { chip.textContent = '[' + room.id + ']'; chip.style.display = 'block'; }
else chip.style.display = 'none';
}
function setConn(on) {
document.getElementById('cdot').className = 'conn-dot' + (on ? ' on' : '');
document.getElementById('clbl').textContent = on ? 'ONLINE' : 'OFFLINE';
}
// ══════════════════════════════════════════════════════
// LANDING
// ══════════════════════════════════════════════════════
function goSetup() { renderSetupTeamNames(); showScr('s-setup'); }
function joinRoom() {
const code = document.getElementById('ji-code').value.trim().toUpperCase();
const name = document.getElementById('ji-name').value.trim();
if (!code) { toast('Enter room code', 'err'); return; }
if (!name) { toast('Enter your name', 'err'); return; }
connect(() => ws_send({ type: 'join_room', roomId: code, playerName: name }));
}
function openRejoin() {
const m = loadMod(); if (!m) return;
document.getElementById('m-rejoin-code').textContent = m.id;
openModal('m-rejoin');
}
function doRejoin() {
const m = loadMod(); if (!m) return;
closeModal('m-rejoin');
connect(() => ws_send({ type: 'mod_rejoin', roomId: m.id, modSecret: m.s }));
}
// ══════════════════════════════════════════════════════
// SETUP PAGE
// ══════════════════════════════════════════════════════
function segSelect(groupId, el) {
document.querySelectorAll('#' + groupId + ' .seg-opt').forEach(b => b.classList.remove('active'));
el.classList.add('active');
if (groupId === 'seg-mode') {
const isTeams = el.dataset.v === 'teams';
document.getElementById('team-opts').style.display = isTeams ? 'flex' : 'none';
document.getElementById('team-opts').style.flexDirection = 'column';
}
}
function renderSetupTeamNames() {
const n = Math.max(2, Math.min(64, parseInt(document.getElementById('st-numteams').value) || 2));
const container = document.getElementById('setup-team-names');
const existing = Array.from(container.querySelectorAll('input')).map(i => i.value);
container.innerHTML = '';
for (let i = 0; i < n; i++) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
const dot = document.createElement('div');
const c = teamColor(i);
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};box-shadow:0 0 6px ${c};flex-shrink:0;`;
const inp = document.createElement('input');
inp.type = 'text'; inp.maxLength = 32;
inp.value = existing[i] || greekName(i);
inp.style.flex = '1';
row.appendChild(dot); row.appendChild(inp);
container.appendChild(row);
}
}
function toggleTimerField() {
document.getElementById('timer-field').style.display = document.getElementById('st-usetimer').checked ? 'block' : 'none';
}
function createRoom() {
const mode = document.querySelector('#seg-mode .seg-opt.active')?.dataset.v || 'individual';
const numTeams = Math.max(2, Math.min(64, parseInt(document.getElementById('st-numteams').value) || 2));
const teamNames = Array.from(document.querySelectorAll('#setup-team-names input')).map(i => i.value.trim() || greekName(0));
const useTimer = document.getElementById('st-usetimer').checked;
const timerSec = parseInt(document.getElementById('st-timersec').value) || 30;
connect(() => ws_send({
type: 'create_room',
settings: {
mode, numTeams, teamNames,
playerPickTeam: document.getElementById('st-playerpick').checked,
buzzerLockout: document.getElementById('st-lockout').checked,
showBuzzOrder: document.getElementById('st-showorder').checked,
timerSeconds: useTimer ? timerSec : 0,
}
}));
}
// ══════════════════════════════════════════════════════
// MOD TIMER
// ══════════════════════════════════════════════════════
function fmtTime(s) { return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60); }
function modTimerLoad() {
modTimerRemaining = Math.max(5, parseInt(document.getElementById('mod-timer-set').value) || 30);
modTimerRunning = false;
clearInterval(modTimerInterval);
document.getElementById('btn-timer-ss').textContent = 'START';
renderModTimerDisplay();
}
function modTimerReset() {
clearInterval(modTimerInterval);
modTimerRunning = false;
modTimerRemaining = Math.max(5, parseInt(document.getElementById('mod-timer-set').value) || 30);
document.getElementById('btn-timer-ss').textContent = 'START';
renderModTimerDisplay();
// broadcast reset to players
broadcastTimerToPlayers(modTimerRemaining, false);
}
function modTimerToggle() {
if (modTimerRunning) {
clearInterval(modTimerInterval); modTimerRunning = false;
document.getElementById('btn-timer-ss').textContent = 'START';
broadcastTimerToPlayers(modTimerRemaining, false);
} else {
if (modTimerRemaining <= 0) modTimerLoad();
modTimerRunning = true;
document.getElementById('btn-timer-ss').textContent = 'PAUSE';
broadcastTimerToPlayers(modTimerRemaining, true);
modTimerInterval = setInterval(() => {
modTimerRemaining--;
renderModTimerDisplay();
broadcastTimerToPlayers(modTimerRemaining, true);
if (modTimerRemaining <= 0) {
clearInterval(modTimerInterval); modTimerRunning = false;
document.getElementById('btn-timer-ss').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 = fmtTime(s);
el.className = 'timer-digits' + (s <= 5 ? 'danger' : s <= 10 ? ' warn' : '');
}
// We sync timer to players via a side-channel: store in sessionStorage and poll
// (pure client-side sync — no extra server message needed)
function broadcastTimerToPlayers(sec, running) {
// encode into a BroadcastChannel so other tabs (players on same device) see it
try {
const bc = new BroadcastChannel('buzzer_timer');
bc.postMessage({ sec, running });
bc.close();
} catch { }
}
// ══════════════════════════════════════════════════════
// MOD RENDER
// ══════════════════════════════════════════════════════
function renderMod() {
if (!room) return;
document.getElementById('mod-code').textContent = room.id;
document.getElementById('mod-pcount').textContent = room.players.length + ' PLAYER' + (room.players.length !== 1 ? 'S' : '');
document.getElementById('mod-rstatus').textContent = 'ROUND: ' + (room.buzzerState.roundOpen ? 'OPEN' : 'CLOSED');
document.getElementById('lock-room-tog').checked = room.locked;
document.getElementById('lock-teams-tog').checked = room.teamLocked;
document.getElementById('pcount-badge').textContent = room.players.length;
document.getElementById('lock-teams-row').style.display = room.settings.mode === 'teams' ? 'flex' : 'none';
document.getElementById('tab-teams-btn').style.display = room.settings.mode === 'teams' ? 'block' : 'none';
if (!modTimerRunning && room.settings.timerSeconds > 0 && modTimerRemaining === 0) {
modTimerRemaining = room.settings.timerSeconds;
document.getElementById('mod-timer-set').value = room.settings.timerSeconds;
renderModTimerDisplay();
}
renderModBuzz(null);
renderModPlayerList();
renderModTeams();
}
function renderModBuzz(evt) {
const order = room?.buzzerState?.buzzOrder ?? [];
const emptyEl = document.getElementById('mod-bz-empty');
const listEl = document.getElementById('mod-bz-list');
if (order.length === 0) { emptyEl.style.display = 'block'; listEl.innerHTML = ''; return; }
emptyEl.style.display = 'none';
listEl.innerHTML = '';
const times = evt?.buzzTimes ?? {};
const firstTime = times[order[0]];
order.forEach((pid, idx) => {
const p = room.players.find(x => x.id === pid); if (!p) return;
const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : null;
const color = p.teamIndex !== null ? teamColor(p.teamIndex) : 'var(--g)';
const ms = times[pid] && firstTime && idx > 0 ? '+' + (times[pid] - firstTime) + 'ms' : '';
const div = document.createElement('div');
div.className = 'bz-entry';
div.innerHTML = `
<div class="bz-rank ${idx === 0 ? 'first' : ''}">${idx + 1}</div>
<div class="bz-info">
<div class="bz-name">${esc(p.name)}</div>
${teamName ? `<div class="bz-team" style="color:${color}">${esc(teamName)}</div>` : ''}
${ms ? `<div class="bz-ms">${ms} after first</div>` : ''}
</div>
`;
listEl.appendChild(div);
});
}
function renderModPlayerList() {
if (!room) return;
const el = document.getElementById('mod-plist');
if (room.players.length === 0) { el.innerHTML = '<div class="empty">No players yet.</div>'; return; }
el.innerHTML = '';
room.players.forEach(p => {
const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : null;
const color = p.teamIndex !== null ? teamColor(p.teamIndex) : null;
let teamSel = '';
if (room.settings.mode === 'teams') {
let opts = `<option value="-1" ${p.teamIndex === null ? 'selected' : ''}>No team</option>`;
for (let i = 0; i < room.settings.numTeams; i++) {
const tn = room.settings.teamNames[i] ?? greekName(i);
opts += `<option value="${i}" ${p.teamIndex === i ? 'selected' : ''}>${esc(tn)}</option>`;
}
teamSel = `<select style="margin-top:6px;font-size:12px;padding:4px 8px;" onchange="ws_send({type:'assign_team',playerId:'${p.id}',teamIndex:+this.value===-1?null:+this.value})">${opts}</select>`;
}
const row = document.createElement('div');
row.className = 'pl-row' + (p.isConnected ? '' : ' offline');
row.innerHTML = `
<div class="pl-info" style="flex:1;min-width:0;">
<div class="pl-name">${esc(p.name)} ${p.isConnected ? '' : '<span class="tag tag-red" style="font-size:9px">OFFLINE</span>'}</div>
${teamName ? `<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>` : '<div class="pl-meta">No team</div>'}
${teamSel}
</div>
<div class="pl-actions">
<button class="btn btn-red btn-sm" onclick="ws_send({type:'kick_player',playerId:'${p.id}'})">KICK</button>
</div>
`;
el.appendChild(row);
});
}
function renderModTeams() {
if (!room || room.settings.mode !== 'teams') return;
const grid = document.getElementById('mod-team-grid');
grid.innerHTML = '';
for (let i = 0; i < room.settings.numTeams; i++) {
const members = room.players.filter(p => p.teamIndex === i);
const color = teamColor(i);
const name = room.settings.teamNames[i] ?? greekName(i);
const card = document.createElement('div');
card.className = 'team-card';
card.style.borderColor = color;
card.innerHTML = `
<div class="tc-n" style="color:${color}">${esc(name)}</div>
<div class="tc-c" style="color:${color}">${members.length}</div>
<div class="tc-m">${members.map(p => esc(p.name)).join('<br>') || '—'}</div>
`;
grid.appendChild(card);
}
}
function renderModSettings() {
if (!room) return;
const s = room.settings;
document.getElementById('ls-lockout').checked = s.buzzerLockout;
document.getElementById('ls-showorder').checked = s.showBuzzOrder;
document.getElementById('ls-playerpick').checked = s.playerPickTeam;
document.getElementById('ls-numteams').value = s.numTeams;
segActivate('ls-seg-mode', s.mode);
renderLiveTeamNames();
}
function segActivate(groupId, val) {
document.querySelectorAll('#' + groupId + ' .seg-opt').forEach(b => b.classList.toggle('active', b.dataset.v === val));
}
function renderLiveTeamNames() {
if (!room) return;
const container = document.getElementById('ls-team-names');
container.innerHTML = '';
for (let i = 0; i < room.settings.numTeams; i++) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
const dot = document.createElement('div');
const c = teamColor(i);
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};flex-shrink:0;`;
const inp = document.createElement('input');
inp.type = 'text'; inp.maxLength = 32;
inp.value = room.settings.teamNames[i] ?? greekName(i);
inp.onchange = () => updateLiveTeamName(i, inp.value);
row.appendChild(dot); row.appendChild(inp);
container.appendChild(row);
}
}
function updateLiveTeamName(idx, val) {
if (!room) return;
const names = [...room.settings.teamNames];
names[idx] = val.trim() || greekName(idx);
ws_send({ type: 'update_settings', settings: { teamNames: names } });
}
function pushSetting(key, val) { ws_send({ type: 'update_settings', settings: { [key]: val } }); }
// ══════════════════════════════════════════════════════
// PLAYER TIMER (receives from BroadcastChannel)
// ══════════════════════════════════════════════════════
let _bc = null;
function initPlayerTimer() {
try {
_bc = new BroadcastChannel('buzzer_timer');
_bc.onmessage = e => {
const { sec, running } = e.data;
playerTimerRemaining = sec;
clearInterval(playerTimerInterval);
renderPlayerTimer();
if (running && sec > 0) {
playerTimerInterval = setInterval(() => {
playerTimerRemaining--;
renderPlayerTimer();
if (playerTimerRemaining <= 0) clearInterval(playerTimerInterval);
}, 1000);
}
};
} catch { }
}
function stopPlayerTimer() { clearInterval(playerTimerInterval); }
function startPlayerTimer() {
// timer already synced via BroadcastChannel
}
function renderPlayerTimer() {
const el = document.getElementById('p-timer');
const s = playerTimerRemaining;
if (s <= 0 || !room?.settings.timerSeconds) { el.className = 'p-timer hidden'; return; }
el.textContent = fmtTime(s);
el.className = 'p-timer' + (s <= 5 ? ' danger' : s <= 10 ? ' warn' : '');
}
// ══════════════════════════════════════════════════════
// 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 = teamColor(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 = `<div>${esc(s.teamNames[i] ?? greekName(i))}</div><div class="tb-count">${members.length} player${members.length !== 1 ? 's' : ''}</div>`;
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 = '<div style="font-size:13px;color:var(--dim);padding:10px">No players yet.</div>'; return; }
el.innerHTML = '';
room.players.forEach(p => {
const isMe = p.id === myId;
const color = p.teamIndex !== null ? teamColor(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 = `
<div class="roster-dot" style="background:${p.isConnected ? (isMe ? 'var(--g)' : color) : 'var(--border)'}"></div>
<div style="flex:1">${esc(p.name)}${isMe ? ' <span style="font-size:11px;color:var(--dim)">(YOU)</span>' : ''}</div>
${teamName ? `<div style="font-size:12px;color:${color}">${esc(teamName)}</div>` : ''}
`;
el.appendChild(row);
});
}
function addFeed(evt) {
const feed = document.getElementById('p-feed');
const isFirst = evt.buzzOrder?.[0] === evt.playerId;
const color = evt.teamIndex !== null ? teamColor(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 = `<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst ? ' <span style="color:var(--yellow)">— FIRST!</span>' : ''}`;
feed.prepend(div);
while (feed.children.length > 30) 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();
if (name === 'settings') renderModSettings();
}
// ══════════════════════════════════════════════════════
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ══════════════════════════════════════════════════════
// 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);
initPlayerTimer();
renderSetupTeamNames();
// init seg mode display
const modeBtn = document.querySelector('#seg-mode .seg-opt.active');
if (modeBtn) segSelect('seg-mode', modeBtn);
});

1215
src/public/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,10 @@ export interface RoomSettings {
mode: "individual" | "teams";
numTeams: number;
teamNames: string[];
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
playerPickTeam: boolean;
showBuzzOrder: boolean;
buzzerLockout: boolean;
timerSeconds: number;
}
export interface Player {
@@ -31,13 +31,28 @@ export interface Room {
settings: RoomSettings;
players: Map<string, Player>;
buzzerState: BuzzerState;
locked: boolean; // room locked to new joins
teamLocked: boolean; // team selection locked
locked: boolean;
teamLocked: boolean;
}
export const rooms = new Map<string, Room>();
export const rooms = new Map<string, Room>();
export const wsToPlayer = new Map<ServerWebSocket<unknown>, { roomId: string; playerId: string }>();
// Infinite Greek alphabet: Alpha, Beta, ... Omega, Alpha II, Beta II, ...
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 = "";
@@ -55,11 +70,11 @@ export function freshBuzzer(): BuzzerState {
export function publicRoom(room: Room) {
return {
id: room.id,
settings: room.settings,
locked: room.locked,
teamLocked: room.teamLocked,
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,
buzzOrder: room.buzzerState.buzzOrder,

View File

@@ -2,14 +2,19 @@ import { readFileSync } from "fs";
import { handleMessage, handleClose } from "./ws-handler";
const HTML = readFileSync("./src/public/index.html", "utf-8");
const CSS = readFileSync("./src/public/styles.css", "utf-8");
const JS = readFileSync("./src/public/script.js", "utf-8");
const server = Bun.serve({
port: 3000,
fetch(req, server) {
if (new URL(req.url).pathname === "/ws") {
const url = new URL(req.url);
if (url.pathname === "/ws") {
if (!server.upgrade(req)) return new Response("WS upgrade failed", { status: 400 });
return undefined as any;
}
if (url.pathname === "/styles.css") return new Response(CSS, { headers: { "Content-Type": "text/css" } });
if (url.pathname === "/script.js") return new Response(JS, { headers: { "Content-Type": "text/javascript" } });
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
},
websocket: {

View File

@@ -2,7 +2,7 @@ import type { ServerWebSocket } from "bun";
import {
rooms, wsToPlayer,
Room, Player,
genId, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
} from "./rooms";
type WS = ServerWebSocket<unknown>;
@@ -18,11 +18,10 @@ export function handleMessage(ws: WS, raw: string) {
/* ── CREATE ROOM ── */
if (type === "create_room") {
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 numTeams = Math.max(2, Math.min(64, (s.numTeams ?? 2) | 0));
const teamNames: string[] = [];
for (let i = 0; i < numTeams; i++)
teamNames.push(sanitize(s.teamNames?.[i] ?? defaultNames[i], 32));
teamNames.push(s.teamNames?.[i] ? sanitize(s.teamNames[i], 32) : greekName(i));
const room: Room = {
id: genId(6),
@@ -35,7 +34,7 @@ export function handleMessage(ws: WS, raw: string) {
playerPickTeam: s.playerPickTeam === true,
showBuzzOrder: s.showBuzzOrder !== false,
buzzerLockout: s.buzzerLockout !== false,
timerSeconds: Math.min(300, Math.max(0, (s.timerSeconds ?? 0) | 0)),
timerSeconds: Math.min(600, Math.max(0, (s.timerSeconds ?? 0) | 0)),
},
players: new Map(),
buzzerState: freshBuzzer(),
@@ -84,7 +83,7 @@ export function handleMessage(ws: WS, raw: string) {
return;
}
/* ── PICK TEAM (player) ── */
/* ── PICK TEAM ── */
if (type === "pick_team") {
const ctx = wsToPlayer.get(ws);
if (!ctx) return;
@@ -109,15 +108,9 @@ export function handleMessage(ws: WS, raw: string) {
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;
}
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());
@@ -125,23 +118,8 @@ export function handleMessage(ws: WS, raw: string) {
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),
});
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;
}
@@ -155,71 +133,53 @@ export function handleMessage(ws: WS, raw: string) {
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") 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.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(300, Math.max(0, s.timerSeconds | 0));
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(7, msg.teamIndex | 0))
: null;
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);
}
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 "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);
@@ -230,7 +190,6 @@ export function handleMessage(ws: WS, raw: string) {
}
export function handleClose(ws: WS) {
// mod disconnect
for (const room of rooms.values()) {
if (room.modWs === ws) {
room.modWs = null;
@@ -238,7 +197,6 @@ export function handleClose(ws: WS) {
return;
}
}
// player disconnect
const ctx = wsToPlayer.get(ws);
if (ctx) {
wsToPlayer.delete(ws);