From c0f66d8cc0c1ed6384064dd98ca7a81963f02459 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Mon, 30 Mar 2026 11:28:47 +0200 Subject: [PATCH] feat: Adding economic system to do scoring --- .env.example | 4 + config/game.settings.json | 44 ++- docker-compose.yml | 1 + public/graphism/logo_first_order.svg | 61 ++++ public/graphism/logo_resistance.svg | 44 +++ public/graphism/playground.svg | 71 ++++ public/index.html | 109 ++++-- public/src/api.js | 18 +- public/src/economy.js | 125 +++++++ public/src/game.js | 296 ++++++++++++++-- public/src/main.js | 96 +++++- public/style.css | 494 +++++++++++++++++++++++---- server/configLoader.js | 6 +- server/db/gameDb.js | 32 ++ server/index.js | 1 + server/routes/game.js | 46 +++ 16 files changed, 1303 insertions(+), 145 deletions(-) create mode 100644 public/graphism/logo_first_order.svg create mode 100644 public/graphism/logo_resistance.svg create mode 100644 public/graphism/playground.svg create mode 100644 public/src/economy.js diff --git a/.env.example b/.env.example index 223d05f..d1ecb8f 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,9 @@ POSTGRES_USERS_USER=users POSTGRES_USERS_PASSWORD=CHANGE_ME POSTGRES_USERS_DB=star_wars_users +# ── Admin ──────────────────────────────────────────────────────────────────── +# Password to unlock the team-switching debug widget in the UI +ADMIN_PASSWORD=CHANGE_ME + # ── CORS ───────────────────────────────────────────────────────────────────── CORS_ORIGIN=* \ No newline at end of file diff --git a/config/game.settings.json b/config/game.settings.json index d63e502..f5a27d1 100644 --- a/config/game.settings.json +++ b/config/game.settings.json @@ -1,6 +1,44 @@ { - "clickCooldownSeconds": 5, + "clickCooldownSeconds": 2, "databaseWipeoutIntervalSeconds": 21600, "debugModeForTeams": true, - "configReloadIntervalSeconds": 30 -} + "configReloadIntervalSeconds": 30, + "resourceWorth": { + "common": { + "rock": 1, + "wood": 1, + "mineral": 2, + "stones": 2, + "liquid": 1, + "oil": 4, + "gas": 3, + "grain": 1, + "livestock": 2, + "fish": 1, + "plant": 1, + "goods": 4, + "animals": 2, + "science": 6, + "factory": 5, + "acid": 1 + }, + "rare": { + "rock": 3, + "wood": 3, + "mineral": 4, + "stones": 5, + "liquid": 2, + "oil": 7, + "gas": 6, + "grain": 3, + "livestock": 3, + "fish": 5, + "plant": 3, + "goods": 8, + "animals": 5, + "science": 15, + "factory": 12, + "acid": 3 + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 848df35..e99ed74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,7 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER:-game}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-star_wars_grid}" USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}" JWT_SECRET: ${JWT_SECRET} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} PORT: "${PORT:-8080}" CONFIG_FILE_PATH: /app/config/game.settings.json volumes: diff --git a/public/graphism/logo_first_order.svg b/public/graphism/logo_first_order.svg new file mode 100644 index 0000000..bb9c365 --- /dev/null +++ b/public/graphism/logo_first_order.svg @@ -0,0 +1,61 @@ + + + +first_orderredblack diff --git a/public/graphism/logo_resistance.svg b/public/graphism/logo_resistance.svg new file mode 100644 index 0000000..0b80a6a --- /dev/null +++ b/public/graphism/logo_resistance.svg @@ -0,0 +1,44 @@ + + + +resistanceblue diff --git a/public/graphism/playground.svg b/public/graphism/playground.svg new file mode 100644 index 0000000..85d0187 --- /dev/null +++ b/public/graphism/playground.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + outer_regions + + + galaxy + + diff --git a/public/index.html b/public/index.html index 73e19b2..cfd2e3e 100644 --- a/public/index.html +++ b/public/index.html @@ -7,19 +7,6 @@ - - -
@@ -61,11 +48,11 @@
@@ -75,13 +62,13 @@
- +
Team
- - + +
@@ -90,23 +77,28 @@
-
- clickCooldownSeconds + Temps avant prochain clic
- worldSeed + Graine de la carte
- next period (UTC) + Prochaine graine (UTC)
- resets in + Prochaine graine dans --:--:--
-
- + +
+ 🪐 Planet stats +
Stats are hidden until you click a tile.
+
+ + +
+ +
+ + Résistance + +0.000/s + + + + +0.000/s + Premier Ordre + +
+ +
+ + 0.000 + + + + + + 0.000 + +
- -
-
Selection
-
Stats are hidden until you click a tile.
+ +
+ 💰 Ressources économiques +
+

