Private
Public Access
1
0

feat: Adding economic system to do scoring

This commit is contained in:
gauvainboiche
2026-03-30 11:28:47 +02:00
parent b19fb262a4
commit c0f66d8cc0
16 changed files with 1303 additions and 145 deletions

View File

@@ -48,4 +48,20 @@ export async function apiGetMe(token) {
return fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
}
}
export async function apiFetchEconScores() {
const res = await fetch("/api/econ-scores");
if (!res.ok) throw new Error("econ_scores_fetch_failed");
return res.json();
}
export async function apiTickEconScores(seed, blue, red) {
const res = await fetch("/api/econ-scores/tick", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, blue, red }),
});
if (!res.ok) throw new Error("econ_tick_failed");
return res.json();
}

125
public/src/economy.js Normal file
View File

@@ -0,0 +1,125 @@
import { resources } from "./planetEconomy.js";
// ── Sort state ────────────────────────────────────────────────────────────────
/** 0=Ressource, 1=Rareté, 2=Valeur, 3=Revenu/s */
let _sortCol = 3;
let _sortDir = "desc";
export function setEconSort(col, dir) {
_sortCol = col;
_sortDir = dir;
}
export function getEconSort() {
return { col: _sortCol, dir: _sortDir };
}
// ── Label → resource key lookup ───────────────────────────────────────────────
/** Map from French label string → { cat: "common"|"rare", key: string } */
const LABEL_TO_RESOURCE = (() => {
const map = new Map();
for (const [cat, entries] of Object.entries(resources)) {
for (const [key, label] of Object.entries(entries)) {
map.set(label, { cat, key });
}
}
return map;
})();
// ── Income calculation ────────────────────────────────────────────────────────
/**
* Compute income per second for a team based on their discovered planets.
*
* @param {string} team - "blue" or "red"
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
* @param {object} resourceWorth - { common: { rock: 1, ... }, rare: { rock: 3, ... } }
* @returns {{ total: number, byResource: Map<string, number> }}
* byResource keys are resource label strings (French names), values are credits/sec
*/
export function computeTeamIncome(team, cells, resourceWorth) {
/** @type {Map<string, number>} label → cumulative income/sec */
const byResource = new Map();
let total = 0;
for (const [, meta] of cells) {
if (meta.discoveredBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const { naturalResources } = meta.planet;
if (!naturalResources) continue;
for (const [label, pct] of Object.entries(naturalResources)) {
const info = LABEL_TO_RESOURCE.get(label);
if (!info) continue;
const worth = resourceWorth?.[info.cat]?.[info.key] ?? 0;
if (worth === 0) continue;
const income = (pct / 100) * worth;
byResource.set(label, (byResource.get(label) ?? 0) + income);
total += income;
}
}
return { total, byResource };
}
// ── Resource table for the sidebar ───────────────────────────────────────────
/**
* Renders the resource overview table for the economy panel.
*
* @param {object} resourceWorth - { common: {…}, rare: {…} }
* @param {Map<string, number>} teamByResource - income/sec per label for current team
* @returns {string} HTML string
*/
export function renderResourceTable(resourceWorth, teamByResource) {
const rows = [];
for (const [cat, entries] of Object.entries(resources)) {
for (const [key, label] of Object.entries(entries)) {
const worth = resourceWorth?.[cat]?.[key] ?? 0;
const income = teamByResource?.get(label) ?? 0;
const incomeStr = income > 0 ? `+${income.toFixed(3)}/s` : "—";
const catLabel = cat === "rare" ? "Rare" : "Commun";
rows.push({ label, catLabel, worth, income, incomeStr });
}
}
// Sort by selected column
const mult = _sortDir === "asc" ? 1 : -1;
rows.sort((a, b) => {
if (_sortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
if (_sortCol === 1) return mult * a.catLabel.localeCompare(b.catLabel, "fr");
if (_sortCol === 2) return mult * (a.worth - b.worth);
if (_sortCol === 3) return mult * (a.income - b.income);
return b.income - a.income || b.worth - a.worth;
});
const tableRows = rows
.map(({ label, catLabel, worth, incomeStr, income }) => {
const incomeClass = income > 0 ? " econ-income--positive" : "";
return `<tr>
<td class="econ-label">${label}</td>
<td class="econ-cat econ-cat--${catLabel.toLowerCase()}">${catLabel}</td>
<td class="econ-worth">${worth}</td>
<td class="econ-income${incomeClass}">${incomeStr}</td>
</tr>`;
})
.join("");
const thLabels = ["Ressource", "Rareté", "Valeur", "Revenu/s"];
const headers = thLabels
.map((lbl, i) => {
const isActive = i === _sortCol;
const indicator = isActive ? (_sortDir === "asc" ? " ▲" : " ▼") : " ⇅";
const activeClass = isActive ? " econTh--active" : "";
return `<th class="econTh${activeClass}" data-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
})
.join("");
return `<table class="econTable">
<thead><tr>${headers}</tr></thead>
<tbody>${tableRows}</tbody>
</table>`;
}

View File

@@ -1,30 +1,33 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores } from "./api.js";
import { computeTeamIncome, renderResourceTable, setEconSort, getEconSort } from "./economy.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)";
// SVG playfield dimensions (must match /graphism/playground.svg viewBox)
const SVG_W = 1000;
const SVG_H = 1000;
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,
debugModeForTeams: false,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
resourceWorth: { common: {}, rare: {} },
};
window.GAME_CONFIG = GAME_CONFIG;
@@ -45,6 +48,93 @@ export const teamCooldownEndMs = { blue: 0, red: 0 };
let rafId = 0;
let lastPointerEvent = null;
// ── Playfield mask ────────────────────────────────────────────────────────────
/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
let exploitableTiles = null;
/** Preloaded SVG image for background rendering. */
let playfieldImg = null;
/**
* Loads /graphism/playground.svg, rasterises it to an offscreen canvas,
* then classifies each 100×100 grid tile as exploitable if ≥80 % of its
* sampled pixels are the black playable zone (R≤20, G≤20, B≤20).
*/
export function loadPlayfieldMask() {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
playfieldImg = img;
// ── Rasterise SVG at native size onto an offscreen canvas ──
const oc = document.createElement("canvas");
oc.width = SVG_W;
oc.height = SVG_H;
const octx = oc.getContext("2d");
// White fill so transparent SVG areas appear white (not black).
octx.fillStyle = "#ffffff";
octx.fillRect(0, 0, SVG_W, SVG_H);
octx.drawImage(img, 0, 0, SVG_W, SVG_H);
let imageData;
try {
imageData = octx.getImageData(0, 0, SVG_W, SVG_H);
} catch (e) {
console.warn("[playfield] canvas tainted no exploitable tiles:", e);
exploitableTiles = new Set();
resolve();
return;
}
const data = imageData.data; // RGBA flat array
const SAMPLES_X = 10;
const SAMPLES_Y = 10;
const cellW = SVG_W / GRID_W; // 7.5 px
const cellH = SVG_H / GRID_H; // 10 px
const tiles = new Set();
for (let gy = 0; gy < GRID_H; gy++) {
for (let gx = 0; gx < GRID_W; gx++) {
let blackCount = 0;
for (let sy = 0; sy < SAMPLES_Y; sy++) {
for (let sx = 0; sx < SAMPLES_X; sx++) {
const px = Math.min(
SVG_W - 1,
Math.floor(gx * cellW + (sx + 0.5) * cellW / SAMPLES_X)
);
const py = Math.min(
SVG_H - 1,
Math.floor(gy * cellH + (sy + 0.5) * cellH / SAMPLES_Y)
);
const idx = (py * SVG_W + px) * 4;
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
// Pure black = playable zone (path2, fill:#000000)
if (r <= 20 && g <= 20 && b <= 20) blackCount++;
}
}
const coverage = blackCount / (SAMPLES_X * SAMPLES_Y);
if (coverage >= 0.8) tiles.add(cellKey(gx, gy));
}
}
exploitableTiles = tiles;
console.log(`[playfield] ${tiles.size} exploitable tiles loaded.`);
resolve();
};
img.onerror = () => {
console.warn("[playfield] failed to load playground.svg");
exploitableTiles = new Set();
resolve();
};
img.src = "/graphism/playground.svg";
});
}
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
@@ -57,8 +147,15 @@ 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 scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const incomeBlueEl = document.getElementById("incomeBlue");
const incomeRedEl = document.getElementById("incomeRed");
const resourceTableEl = document.getElementById("resourceTableBody");
const econScoreBlueEl = document.getElementById("econScoreBlue");
const econScoreRedEl = document.getElementById("econScoreRed");
const econDeltaBlueEl = document.getElementById("econDeltaBlue");
const econDeltaRedEl = document.getElementById("econDeltaRed");
const teamCorner = document.getElementById("teamCorner");
const teamTrack = document.getElementById("teamSegmentedTrack");
const teamBlueBtn = document.getElementById("teamBlue");
@@ -68,16 +165,9 @@ const teamRedBtn = document.getElementById("teamRed");
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;
if (!exploitableTiles) return false;
return exploitableTiles.has(cellKey(x, y));
}
function hasPlanetAt(x, y) {
@@ -98,6 +188,9 @@ export function applyConfigPayload(data) {
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
if (data.resourceWorth && typeof data.resourceWorth === "object") {
GAME_CONFIG.resourceWorth = data.resourceWorth;
}
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
@@ -107,11 +200,8 @@ export function applyConfigPayload(data) {
updateResetCountdown();
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
// Team switcher visibility is managed exclusively via unlockTeamSwitcher() /
// lockTeamSwitcher() — NOT by debugModeForTeams config.
}
export function updateResetCountdown() {
@@ -125,6 +215,18 @@ export function updateResetCountdown() {
String(s % 60).padStart(2, "0");
}
// ── Team switcher (admin-only) ────────────────────────────────────────────────
/** Show the team switcher widget. Called only after successful admin unlock. */
export function unlockTeamSwitcher() {
teamCorner.classList.remove("teamCorner--hidden");
}
/** Hide the team switcher widget. */
export function lockTeamSwitcher() {
teamCorner.classList.add("teamCorner--hidden");
}
// ── Scores ────────────────────────────────────────────────────────────────────
export async function fetchAndApplyScores() {
@@ -135,6 +237,92 @@ export async function fetchAndApplyScores() {
} catch { /* ignore */ }
}
// ── Economy display ───────────────────────────────────────────────────────────
/** Recalculates and renders team income and resource table. */
export function updateEconomyDisplay() {
const worth = GAME_CONFIG.resourceWorth;
const blueIncome = computeTeamIncome("blue", cells, worth);
const redIncome = computeTeamIncome("red", cells, worth);
if (incomeBlueEl) incomeBlueEl.textContent = `+${blueIncome.total.toFixed(3)}/s`;
if (incomeRedEl) incomeRedEl.textContent = `+${redIncome.total.toFixed(3)}/s`;
// Resource table shows own-team breakdown
const teamIncome = currentTeam === "blue" ? blueIncome : redIncome;
if (resourceTableEl) {
resourceTableEl.innerHTML = renderResourceTable(worth, teamIncome.byResource);
}
}
// ── Economic score ────────────────────────────────────────────────────────────
let econScoreBlue = 0;
let econScoreRed = 0;
/** Trigger the delta fade animation on an element. */
function showEconDelta(el, delta) {
if (!el || delta <= 0) return;
el.textContent = `+${delta.toFixed(3)}`;
el.classList.remove("econDelta--active");
// Force reflow so removing/re-adding the class restarts the animation
void el.offsetWidth;
el.classList.add("econDelta--active");
}
/**
* Called every ECON_TICK_MS milliseconds.
* Adds income × (interval in seconds) to each team's cumulative score,
* updates the score display, and triggers the delta animation.
*
* @param {number} intervalSeconds - how many seconds elapsed since last tick
*/
export async function tickEconScore(intervalSeconds) {
const worth = GAME_CONFIG.resourceWorth;
const blueIncome = computeTeamIncome("blue", cells, worth);
const redIncome = computeTeamIncome("red", cells, worth);
const blueDelta = blueIncome.total * intervalSeconds;
const redDelta = redIncome.total * intervalSeconds;
try {
const scores = await apiTickEconScores(seedStr, blueDelta, redDelta);
econScoreBlue = scores.blue;
econScoreRed = scores.red;
} catch {
// fallback: update locally if server unreachable
econScoreBlue += blueDelta;
econScoreRed += redDelta;
}
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
showEconDelta(econDeltaBlueEl, blueDelta);
showEconDelta(econDeltaRedEl, redDelta);
}
/** Load economic scores from server (called on boot / after seed change). */
export async function loadEconScores() {
try {
const scores = await apiFetchEconScores();
econScoreBlue = scores.blue ?? 0;
econScoreRed = scores.red ?? 0;
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
} catch { /* ignore */ }
}
/** Reset economic scores (called when world period changes). */
export function resetEconScores() {
econScoreBlue = 0;
econScoreRed = 0;
if (econScoreBlueEl) econScoreBlueEl.textContent = "0.000";
if (econScoreRedEl) econScoreRedEl.textContent = "0.000";
}
// ── Config fetch + apply ──────────────────────────────────────────────────────
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
@@ -186,6 +374,8 @@ function tickCooldown() {
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
// Auto-refresh from server when cooldown expires
refreshFromServer();
return;
}
countdownWrap.classList.remove("hidden");
@@ -222,24 +412,33 @@ export function clearCooldown() {
export function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
// 1. Dark base fill (shows in areas outside the SVG paths)
ctx.fillStyle = "#050810";
ctx.fillRect(0, 0, w, h);
// 2. Draw the SVG playfield as the background layer
if (playfieldImg) {
ctx.drawImage(playfieldImg, 0, 0, w, h);
}
const cw = w / GRID_W;
const ch = h / GRID_H;
// 3. Draw game tile overlays (only for exploitable tiles)
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; }
if (!isExploitable(x, y)) 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;
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);
}
}
// 4. Draw planet dots on own discovered tiles
for (const [key, meta] of cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
@@ -253,6 +452,7 @@ export function draw() {
ctx.fill();
}
// 5. Grid lines between adjacent exploitable tiles
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
@@ -369,6 +569,7 @@ async function onCanvasClick(ev) {
if (!res.ok) throw new Error("reveal");
applyRevealPayload(await res.json());
startCooldown();
updateEconomyDisplay();
draw();
fetchAndApplyScores();
} catch (e) {
@@ -385,6 +586,18 @@ export function updateTeamSegmented() {
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
// ── Lightweight grid refresh (called every second) ────────────────────────────
/** Fetches the current grid and redraws without touching config or scores. */
export async function refreshGridDisplay() {
try {
await fetchGridForSeed(seedStr);
updateEconomyDisplay();
draw();
refreshCursorFromLast();
} catch { /* ignore */ }
}
// ── Full server refresh ───────────────────────────────────────────────────────
// Exported so auth.js / main.js can call it after login or on timer.
@@ -393,12 +606,15 @@ export async function refreshFromServer() {
const seedChanged = await fetchConfig();
if (seedChanged) {
clearCooldown();
resetEconScores();
loadEconScores();
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.";
hint.textContent = "World period changed — grid reset. Click a cell in the playfield.";
}
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
updateEconomyDisplay();
draw();
refreshCursorFromLast();
} catch {
@@ -408,9 +624,25 @@ export async function refreshFromServer() {
// ── Event listeners ───────────────────────────────────────────────────────────
// ── Resource table sort (delegated, set up once) ──────────────────────────────
resourceTableEl?.addEventListener("click", (ev) => {
const th = ev.target.closest("th[data-sort-col]");
if (!th) return;
const col = Number(th.dataset.sortCol);
const { col: curCol, dir: curDir } = getEconSort();
// Toggle direction if same column; otherwise default to desc for numeric cols, asc for text
const newDir = col === curCol
? (curDir === "asc" ? "desc" : "asc")
: (col === 2 || col === 3 ? "desc" : "asc");
setEconSort(col, newDir);
updateEconomyDisplay();
});
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(); });
// Team switcher buttons (kept in codebase, only functional when admin-unlocked)
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });

View File

@@ -6,8 +6,14 @@ import {
fetchConfig,
fetchGridForSeed,
fetchAndApplyScores,
updateEconomyDisplay,
tickEconScore,
loadEconScores,
refreshFromServer,
refreshGridDisplay,
loadPlayfieldMask,
draw,
unlockTeamSwitcher,
} from "./game.js";
import {
@@ -18,9 +24,14 @@ import {
// ── DOM refs ──────────────────────────────────────────────────────────────────
const refreshBtn = document.getElementById("refreshBtn");
const hint = document.getElementById("hint");
const cooldownEl = document.getElementById("cooldownConfig");
const hint = document.getElementById("hint");
const cooldownEl = document.getElementById("cooldownConfig");
const burgerBtn = document.getElementById("burgerBtn");
const closeMenuBtn = document.getElementById("closeMenuBtn");
const infoColumn = document.getElementById("infoColumn");
const adminPasswordIn = document.getElementById("adminPasswordInput");
const adminUnlockBtn = document.getElementById("adminUnlockBtn");
const adminStatus = document.getElementById("adminStatus");
// ── Polling ───────────────────────────────────────────────────────────────────
@@ -40,19 +51,87 @@ function scheduleConfigPoll() {
}, ms);
}
const ECON_TICK_SECONDS = 5;
function scheduleScorePoll() {
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchAndApplyScores();
tickEconScore(ECON_TICK_SECONDS);
scheduleScorePoll();
}, 5_000);
}, ECON_TICK_SECONDS * 1_000);
}
// ── Burger / mobile menu ──────────────────────────────────────────────────────
function openMenu() {
infoColumn.classList.add("infoColumn--open");
}
function closeMenu() {
infoColumn.classList.remove("infoColumn--open");
}
burgerBtn.addEventListener("click", openMenu);
closeMenuBtn.addEventListener("click", closeMenu);
// Close when clicking outside the panel (on the galaxy overlay)
document.addEventListener("click", (ev) => {
if (
infoColumn.classList.contains("infoColumn--open") &&
!infoColumn.contains(ev.target) &&
ev.target !== burgerBtn
) {
closeMenu();
}
});
// ── Admin password unlock ─────────────────────────────────────────────────────
function showAdminStatus(message, isOk) {
adminStatus.textContent = message;
adminStatus.className = "adminStatus " + (isOk ? "adminStatus--ok" : "adminStatus--err");
}
adminUnlockBtn.addEventListener("click", async () => {
const password = adminPasswordIn.value.trim();
if (!password) {
showAdminStatus("Enter a password.", false);
return;
}
try {
const res = await fetch("/api/admin/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (res.ok && data.ok) {
showAdminStatus("Admin unlocked — team switcher enabled.", true);
unlockTeamSwitcher();
adminPasswordIn.value = "";
} else {
showAdminStatus("Invalid password.", false);
}
} catch {
showAdminStatus("Could not verify — server unreachable.", false);
}
});
// Allow Enter key in password field
adminPasswordIn.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") adminUnlockBtn.click();
});
// ── Boot ──────────────────────────────────────────────────────────────────────
async function boot() {
updateTeamSegmented();
// Load the SVG playfield mask before any drawing or data fetch
await loadPlayfieldMask();
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
@@ -64,6 +143,8 @@ async function boot() {
await fetchConfig();
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
await loadEconScores();
updateEconomyDisplay();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
cooldownEl.textContent = "?";
@@ -77,12 +158,11 @@ async function boot() {
scheduleConfigPoll();
scheduleScorePoll();
// Refresh grid every second so all clients see new tiles promptly
setInterval(refreshGridDisplay, 1_000);
}
// ── Global event listeners ────────────────────────────────────────────────────
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Start ─────────────────────────────────────────────────────────────────────
boot();