refacto: Replaced useless DB queries by websocket calls + patching WS auth-token leak
This commit is contained in:
@@ -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.). */
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
106
public/src/realtime.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user