Chargement…

+
+
+ + +
+
+ ⚙ Options +
+
+ + +
+ + +
+
- +
- + + +
Click a cell in the ring. Planet stats stay hidden until you reveal a tile.
diff --git a/public/src/api.js b/public/src/api.js index 576600d..65a1b9e 100644 --- a/public/src/api.js +++ b/public/src/api.js @@ -48,4 +48,20 @@ export async function apiGetMe(token) { return fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` }, }); -} \ No newline at end of file +} + +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(); +} diff --git a/public/src/economy.js b/public/src/economy.js new file mode 100644 index 0000000..d6e37bb --- /dev/null +++ b/public/src/economy.js @@ -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} cells + * @param {object} resourceWorth - { common: { rock: 1, ... }, rare: { rock: 3, ... } } + * @returns {{ total: number, byResource: Map }} + * byResource keys are resource label strings (French names), values are credits/sec + */ +export function computeTeamIncome(team, cells, resourceWorth) { + /** @type {Map} 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} 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 ` + ${label} + ${catLabel} + ${worth} + ${incomeStr} + `; + }) + .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 `${lbl}${indicator}`; + }) + .join(""); + + return ` + ${headers} + ${tableRows} +
`; +} \ No newline at end of file diff --git a/public/src/game.js b/public/src/game.js index 2595c26..a38e790 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -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(); }); \ No newline at end of file +// 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(); }); diff --git a/public/src/main.js b/public/src/main.js index 9ffd1fd..34eeb9c 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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(); \ No newline at end of file diff --git a/public/style.css b/public/style.css index 3ad06d6..d1f9287 100644 --- a/public/style.css +++ b/public/style.css @@ -15,57 +15,6 @@ body { min-height: 100vh; } -/* ── Mobile / small-screen overlay ───────────────────────────────────────── */ - -.mobileOverlay { - display: none; /* hidden on desktop — shown via media query below */ - position: fixed; - inset: 0; - z-index: 200; - background: rgba(5, 8, 18, 0.97); - backdrop-filter: blur(16px); - align-items: center; - justify-content: center; - padding: 24px; - text-align: center; -} - -.mobileOverlayCard { - max-width: 360px; - padding: 40px 28px; - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(15, 22, 42, 0.9); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); -} - -.mobileOverlayIcon { - font-size: 52px; - margin-bottom: 16px; -} - -.mobileOverlayTitle { - margin: 0 0 12px; - font-size: 22px; - font-weight: 800; - letter-spacing: 0.2px; - color: rgba(113, 199, 255, 0.95); -} - -.mobileOverlayText { - margin: 0; - font-size: 14px; - line-height: 1.7; - color: rgba(233, 238, 246, 0.75); -} - -/* Show overlay on small / narrow screens */ -@media (max-width: 768px), (max-height: 500px) { - .mobileOverlay { - display: flex; - } -} - /* ── Auth overlay ─────────────────────────────────────────────────────────── */ .authOverlay { @@ -242,7 +191,7 @@ body { background: rgba(113, 199, 255, 0.28); } -/* ── Team corner (debug) ──────────────────────────────────────────────────── */ +/* ── Team corner (admin only) ─────────────────────────────────────────────── */ .teamCorner { position: fixed; @@ -327,6 +276,7 @@ body { .scoreBoard { display: flex; align-items: center; + justify-content: center; gap: 16px; padding: 10px 16px; border-radius: 14px; @@ -334,6 +284,7 @@ body { background: rgba(255, 255, 255, 0.04); font-size: 22px; font-weight: 800; + font-family: "Courier New", Courier, monospace; font-variant-numeric: tabular-nums; letter-spacing: 0.5px; } @@ -341,9 +292,16 @@ body { .scoreTeam { display: flex; align-items: center; + justify-content: center; gap: 10px; } +.team-logo { + width: 64px; + height: 64px; + display: block; +} + .scoreTeamName { font-size: 12px; font-weight: 700; @@ -380,17 +338,16 @@ body { .app { display: flex; flex-direction: row; - align-items: stretch; - height: 100vh; - overflow: hidden; + align-items: flex-start; + justify-content: center; + min-height: 100vh; } /* ── Left information column ──────────────────────────────────────────────── */ .infoColumn { - flex: 1 1 0; - min-width: 260px; - max-width: 420px; + flex: 0 0 500px; + width: 500px; display: flex; flex-direction: column; gap: 16px; @@ -399,6 +356,7 @@ body { overflow-x: hidden; background: rgba(7, 10, 20, 0.55); border-right: 1px solid rgba(255, 255, 255, 0.07); + min-height: 100vh; } /* Scrollbar styling for the info column */ @@ -413,6 +371,20 @@ body { border-radius: 4px; } +/* Mobile close button — hidden on desktop */ +.closeMenuBtn { + display: none; + align-self: flex-end; + padding: 6px 10px; + font-size: 16px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.07); + color: #e9eef6; + border-radius: 8px; + cursor: pointer; + margin-bottom: 4px; +} + .infoSection--title .h1 { font-size: 17px; font-weight: 700; @@ -431,6 +403,7 @@ body { display: flex; flex-direction: column; gap: 6px; + font-family: "Courier New", Courier, monospace; } .infoRow { @@ -455,10 +428,12 @@ body { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-family: "Courier New", Courier, monospace; } .infoVal code { font-size: 11px; + font-family: "Courier New", Courier, monospace; padding: 2px 7px; border-radius: 7px; background: rgba(255, 255, 255, 0.08); @@ -493,9 +468,11 @@ body { display: flex; align-items: baseline; gap: 2px; + font-family: "Courier New", Courier, monospace; } .countdown { + font-family: "Courier New", Courier, monospace; font-variant-numeric: tabular-nums; min-width: 2ch; text-align: right; @@ -510,6 +487,7 @@ body { #userDisplay { font-weight: 600; font-size: 12px; + font-family: "Courier New", Courier, monospace; } .logoutBtn { @@ -584,6 +562,7 @@ button:hover { margin: 0; padding: 12px 14px 14px; font-size: 12px; + font-family: "Courier New", Courier, monospace; line-height: 1.5; white-space: pre-wrap; word-break: break-word; @@ -595,17 +574,317 @@ button:hover { font-style: italic; } -/* ── Galaxy: square, pinned to the right ──────────────────────────────────── */ +/* ── Collapsible panel variant ────────────────────────────────────────────── */ + +.panelCollapsible { + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(255, 255, 255, 0.03); + overflow: hidden; + min-height: unset; +} + +.panelTitleSummary { + padding: 10px 14px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.7; + cursor: pointer; + list-style: none; + user-select: none; + display: flex; + align-items: center; + gap: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + transition: opacity 0.15s; +} + +.panelTitleSummary::-webkit-details-marker { + display: none; +} + +.panelCollapsible[open] .panelTitleSummary { + opacity: 0.9; +} + +.panelTitleSummary:hover { + opacity: 1; +} + +/* ── Team income summary ──────────────────────────────────────────────────── */ + +.econSummary { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + font-family: "Courier New", Courier, monospace; + font-size: 12px; +} + +.econSummaryTeam { + display: flex; + align-items: center; + gap: 8px; +} + +.econSummaryTeam--blue .econSummaryLabel { color: rgba(90, 200, 255, 0.85); } +.econSummaryTeam--blue .econSummaryVal { color: rgba(90, 200, 255, 1); font-weight: 700; } +.econSummaryTeam--red .econSummaryLabel { color: rgba(220, 75, 85, 0.85); } +.econSummaryTeam--red .econSummaryVal { color: rgba(220, 75, 85, 1); font-weight: 700; } + +.econSummarySep { + opacity: 0.3; +} + +/* ── Economic score (cumulative) ──────────────────────────────────────────── */ + +.econSummaryRow { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +.econSummaryRow--score { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 6px; + margin-top: 4px; +} + +.econScoreVal { + font-size: 13px; + font-weight: 800; + font-family: "Courier New", Courier, monospace; + font-variant-numeric: tabular-nums; +} + +.econSummaryTeam--blue .econScoreVal { color: rgba(90, 200, 255, 1); } +.econSummaryTeam--red .econScoreVal { color: rgba(220, 75, 85, 1); } + +/* Delta badge */ +@keyframes econDeltaFade { + 0% { opacity: 0; transform: translateY(2px); } + 12% { opacity: 1; transform: translateY(-3px); } + 70% { opacity: 1; transform: translateY(-3px); } + 100% { opacity: 0; transform: translateY(-7px); } +} + +.econDelta { + font-weight: 700; + font-size: 11px; + font-family: "Courier New", Courier, monospace; + color: rgba(90, 210, 130, 0.95); + opacity: 0; + min-width: 6ch; + display: inline-block; + text-align: center; +} + +.econDelta--active { + animation: econDeltaFade 3s ease forwards; +} + +/* ── Economy resource table ───────────────────────────────────────────────── */ + +.econTableWrap { + padding: 4px 8px 10px; + overflow-x: auto; +} + +.econEmpty { + margin: 10px 14px; + font-size: 12px; + opacity: 0.5; + font-style: italic; + font-family: "Courier New", Courier, monospace; +} + +.econTable { + width: 100%; + border-collapse: collapse; + font-size: 11px; + font-family: "Courier New", Courier, monospace; +} + +.econTable thead th { + padding: 6px 8px; + text-align: left; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.55; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +.econTh { + cursor: pointer; + user-select: none; + transition: opacity 0.15s; +} + +.econTh:hover { + opacity: 0.9; +} + +.econTh--active { + opacity: 0.9; + color: rgba(113, 199, 255, 0.95); +} + +.econSortIcon { + font-style: normal; + opacity: 0.55; + margin-left: 2px; + font-size: 9px; +} + +.econTh--active .econSortIcon { + opacity: 1; + color: rgba(113, 199, 255, 1); +} + +.econTable tbody tr:hover { + background: rgba(255, 255, 255, 0.04); +} + +.econTable td { + padding: 4px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.econ-label { + color: rgba(233, 238, 246, 0.9); + white-space: nowrap; +} + +.econ-cat { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.econ-cat--commun { + color: rgba(170, 180, 200, 0.7); +} + +.econ-cat--rare { + color: rgba(255, 210, 100, 0.85); +} + +.econ-worth { + text-align: right; + color: rgba(113, 199, 255, 0.85); + font-weight: 600; + white-space: nowrap; +} + +.econ-income { + text-align: right; + color: rgba(233, 238, 246, 0.4); + white-space: nowrap; +} + +.econ-income--positive { + color: rgba(90, 210, 130, 0.95); + font-weight: 700; +} + +/* ── Options / admin section ──────────────────────────────────────────────── */ + +.infoSection--options { + margin-top: auto; + padding-top: 8px; +} + +.optionsDetails { + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.07); + overflow: hidden; +} + +.optionsSummary { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + opacity: 0.6; + cursor: pointer; + list-style: none; + user-select: none; +} + +.optionsSummary::-webkit-details-marker { + display: none; +} + +.optionsSummary:hover { + opacity: 0.9; +} + +.optionsPanel { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.optionsPanel .authField input[type="password"] { + padding: 8px 10px; + font-size: 12px; + font-family: "Courier New", Courier, monospace; +} + +.adminUnlockBtn { + padding: 7px 12px; + font-size: 12px; + align-self: flex-start; +} + +.adminStatus { + font-size: 11px; + padding: 6px 10px; + border-radius: 7px; + font-family: "Courier New", Courier, monospace; +} + +.adminStatus.hidden { + display: none; +} + +.adminStatus--ok { + color: rgba(90, 200, 130, 0.95); + background: rgba(30, 120, 60, 0.15); + border: 1px solid rgba(30, 150, 70, 0.25); +} + +.adminStatus--err { + color: rgba(255, 130, 100, 0.95); + background: rgba(200, 50, 30, 0.12); + border: 1px solid rgba(200, 50, 30, 0.25); +} + +/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */ .galaxyMain { - /* Height fills the viewport; aspect-ratio keeps it perfectly square */ - flex: 0 0 auto; + /* Fit the tallest square that fits in the viewport without overlapping the info column */ + --map-size: min(100vh, calc(100vw - 340px)); position: relative; - height: 100vh; - aspect-ratio: 1 / 1; - /* Constrain width so the canvas never exceeds available space */ - max-width: calc(100vw - 260px); - background: #000; + width: var(--map-size); + height: var(--map-size); + flex-shrink: 0; + background: #050810; overflow: hidden; } @@ -616,12 +895,35 @@ canvas { image-rendering: pixelated; } +/* ── Burger button (hidden on desktop) ────────────────────────────────────── */ + +.burgerBtn { + display: none; + position: absolute; + top: 12px; + left: 12px; + z-index: 20; + width: 44px; + height: 44px; + padding: 0; + font-size: 22px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(7, 10, 20, 0.82); + backdrop-filter: blur(8px); + color: #e9eef6; + align-items: center; + justify-content: center; + cursor: pointer; +} + .hint { position: absolute; left: 14px; bottom: 14px; padding: 10px 12px; font-size: 12px; + font-family: "Courier New", Courier, monospace; opacity: 0.8; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); @@ -629,4 +931,64 @@ canvas { backdrop-filter: blur(6px); max-width: calc(100% - 28px); pointer-events: none; +} + +/* ── Mobile responsive (≤768px) ───────────────────────────────────────────── */ + +@media (max-width: 768px) { + .app { + flex-direction: column; + align-items: center; + min-height: 100dvh; + } + + /* Hide the left column by default on mobile */ + .infoColumn { + display: none; + position: fixed; + inset: 0; + z-index: 50; + width: 100%; + max-width: 420px; + min-height: unset; + height: 100dvh; + overflow-y: auto; + background: rgba(7, 10, 20, 0.97); + backdrop-filter: blur(16px); + border-right: none; + flex-shrink: 0; + } + + /* Show the panel when open */ + .infoColumn--open { + display: flex; + } + + /* Show close button on mobile */ + .closeMenuBtn { + display: flex; + } + + /* Show burger button on mobile */ + .burgerBtn { + display: flex; + } + + /* Galaxy scales to viewport width (square) */ + .galaxyMain { + --map-size: 100vw; + width: 100vw; + height: 100vw; + } + + /* Scoreboard wraps nicely on narrow screens */ + .scoreBoard { + flex-wrap: wrap; + font-size: 18px; + } + + .team-logo { + width: 44px; + height: 44px; + } } \ No newline at end of file diff --git a/server/configLoader.js b/server/configLoader.js index 8c83490..933643d 100644 --- a/server/configLoader.js +++ b/server/configLoader.js @@ -7,12 +7,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_FILE_PATH = process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json"); -/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number }} */ +/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object }} */ let cached = { clickCooldownSeconds: 5, databaseWipeoutIntervalSeconds: 21600, debugModeForTeams: true, configReloadIntervalSeconds: 30, + resourceWorth: { common: {}, rare: {} }, }; let lastMtimeMs = 0; @@ -46,6 +47,9 @@ export function loadConfigFile() { if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) { cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds; } + if (j.resourceWorth && typeof j.resourceWorth === "object") { + cached.resourceWorth = j.resourceWorth; + } lastMtimeMs = st.mtimeMs; } catch (e) { if (e.code === "ENOENT") { diff --git a/server/db/gameDb.js b/server/db/gameDb.js index 0050fae..7f5f514 100644 --- a/server/db/gameDb.js +++ b/server/db/gameDb.js @@ -29,6 +29,14 @@ export async function initGameSchema() { PRIMARY KEY (world_seed, team) ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS team_econ_scores ( + world_seed TEXT NOT NULL, + team TEXT NOT NULL CHECK (team IN ('blue', 'red')), + score DOUBLE PRECISION NOT NULL DEFAULT 0, + PRIMARY KEY (world_seed, team) + ); + `); await pool.query(` ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT; UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL; @@ -59,6 +67,7 @@ export async function ensureSeedEpoch() { if (seedSlot !== lastSeedSlot) { await pool.query("TRUNCATE grid_cells RESTART IDENTITY"); await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]); + await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]); console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`); lastSeedSlot = seedSlot; } @@ -115,6 +124,29 @@ export async function upsertTeamCooldown(worldSeed, team) { ); } +// ── Economic scores ─────────────────────────────────────────────────────────── + +export async function getEconScores(worldSeed) { + const { rows } = await pool.query( + `SELECT team, score FROM team_econ_scores WHERE world_seed = $1`, + [worldSeed] + ); + const result = { blue: 0, red: 0 }; + for (const row of rows) result[row.team] = Number(row.score); + return result; +} + +export async function addEconScore(worldSeed, team, delta) { + if (delta <= 0) return; + await pool.query( + `INSERT INTO team_econ_scores (world_seed, team, score) + VALUES ($1, $2, $3) + ON CONFLICT (world_seed, team) DO UPDATE + SET score = team_econ_scores.score + EXCLUDED.score`, + [worldSeed, team, delta] + ); +} + // ── Scores ──────────────────────────────────────────────────────────────────── export async function getScores(worldSeed) { diff --git a/server/index.js b/server/index.js index 392de82..04cca1d 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import "dotenv/config"; import { loadConfigFile, getConfig } from "./configLoader.js"; import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js"; import { initUsersSchema } from "./db/usersDb.js"; diff --git a/server/routes/game.js b/server/routes/game.js index 261ce3f..ae7d387 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -9,6 +9,8 @@ import { getTeamCooldown, upsertTeamCooldown, getScores, + getEconScores, + addEconScore, } from "../db/gameDb.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js"; @@ -43,6 +45,7 @@ router.get("/config", async (req, res) => { seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc, seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc, teamCooldownRemaining, + resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, }); } catch (e) { console.error(e); @@ -134,6 +137,49 @@ router.post("/cell/reveal", async (req, res) => { } }); +// POST /api/admin/verify +router.post("/admin/verify", (req, res) => { + const password = String(req.body?.password ?? ""); + const adminPwd = process.env.ADMIN_PASSWORD; + if (!adminPwd) { + return res.status(503).json({ ok: false, error: "not_configured" }); + } + if (password && password === adminPwd) { + return res.json({ ok: true }); + } + return res.status(401).json({ ok: false, error: "invalid_password" }); +}); + +// GET /api/econ-scores +router.get("/econ-scores", async (_req, res) => { + try { + const worldSeed = await ensureSeedEpoch(); + const scores = await getEconScores(worldSeed); + res.json(scores); + } catch (e) { + console.error(e); + res.status(500).json({ error: "database_error" }); + } +}); + +// POST /api/econ-scores/tick body: { seed, blue, red } +router.post("/econ-scores/tick", async (req, res) => { + const seed = String(req.body?.seed ?? ""); + const blue = Number(req.body?.blue ?? 0); + const red = Number(req.body?.red ?? 0); + try { + const worldSeed = await ensureSeedEpoch(); + if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed }); + await addEconScore(worldSeed, "blue", blue); + await addEconScore(worldSeed, "red", red); + const scores = await getEconScores(worldSeed); + res.json(scores); + } catch (e) { + console.error(e); + res.status(500).json({ error: "database_error" }); + } +}); + // GET /api/scores router.get("/scores", async (_req, res) => { try {