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_order red black
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 @@
+
+
+
+resistance blue
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 @@
-
-
-
-
🚀
-
Desktop only
-
- Star Wars – Wild Space requires a desktop browser.
- The galaxy map is not playable on phones or small screens.
- Please open this page on a computer.
-
-
-
-
-
+
Team
- Blue
- Red
+ Resistance
+ First Order
@@ -90,23 +77,28 @@
-
+
+
+
+
Star Wars – Wild Space
-
100×100 — exploitable ring only (inner Ø60, outer Ø100)
+
100×100 — exploitable zone from playground SVG map
- Blue
+
+ Resistance
0
—
0
- Red
+ First Order
+
@@ -127,38 +119,87 @@
- clickCooldownSeconds
+ Temps avant prochain clic
—
- worldSeed
+ Graine de la carte
—
- next period (UTC)
+ Prochaine graine (UTC)
—
- resets in
+ Prochaine graine dans
--:--:--
-
-
Refresh
+
+
+ 🪐 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
+
+
+
+
+
+
+ ⚙ Options
+
+
+ Admin password
+
+
+
Unlock
+
+
+
-
+
-
+
+ ☰
+
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 {