Private
Public Access
1
0

refacto: Replaced useless DB queries by websocket calls + patching WS auth-token leak

This commit is contained in:
gauvainboiche
2026-04-01 18:47:37 +02:00
parent e28a2d6e9c
commit f161ccb0f0
33 changed files with 6246 additions and 43 deletions

View File

@@ -5,7 +5,10 @@
export async function apiFetchConfig(team) {
const token = localStorage.getItem("authToken");
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`, { headers });
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`, {
headers,
cache: "no-store",
});
if (!res.ok) throw new Error("config_fetch_failed");
return res.json();
}
@@ -14,7 +17,10 @@ export async function apiFetchConfig(team) {
export async function apiFetchGrid(seed) {
const token = localStorage.getItem("authToken");
const headers = token ? { Authorization: `Bearer ${token}` } : {};
return fetch(`/api/grid/${encodeURIComponent(seed)}`, { headers });
return fetch(`/api/grid/${encodeURIComponent(seed)}`, {
headers,
cache: "no-store",
});
}
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */

View File

@@ -42,6 +42,7 @@ export function applyUser(user, token) {
const teamColor = user.team === "blue" ? "rgba(90,200,255,0.9)" : "rgba(220,75,85,0.9)";
userDisplayEl.innerHTML = `<span style="color:${teamColor}">${user.username}</span>`;
logoutBtn.classList.remove("hidden");
window.dispatchEvent(new CustomEvent("auth:changed"));
}
function logout() {
@@ -51,6 +52,7 @@ function logout() {
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
window.dispatchEvent(new CustomEvent("auth:changed"));
}
// ── Session restore ───────────────────────────────────────────────────────────

View File

@@ -314,20 +314,28 @@ export function updateResetCountdown() {
export async function loadVictoryPoints() {
try {
const { blue, red } = await apiFetchVictoryPoints();
if (vpBlueEl) vpBlueEl.textContent = String(blue ?? 0);
if (vpRedEl) vpRedEl.textContent = String(red ?? 0);
setVictoryPointsDisplay(blue ?? 0, red ?? 0);
} catch { /* ignore */ }
}
export async function fetchAndApplyActivePlayers() {
try {
const { blue, red } = await apiFetchActivePlayers();
const fmt = (n) => `${n} joueur${n > 1 ? "s" : ""}`;
if (activeCountBlueEl) activeCountBlueEl.textContent = fmt(blue ?? 0);
if (activeCountRedEl) activeCountRedEl.textContent = fmt(red ?? 0);
setActivePlayersDisplay(blue ?? 0, red ?? 0);
} catch { /* ignore */ }
}
function setVictoryPointsDisplay(blue, red) {
if (vpBlueEl) vpBlueEl.textContent = String(blue ?? 0);
if (vpRedEl) vpRedEl.textContent = String(red ?? 0);
}
function setActivePlayersDisplay(blue, red) {
const fmt = (n) => `${n} joueur${n > 1 ? "s" : ""}`;
if (activeCountBlueEl) activeCountBlueEl.textContent = fmt(blue ?? 0);
if (activeCountRedEl) activeCountRedEl.textContent = fmt(red ?? 0);
}
// ── Player list popup (click on joueur count) ─────────────────────────────────
function closePlayerListPopup() {
@@ -400,9 +408,7 @@ let milDeductRed = 0;
export async function loadMilitaryDeductions() {
try {
const { blue, red } = await apiFetchMilitaryDeductions();
milDeductBlue = blue ?? 0;
milDeductRed = red ?? 0;
updateEconomyDisplay();
applyMilitaryDeductionsUpdate({ blue, red });
} catch { /* ignore */ }
}
@@ -505,6 +511,85 @@ export function updateEconomyDisplay() {
let econScoreBlue = 0;
let econScoreRed = 0;
export function applyMilitaryDeductionsUpdate(deductions) {
if (!deductions || typeof deductions !== "object") return;
if (deductions.blue !== undefined && deductions.blue !== null) {
milDeductBlue = Number(deductions.blue);
}
if (deductions.red !== undefined && deductions.red !== null) {
milDeductRed = Number(deductions.red);
}
updateEconomyDisplay();
}
export function applyTeamQuotaUpdate(team, actionsRemainingValue) {
if (team !== currentTeam) return;
if (actionsRemainingValue === null || actionsRemainingValue === undefined) return;
teamActionsRemaining = Number(actionsRemainingValue);
updateTeamQuotaDisplay();
}
export function applyCellUpdate(cell) {
if (!cell || typeof cell !== "object") return;
const x = Number(cell.x);
const y = Number(cell.y);
if (!Number.isInteger(x) || !Number.isInteger(y)) return;
const key = cellKey(x, y);
const hadCell = cells.has(key);
cells.set(key, {
controlledBy: cell.discoveredBy ?? cell.discovered_by ?? null,
hasPlanet: Boolean(cell.hasPlanet ?? cell.has_planet),
planet: cell.planet ?? cell.planet_json ?? null,
capturedBy: cell.capturedBy ?? cell.captured_by ?? null,
});
if (!hadCell) {
markTileReveal(key);
}
updateEconomyDisplay();
draw();
refreshCursorFromLast();
}
export function applyRealtimeSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") return;
let shouldUpdateEconomy = false;
if (snapshot.scores && typeof snapshot.scores === "object") {
econScoreBlue = Number(snapshot.scores.blue ?? econScoreBlue);
econScoreRed = Number(snapshot.scores.red ?? econScoreRed);
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
}
if (snapshot.elementBonus && typeof snapshot.elementBonus === "object") {
elemBonusBlue = Number(snapshot.elementBonus.blue ?? elemBonusBlue);
elemBonusRed = Number(snapshot.elementBonus.red ?? elemBonusRed);
updateEffectiveCooldownDisplay();
shouldUpdateEconomy = true;
}
if (snapshot.militaryDeductions && typeof snapshot.militaryDeductions === "object") {
milDeductBlue = Number(snapshot.militaryDeductions.blue ?? milDeductBlue);
milDeductRed = Number(snapshot.militaryDeductions.red ?? milDeductRed);
shouldUpdateEconomy = true;
}
if (snapshot.activePlayers && typeof snapshot.activePlayers === "object") {
setActivePlayersDisplay(snapshot.activePlayers.blue ?? 0, snapshot.activePlayers.red ?? 0);
}
if (snapshot.victoryPoints && typeof snapshot.victoryPoints === "object") {
setVictoryPointsDisplay(snapshot.victoryPoints.blue ?? 0, snapshot.victoryPoints.red ?? 0);
}
if (shouldUpdateEconomy) {
updateEconomyDisplay();
}
}
/** Trigger the delta fade animation on an element. */
function showEconDelta(el, delta) {
if (!el || delta <= 0) return;
@@ -580,24 +665,56 @@ export async function fetchConfig() {
// ── 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), {
controlledBy: row.discovered_by ?? row.discoveredBy ?? null,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
capturedBy: row.captured_by ?? row.capturedBy ?? null,
});
export async function fetchGridForSeed(seed) {
let currentSeed = seed;
const seenSeeds = new Set();
for (let attempt = 0; attempt < 5; attempt++) {
const res = await apiFetchGrid(currentSeed);
if (res.status === 410) {
let nextSeed = "";
try {
const stale = await res.json();
if (typeof stale?.worldSeed === "string" && stale.worldSeed) {
nextSeed = stale.worldSeed;
}
} catch {
// ignore parse errors, fallback to /api/config
}
seenSeeds.add(currentSeed);
if (nextSeed) {
if (seenSeeds.has(nextSeed)) break;
GAME_CONFIG.worldSeed = nextSeed;
applySeed(nextSeed);
currentSeed = nextSeed;
continue;
}
await fetchConfig();
currentSeed = seedStr;
if (seenSeeds.has(currentSeed)) break;
continue;
}
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), {
controlledBy: row.discovered_by ?? row.discoveredBy ?? null,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
capturedBy: row.captured_by ?? row.capturedBy ?? null,
});
}
return;
}
throw new Error("grid_stale");
}
// ── Action quota ──────────────────────────────────────────────────────────────

View File

@@ -11,6 +11,10 @@ import {
loadDbInfo,
loadElementBonus,
loadMilitaryDeductions,
applyRealtimeSnapshot,
applyCellUpdate,
applyTeamQuotaUpdate,
applyMilitaryDeductionsUpdate,
refreshFromServer,
refreshGridDisplay,
loadPlayfieldMask,
@@ -23,6 +27,11 @@ import {
hideAuthOverlay,
} from "./auth.js";
import {
startRealtime,
restartRealtime,
} from "./realtime.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const hint = document.getElementById("hint");
@@ -36,8 +45,26 @@ const infoColumn = document.getElementById("infoColumn");
let configPollTimer = 0;
let scorePollTimer = 0;
let resetTimer = null;
let victoryPollTimer = 0;
let gridPollTimer = 0;
let fallbackPollingEnabled = false;
let syncInFlight = false;
function clearFallbackTimers() {
clearTimeout(configPollTimer);
clearTimeout(scorePollTimer);
if (victoryPollTimer) {
clearInterval(victoryPollTimer);
victoryPollTimer = 0;
}
if (gridPollTimer) {
clearInterval(gridPollTimer);
gridPollTimer = 0;
}
}
function scheduleConfigPoll() {
if (!fallbackPollingEnabled) return;
clearTimeout(configPollTimer);
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
configPollTimer = window.setTimeout(async () => {
@@ -52,6 +79,7 @@ function scheduleConfigPoll() {
const ECON_TICK_SECONDS = 5;
function scheduleScorePoll() {
if (!fallbackPollingEnabled) return;
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchAndApplyActivePlayers();
@@ -62,6 +90,74 @@ function scheduleScorePoll() {
}, ECON_TICK_SECONDS * 1_000);
}
function startFallbackPolling() {
if (fallbackPollingEnabled) return;
fallbackPollingEnabled = true;
scheduleConfigPoll();
scheduleScorePoll();
victoryPollTimer = setInterval(loadVictoryPoints, 30_000);
gridPollTimer = setInterval(refreshGridDisplay, 1_000);
}
function stopFallbackPolling() {
fallbackPollingEnabled = false;
clearFallbackTimers();
}
async function syncFromServer() {
if (syncInFlight) return;
syncInFlight = true;
try {
await refreshFromServer();
await loadVictoryPoints();
} finally {
syncInFlight = false;
}
}
function handleRealtimeMessage(message) {
if (!message || typeof message !== "object") return;
const worldSeed = typeof message.worldSeed === "string" ? message.worldSeed : null;
if (worldSeed && seedStr && worldSeed !== seedStr) {
syncFromServer();
return;
}
switch (message.type) {
case "snapshot":
applyRealtimeSnapshot(message);
break;
case "cell-updated":
if (message.cell) applyCellUpdate(message.cell);
break;
case "team-quota-updated":
applyTeamQuotaUpdate(message.team, message.actionsRemaining);
break;
case "military-deductions-updated":
applyMilitaryDeductionsUpdate(message.deductions);
break;
case "config-updated":
case "seed-changed":
syncFromServer();
break;
default:
break;
}
}
function startRealtimeFlow() {
startRealtime({
onOpen: () => {
stopFallbackPolling();
},
onClose: () => {
startFallbackPolling();
},
onMessage: handleRealtimeMessage,
});
}
// ── Burger / mobile menu ──────────────────────────────────────────────────────
function openMenu() {
@@ -101,7 +197,13 @@ async function boot() {
try {
await fetchConfig();
await fetchGridForSeed(seedStr);
try {
await fetchGridForSeed(seedStr);
} catch (e) {
if (e?.message !== "grid_stale") throw e;
await fetchConfig();
await fetchGridForSeed(seedStr);
}
await fetchAndApplyActivePlayers();
await loadEconScores();
await loadVictoryPoints();
@@ -120,14 +222,11 @@ async function boot() {
resetTimer = setInterval(updateResetCountdown, 1_000);
updateResetCountdown();
scheduleConfigPoll();
scheduleScorePoll();
startRealtimeFlow();
// Refresh VP every 30 s so new awards are reflected promptly
setInterval(loadVictoryPoints, 30_000);
// Refresh grid every second so all clients see new tiles promptly
setInterval(refreshGridDisplay, 1_000);
window.addEventListener("auth:changed", () => {
restartRealtime();
});
}
// ── Start ─────────────────────────────────────────────────────────────────────

106
public/src/realtime.js Normal file
View File

@@ -0,0 +1,106 @@
let socket = null;
let reconnectTimer = 0;
let reconnectAttempt = 0;
let stopped = false;
let handlers = null;
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = 0;
}
}
function buildSocketUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function sendAuthHandshake() {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const token = localStorage.getItem("authToken");
if (!token) return;
socket.send(JSON.stringify({
type: "auth",
token,
}));
}
function scheduleReconnect() {
if (stopped) return;
clearReconnectTimer();
const delay = Math.min(10_000, 1_000 * 2 ** reconnectAttempt);
reconnectAttempt += 1;
reconnectTimer = window.setTimeout(() => {
connect();
}, delay);
}
function connect() {
if (stopped) return;
clearReconnectTimer();
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
socket = new WebSocket(buildSocketUrl());
socket.addEventListener("open", () => {
reconnectAttempt = 0;
sendAuthHandshake();
handlers?.onOpen?.();
});
socket.addEventListener("message", (event) => {
try {
const data = JSON.parse(event.data);
handlers?.onMessage?.(data);
} catch {
// ignore invalid payloads
}
});
socket.addEventListener("close", () => {
handlers?.onClose?.();
scheduleReconnect();
});
socket.addEventListener("error", () => {
// Let close handler trigger reconnect.
});
}
export function startRealtime(nextHandlers = {}) {
handlers = nextHandlers;
stopped = false;
connect();
}
export function restartRealtime() {
if (stopped) return;
if (socket) {
try {
socket.close();
} catch {
// ignore
}
}
connect();
}
export function stopRealtime() {
stopped = true;
clearReconnectTimer();
if (!socket) return;
try {
socket.close();
} catch {
// ignore
}
socket = null;
}
export function isRealtimeConnected() {
return Boolean(socket && socket.readyState === WebSocket.OPEN);
}