Private
Public Access
1
0

refacto: Keeping entrypoints clean and making files by purpose

This commit is contained in:
gauvainboiche
2026-03-29 13:28:18 +02:00
parent 79cf3ca13e
commit 84af90e81e
13 changed files with 1198 additions and 1167 deletions

51
public/src/api.js Normal file
View File

@@ -0,0 +1,51 @@
// ── Raw HTTP wrappers ─────────────────────────────────────────────────────────
// These functions only perform fetch() calls and return the raw Response or
// parsed JSON. No state mutations, no DOM access.
export async function apiFetchConfig(team) {
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`);
if (!res.ok) throw new Error("config_fetch_failed");
return res.json();
}
export async function apiFetchScores() {
const res = await fetch("/api/scores");
if (!res.ok) throw new Error("scores_fetch_failed");
return res.json();
}
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
export async function apiFetchGrid(seed) {
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
}
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
export async function apiRevealCell(seed, x, y, team) {
return fetch("/api/cell/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, x, y, team }),
});
}
export async function apiLogin(username, password) {
return fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
}
export async function apiRegister(username, email, password, team) {
return fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
});
}
export async function apiGetMe(token) {
return fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
}

147
public/src/auth.js Normal file
View File

@@ -0,0 +1,147 @@
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
import { setCurrentTeam, updateTeamSegmented, refreshFromServer } from "./game.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const authOverlay = document.getElementById("authOverlay");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
// ── Auth state ────────────────────────────────────────────────────────────────
export let authToken = localStorage.getItem("authToken") ?? null;
export let currentUser = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function showError(el, msg) { el.textContent = msg; el.classList.remove("hidden"); }
function clearError(el) { el.textContent = ""; el.classList.add("hidden"); }
export function showAuthOverlay() { authOverlay.classList.remove("hidden"); }
export function hideAuthOverlay() { authOverlay.classList.add("hidden"); }
// ── Apply user (after login / register / session restore) ─────────────────────
export function applyUser(user, token) {
currentUser = user;
authToken = token;
localStorage.setItem("authToken", token);
setCurrentTeam(user.team);
userDisplayEl.textContent = `${user.username} [${user.team}]`;
logoutBtn.classList.remove("hidden");
updateTeamSegmented();
}
function logout() {
currentUser = null;
authToken = null;
localStorage.removeItem("authToken");
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
}
// ── Session restore ───────────────────────────────────────────────────────────
export async function tryRestoreSession() {
if (!authToken) return false;
try {
const res = await apiGetMe(authToken);
if (!res.ok) { localStorage.removeItem("authToken"); authToken = null; return false; }
const data = await res.json();
applyUser(data.user, data.token);
return true;
} catch {
return false;
}
}
// ── Tab switching ─────────────────────────────────────────────────────────────
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
clearError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearError(registerErrorEl);
});
// ── Login form ────────────────────────────────────────────────────────────────
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(loginErrorEl);
const username = loginUsernameEl.value.trim();
const password = loginPasswordEl.value;
if (!username || !password) return;
try {
const res = await apiLogin(username, password);
const data = await res.json();
if (!res.ok) {
const msgs = { invalid_credentials: "Invalid username or password.", missing_fields: "Please fill in all fields." };
showError(loginErrorEl, msgs[data.error] ?? "Login failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
showError(loginErrorEl, "Network error. Try again.");
}
});
// ── Register form ─────────────────────────────────────────────────────────────
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
try {
const res = await apiRegister(username, email, password, teamInput.value);
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short:"Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
};
showError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
showError(registerErrorEl, "Network error. Try again.");
}
});
// ── Logout ────────────────────────────────────────────────────────────────────
logoutBtn.addEventListener("click", logout);

416
public/src/game.js Normal file
View File

@@ -0,0 +1,416 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
// ── Constants ─────────────────────────────────────────────────────────────────
const GRID_W = 100;
const GRID_H = 100;
const OUTER_RADIUS = 50;
const INNER_RADIUS = 30;
const PLANET_CHANCE = 0.1;
const COLOR_OUTSIDE = "#000000";
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
// ── Shared game state ─────────────────────────────────────────────────────────
export const GAME_CONFIG = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
};
window.GAME_CONFIG = GAME_CONFIG;
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} */
export const cells = new Map();
export let currentTeam = "blue";
export function setCurrentTeam(t) { currentTeam = t; }
export let seedStr = "";
export let seedU32 = 0;
export function applySeed(str) {
seedStr = str;
seedU32 = fnv1a32(str || "fallback");
}
export const teamCooldownEndMs = { blue: 0, red: 0 };
let rafId = 0;
let lastPointerEvent = null;
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const details = document.getElementById("details");
const hint = document.getElementById("hint");
const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownCfgEl = document.getElementById("cooldownConfig");
const seedDisplayEl = document.getElementById("worldSeedDisplay");
const nextPeriodEl = document.getElementById("nextPeriodUtc");
const resetCountEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const teamCorner = document.getElementById("teamCorner");
const teamTrack = document.getElementById("teamSegmentedTrack");
const teamBlueBtn = document.getElementById("teamBlue");
const teamRedBtn = document.getElementById("teamRed");
// ── Cell helpers ──────────────────────────────────────────────────────────────
export function cellKey(x, y) { return `${x},${y}`; }
function cellCenter(x, y) {
const cx = (GRID_W - 1) / 2;
const cy = (GRID_H - 1) / 2;
return { dx: x - cx, dy: y - cy };
}
export function isExploitable(x, y) {
const { dx, dy } = cellCenter(x, y);
const r = Math.hypot(dx, dy);
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
}
function hasPlanetAt(x, y) {
const h = hash2u32(x, y, seedU32);
return h / 4294967296 < PLANET_CHANCE;
}
export function cellMeta(key) { return cells.get(key) ?? null; }
export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
// ── Config display ────────────────────────────────────────────────────────────
export function applyConfigPayload(data) {
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
nextPeriodEl.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
: "—";
updateResetCountdown();
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
}
export function updateResetCountdown() {
if (!GAME_CONFIG.seedPeriodEndsAtUtc) { resetCountEl.textContent = "--:--:--"; return; }
const diff = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime() - Date.now();
if (diff <= 0) { resetCountEl.textContent = "00:00:00"; return; }
const s = Math.floor(diff / 1000);
resetCountEl.textContent =
String(Math.floor(s / 3600)).padStart(2, "0") + ":" +
String(Math.floor((s % 3600) / 60)).padStart(2, "0") + ":" +
String(s % 60).padStart(2, "0");
}
// ── Scores ────────────────────────────────────────────────────────────────────
export async function fetchAndApplyScores() {
try {
const { blue, red } = await apiFetchScores();
scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
}
// ── Config fetch + apply ──────────────────────────────────────────────────────
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
export async function fetchConfig() {
const data = await apiFetchConfig(currentTeam);
const prevSeed = GAME_CONFIG.worldSeed;
applyConfigPayload(data);
applySeed(GAME_CONFIG.worldSeed);
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}
// ── Grid fetch ────────────────────────────────────────────────────────────────
export async function fetchGridForSeed(seed, depth = 0) {
if (depth > 4) throw new Error("grid_stale");
const res = await apiFetchGrid(seed);
if (res.status === 410) {
await fetchConfig();
return fetchGridForSeed(seedStr, depth + 1);
}
if (!res.ok) throw new Error("grid");
const data = await res.json();
cells.clear();
for (const row of data.cells || []) {
cells.set(cellKey(row.x, row.y), {
discoveredBy: row.discovered_by ?? row.discoveredBy,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
});
}
}
// ── Cooldown ──────────────────────────────────────────────────────────────────
export function cooldownActive() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
return Date.now() < teamCooldownEndMs[currentTeam];
}
function remainingSecs() {
return Math.max(0, (teamCooldownEndMs[currentTeam] - Date.now()) / 1000);
}
function tickCooldown() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) { countdownWrap.classList.add("hidden"); return; }
const left = remainingSecs();
if (left <= 0) {
countdownWrap.classList.add("hidden");
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
return;
}
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(Math.ceil(left));
refreshCursorFromLast();
rafId = requestAnimationFrame(tickCooldown);
}
export function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
refreshCursorFromLast();
return;
}
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(secs);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tickCooldown);
refreshCursorFromLast();
}
export function clearCooldown() {
teamCooldownEndMs.blue = 0;
teamCooldownEndMs.red = 0;
cancelAnimationFrame(rafId);
countdownWrap.classList.add("hidden");
}
// ── Draw ──────────────────────────────────────────────────────────────────────
export function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(0, 0, w, h);
const cw = w / GRID_W;
const ch = h / GRID_H;
for (let y = 0; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y)) { ctx.fillStyle = COLOR_OUTSIDE; ctx.fillRect(x * cw, y * ch, cw, ch); continue; }
const k = cellKey(x, y);
const meta = cellMeta(k);
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
else if (meta.discoveredBy !== currentTeam) ctx.fillStyle = COLOR_OPPONENT_GREY;
else ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
ctx.fillRect(x * cw, y * ch, cw, ch);
}
}
for (const [key, meta] of cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
const rng = mulberry32(h32);
ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`;
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
ctx.beginPath();
ctx.arc(xs * cw + cw / 2, ys * ch + ch / 2, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 1; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) {
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch);
}
for (let y = 1; y < GRID_H; y++) for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
}
ctx.stroke();
}
// ── Cursor ────────────────────────────────────────────────────────────────────
function pickCell(ev) {
const rect = canvas.getBoundingClientRect();
const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W);
const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H);
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
return { x, y };
}
function refreshCursor(ev) {
const cell = pickCell(ev);
if (!cell || !isExploitable(cell.x, cell.y) || isOpponentTile(cellKey(cell.x, cell.y))) {
canvas.style.cursor = "default";
} else {
canvas.style.cursor = "pointer";
}
}
function refreshCursorFromLast() {
if (lastPointerEvent) refreshCursor(lastPointerEvent);
}
// ── Selection display ─────────────────────────────────────────────────────────
function applyRevealPayload(cell) {
cells.set(cellKey(cell.x, cell.y), {
discoveredBy: cell.discoveredBy ?? currentTeam,
hasPlanet: Boolean(cell.hasPlanet),
planet: cell.planet ?? null,
});
details.classList.remove("details--hidden");
if (!cell.exploitable) {
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable.`;
return;
}
if (!cell.hasPlanet) {
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
}
function showLocalSelection(x, y) {
const k = cellKey(x, y);
details.classList.remove("details--hidden");
if (!isOwnTile(k)) return;
if (!isExploitable(x, y)) {
hint.textContent = `(${x},${y}) not exploitable.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable.`;
return;
}
const meta = cellMeta(k);
if (!meta?.hasPlanet) {
hint.textContent = `(${x},${y}) exploitable — empty.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
let planet = meta.planet;
if (!planet) {
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
planet = generatePlanet(mulberry32(h));
}
hint.textContent = `(${x},${y}) exploitable — planet.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
}
// ── Canvas click handler ──────────────────────────────────────────────────────
async function onCanvasClick(ev) {
const cell = pickCell(ev);
if (!cell || !isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) return;
if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
if (cooldownActive()) {
hint.textContent = "Cooldown active — reveal a tile your team already discovered to view stats.";
return;
}
try {
const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam);
if (res.status === 409) {
hint.textContent = "This tile was already discovered by the other team.";
await fetchGridForSeed(seedStr);
draw();
return;
}
if (res.status === 410) {
hint.textContent = "World period changed — syncing.";
await refreshFromServer();
return;
}
if (!res.ok) throw new Error("reveal");
applyRevealPayload(await res.json());
startCooldown();
draw();
fetchAndApplyScores();
} catch (e) {
if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; }
hint.textContent = "Could not save reveal — check server / database.";
}
}
// ── Team segmented control ────────────────────────────────────────────────────
export function updateTeamSegmented() {
teamTrack.dataset.active = currentTeam;
teamBlueBtn.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
// ── Full server refresh ───────────────────────────────────────────────────────
// Exported so auth.js / main.js can call it after login or on timer.
export async function refreshFromServer() {
try {
const seedChanged = await fetchConfig();
if (seedChanged) {
clearCooldown();
details.textContent = "Stats are hidden until you click a tile.";
details.classList.add("details--hidden");
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
}
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
draw();
refreshCursorFromLast();
} catch {
hint.textContent = "Could not sync with server. Is the API running?";
}
}
// ── Event listeners ───────────────────────────────────────────────────────────
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });

View File

@@ -1,697 +1,51 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import {
GAME_CONFIG,
seedStr,
updateTeamSegmented,
updateResetCountdown,
fetchConfig,
fetchGridForSeed,
fetchAndApplyScores,
refreshFromServer,
draw,
} from "./game.js";
const GRID_W = 100;
const GRID_H = 100;
const OUTER_RADIUS = 50;
const INNER_RADIUS = 30;
const PLANET_CHANCE = 0.1;
import {
tryRestoreSession,
showAuthOverlay,
hideAuthOverlay,
} from "./auth.js";
/** Outside donut: solid black, no grid. */
const COLOR_OUTSIDE = "#000000";
/** Exploitable, not yet discovered by anyone (classic ring). */
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
/** Current team's discovered tiles (blue). */
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
/** Current team's discovered tiles (red). */
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
/** Other team's tiles: empty look, not interactive. */
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const details = document.getElementById("details");
const hint = document.getElementById("hint");
const refreshBtn = document.getElementById("refreshBtn");
const teamCorner = document.getElementById("teamCorner");
const teamSegmentedTrack = document.getElementById("teamSegmentedTrack");
const teamBlue = document.getElementById("teamBlue");
const teamRed = document.getElementById("teamRed");
const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownConfigEl = document.getElementById("cooldownConfig");
const worldSeedDisplay = document.getElementById("worldSeedDisplay");
const nextPeriodUtc = document.getElementById("nextPeriodUtc");
const refreshCountdownEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
const hint = document.getElementById("hint");
const cooldownEl = document.getElementById("cooldownConfig");
// Auth elements
const authOverlay = document.getElementById("authOverlay");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, worldSeed: string, seedPeriodEndsAtUtc: string }} */
const GAME_CONFIG = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
};
window.GAME_CONFIG = GAME_CONFIG;
let seedStr = "";
let seedU32 = 0;
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object | null }>} */
const state = { cells: new Map() };
/** @type {"blue" | "red"} */
let currentTeam = "blue";
// Auth state
let authToken = localStorage.getItem("authToken") ?? null;
let currentUser = null; // { id, username, team, role }
let rafId = 0;
/** @type {MouseEvent | null} */
let lastPointerEvent = null;
// ── Polling ───────────────────────────────────────────────────────────────────
let configPollTimer = 0;
let scorePollTimer = 0;
/** @type {ReturnType<typeof setInterval> | null} */
let refreshTimer = null;
let scorePollTimer = 0;
let resetTimer = null;
const teamCooldownEndMs = { blue: 0, red: 0 };
// ── Helpers ──────────────────────────────────────────────────────────────────
function cellKey(x, y) { return `${x},${y}`; }
function cellCenter(x, y) {
const cx = (GRID_W - 1) / 2;
const cy = (GRID_H - 1) / 2;
return { dx: x - cx, dy: y - cy };
}
function isExploitable(x, y) {
const { dx, dy } = cellCenter(x, y);
const r = Math.hypot(dx, dy);
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
}
function hasPlanetAt(x, y) {
const h = hash2u32(x, y, seedU32);
return h / 4294967296 < PLANET_CHANCE;
}
function cellMeta(key) { return state.cells.get(key) ?? null; }
function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
// ── Auth ─────────────────────────────────────────────────────────────────────
function showAuthOverlay() {
authOverlay.classList.remove("hidden");
}
function hideAuthOverlay() {
authOverlay.classList.add("hidden");
}
function setAuthError(el, msg) {
el.textContent = msg;
el.classList.remove("hidden");
}
function clearAuthError(el) {
el.textContent = "";
el.classList.add("hidden");
}
function applyUser(user, token) {
currentUser = user;
authToken = token;
localStorage.setItem("authToken", token);
currentTeam = user.team;
userDisplayEl.textContent = `${user.username} [${user.team}]`;
logoutBtn.classList.remove("hidden");
updateTeamSegmented();
}
function logout() {
currentUser = null;
authToken = null;
localStorage.removeItem("authToken");
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
}
async function tryRestoreSession() {
if (!authToken) return false;
try {
const res = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) {
localStorage.removeItem("authToken");
authToken = null;
return false;
}
const data = await res.json();
applyUser(data.user, data.token);
return true;
} catch {
return false;
}
}
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
clearAuthError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearAuthError(registerErrorEl);
});
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(loginErrorEl);
const username = loginUsernameEl.value.trim();
const password = loginPasswordEl.value;
if (!username || !password) return;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
invalid_credentials: "Invalid username or password.",
missing_fields: "Please fill in all fields.",
};
setAuthError(loginErrorEl, msgs[data.error] ?? "Login failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(loginErrorEl, "Network error. Try again.");
}
});
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) {
setAuthError(registerErrorEl, "Please choose a team.");
return;
}
const team = teamInput.value;
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short: "Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
};
setAuthError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(registerErrorEl, "Network error. Try again.");
}
});
logoutBtn.addEventListener("click", logout);
// ── Cooldown ─────────────────────────────────────────────────────────────────
function cooldownBlocksNewReveal() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
return Date.now() < teamCooldownEndMs[currentTeam];
}
function refreshCursorFromLast() {
if (lastPointerEvent) refreshCursor(lastPointerEvent);
}
function remainingCooldownSeconds() {
const left = (teamCooldownEndMs[currentTeam] - Date.now()) / 1000;
return Math.max(0, left);
}
function tickCountdown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
countdownWrap.classList.add("hidden");
cancelAnimationFrame(rafId);
return;
}
const left = remainingCooldownSeconds();
if (left <= 0) {
countdownWrap.classList.add("hidden");
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
cancelAnimationFrame(rafId);
return;
}
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(Math.ceil(left));
refreshCursorFromLast();
rafId = requestAnimationFrame(tickCountdown);
}
function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
refreshCursorFromLast();
return;
}
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(secs);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tickCountdown);
refreshCursorFromLast();
}
// ── Config / seed ─────────────────────────────────────────────────────────────
function applyWorldSeedFromConfig() {
seedStr = GAME_CONFIG.worldSeed || "";
seedU32 = fnv1a32(seedStr || "fallback");
}
function applyDebugTeamVisibility() {
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
}
function applyConfigPayload(data) {
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
cooldownConfigEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
worldSeedDisplay.textContent = GAME_CONFIG.worldSeed || "—";
nextPeriodUtc.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
: "—";
updateRefreshCountdown();
applyDebugTeamVisibility();
}
function updateRefreshCountdown() {
if (!GAME_CONFIG.seedPeriodEndsAtUtc) {
refreshCountdownEl.textContent = "--:--:--";
return;
}
const endTime = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime();
const diffMs = endTime - Date.now();
if (diffMs <= 0) {
refreshCountdownEl.textContent = "00:00:00";
return;
}
const totalSeconds = Math.floor(diffMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
refreshCountdownEl.textContent =
String(hours).padStart(2, "0") + ":" +
String(minutes).padStart(2, "0") + ":" +
String(seconds).padStart(2, "0");
}
async function fetchConfig() {
const res = await fetch(`/api/config?team=${currentTeam}`);
if (!res.ok) throw new Error("config");
const data = await res.json();
const prevSeed = GAME_CONFIG.worldSeed;
applyConfigPayload(data);
applyWorldSeedFromConfig();
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}
// ── Scores ────────────────────────────────────────────────────────────────────
async function fetchScores() {
try {
const res = await fetch("/api/scores");
if (!res.ok) return;
const { blue, red } = await res.json();
scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
function scheduleConfigPoll() {
clearTimeout(configPollTimer);
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
configPollTimer = window.setTimeout(async () => {
try {
const changed = await fetchConfig();
if (changed) await refreshFromServer();
} catch { /* ignore */ }
scheduleConfigPoll();
}, ms);
}
function scheduleScorePoll() {
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchScores();
await fetchAndApplyScores();
scheduleScorePoll();
}, 5000);
}
// ── Grid ──────────────────────────────────────────────────────────────────────
async function fetchGridForSeed(seed, depth = 0) {
if (depth > 4) throw new Error("grid_stale");
const res = await fetch(`/api/grid/${encodeURIComponent(seed)}`);
if (res.status === 410) {
await fetchConfig();
applyWorldSeedFromConfig();
return fetchGridForSeed(GAME_CONFIG.worldSeed, depth + 1);
}
if (!res.ok) throw new Error("grid");
const data = await res.json();
state.cells.clear();
for (const row of data.cells || []) {
const k = cellKey(row.x, row.y);
const discoveredBy = row.discovered_by ?? row.discoveredBy;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
});
}
}
async function revealOnServer(x, y) {
const res = await fetch("/api/cell/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed: seedStr, x, y, team: currentTeam }),
});
if (res.status === 409) {
await res.json().catch(() => ({}));
const e = new Error("taken");
e.code = "TAKEN";
throw e;
}
if (res.status === 410) {
const body = await res.json().catch(() => ({}));
const e = new Error("seed_expired");
e.code = "SEED_EXPIRED";
e.worldSeed = body.worldSeed;
throw e;
}
if (!res.ok) throw new Error("reveal");
return res.json();
}
function resetUiForNewPeriod() {
teamCooldownEndMs.blue = 0;
teamCooldownEndMs.red = 0;
cancelAnimationFrame(rafId);
countdownWrap.classList.add("hidden");
details.textContent = "Stats are hidden until you click a tile.";
details.classList.add("details--hidden");
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
}
async function refreshFromServer() {
try {
const seedChanged = await fetchConfig();
applyWorldSeedFromConfig();
if (seedChanged) resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
await fetchScores();
draw();
refreshCursorFromLast();
} catch {
hint.textContent = "Could not sync with server. Is the API running?";
}
}
// ── Draw ──────────────────────────────────────────────────────────────────────
function fillColorForCell(k) {
const meta = cellMeta(k);
if (!meta) return COLOR_RING_IDLE;
if (meta.discoveredBy !== currentTeam) return COLOR_OPPONENT_GREY;
return currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
}
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(0, 0, w, h);
const cw = w / GRID_W;
const ch = h / GRID_H;
for (let y = 0; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y)) {
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(x * cw, y * ch, cw, ch);
continue;
}
const k = cellKey(x, y);
ctx.fillStyle = fillColorForCell(k);
ctx.fillRect(x * cw, y * ch, cw, ch);
}
}
for (const [key, meta] of state.cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
const rng = mulberry32(h32);
const hue = Math.floor(rng() * 360);
ctx.fillStyle = `hsl(${hue} 85% 65%)`;
const px = xs * cw + cw / 2;
const py = ys * ch + ch / 2;
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 1; x < GRID_W; x++) {
for (let y = 0; y < GRID_H; y++) {
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
const xx = x * cw;
ctx.moveTo(xx, y * ch);
ctx.lineTo(xx, (y + 1) * ch);
}
}
for (let y = 1; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
const yy = y * ch;
ctx.moveTo(x * cw, yy);
ctx.lineTo((x + 1) * cw, yy);
}
}
ctx.stroke();
}
// ── Cursor / click ────────────────────────────────────────────────────────────
function pickCellFromEvent(ev) {
const rect = canvas.getBoundingClientRect();
const sx = (ev.clientX - rect.left) / rect.width;
const sy = (ev.clientY - rect.top) / rect.height;
const x = Math.floor(sx * GRID_W);
const y = Math.floor(sy * GRID_H);
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
return { x, y };
}
function refreshCursor(ev) {
const cell = pickCellFromEvent(ev);
if (!cell || !isExploitable(cell.x, cell.y)) { canvas.style.cursor = "default"; return; }
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) { canvas.style.cursor = "default"; return; }
canvas.style.cursor = "pointer";
}
function applyRevealPayload(cell) {
const k = cellKey(cell.x, cell.y);
const discoveredBy = cell.discoveredBy ?? currentTeam;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(cell.hasPlanet),
planet: cell.planet ?? null,
});
details.classList.remove("details--hidden");
if (!cell.exploitable) {
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
if (!cell.hasPlanet) {
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
}
function showSelectionFromLocal(x, y) {
const k = cellKey(x, y);
details.classList.remove("details--hidden");
if (!isOwnTile(k)) return;
if (!isExploitable(x, y)) {
hint.textContent = `(${x},${y}) not exploitable.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
const meta = cellMeta(k);
if (!meta?.hasPlanet) {
hint.textContent = `(${x},${y}) exploitable — empty.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
let planet = meta.planet;
if (!planet) {
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
const rng = mulberry32(h);
planet = generatePlanet(rng);
}
hint.textContent = `(${x},${y}) exploitable — planet.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
}
async function onCanvasClick(ev) {
const cell = pickCellFromEvent(ev);
if (!cell) return;
if (!isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) return;
if (isOwnTile(key)) {
showSelectionFromLocal(cell.x, cell.y);
return;
}
if (cooldownBlocksNewReveal()) {
hint.textContent = "Cooldown — you cannot reveal new tiles yet. Click a tile your team already discovered to view its stats.";
return;
}
try {
const payload = await revealOnServer(cell.x, cell.y);
applyRevealPayload(payload);
startCooldown();
draw();
// Update scores after reveal
fetchScores();
} catch (e) {
if (e?.code === "TAKEN") {
hint.textContent = "This tile was already discovered by the other team.";
await fetchGridForSeed(seedStr);
draw();
return;
}
if (e?.code === "SEED_EXPIRED") {
hint.textContent = "World period changed — syncing.";
await refreshFromServer();
return;
}
hint.textContent = "Could not save reveal — check server / database.";
}
}
// ── Team segmented control ────────────────────────────────────────────────────
function updateTeamSegmented() {
teamSegmentedTrack.dataset.active = currentTeam;
teamBlue.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
teamRed.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
function setTeam(team) {
currentTeam = team;
updateTeamSegmented();
draw();
refreshCursorFromLast();
}
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
teamBlue.addEventListener("click", () => setTeam("blue"));
teamRed.addEventListener("click", () => setTeam("red"));
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Config poll ───────────────────────────────────────────────────────────────
function scheduleClientConfigPoll() {
clearTimeout(configPollTimer);
const ms = Math.max(5000, GAME_CONFIG.configReloadIntervalSeconds * 1000);
configPollTimer = window.setTimeout(async () => {
try {
const changed = await fetchConfig();
applyWorldSeedFromConfig();
if (changed) {
resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
draw();
refreshCursorFromLast();
}
} catch { /* ignore */ }
scheduleClientConfigPoll();
}, ms);
}, 5_000);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
@@ -699,50 +53,36 @@ function scheduleClientConfigPoll() {
async function boot() {
updateTeamSegmented();
// Try to restore session first
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
// Still load config/grid in the background for display
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
scheduleScorePoll();
return;
} else {
hideAuthOverlay();
}
hideAuthOverlay();
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
await fetchAndApplyScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
cooldownEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
if (resetTimer) clearInterval(resetTimer);
resetTimer = setInterval(updateResetCountdown, 1_000);
updateResetCountdown();
scheduleConfigPoll();
scheduleScorePoll();
}
// ── Global event listeners ────────────────────────────────────────────────────
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Start ─────────────────────────────────────────────────────────────────────
boot();