diff --git a/config/game.settings.json b/config/game.settings.json
index f5a27d1..6c15b1f 100644
--- a/config/game.settings.json
+++ b/config/game.settings.json
@@ -1,44 +1,54 @@
{
- "clickCooldownSeconds": 2,
+ "clickCooldownSeconds": 100,
"databaseWipeoutIntervalSeconds": 21600,
"debugModeForTeams": true,
"configReloadIntervalSeconds": 30,
+ "elementWorth": {
+ "common": 1,
+ "petrol": 3,
+ "food": 2,
+ "medic": 5,
+ "science": 8,
+ "industry": 4,
+ "money": 6,
+ "goods": 3
+ },
"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
+ "rock": 100,
+ "wood": 120,
+ "plant": 120,
+ "grain": 150,
+ "liquid": 180,
+ "fish": 200,
+ "stones": 220,
+ "animals": 300,
+ "livestock": 350,
+ "acid": 400,
+ "mineral": 450,
+ "gas": 800,
+ "oil": 1000,
+ "goods": 1200,
+ "factory": 1500,
+ "science": 2000
},
"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
+ "rock": 300,
+ "wood": 400,
+ "plant": 500,
+ "grain": 450,
+ "liquid": 600,
+ "fish": 900,
+ "stones": 1200,
+ "animals": 1500,
+ "livestock": 1400,
+ "acid": 1800,
+ "mineral": 2500,
+ "gas": 3000,
+ "oil": 3500,
+ "goods": 4000,
+ "factory": 4500,
+ "science": 5000
}
}
}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index cfd2e3e..d3632fb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -89,17 +89,35 @@
-
-
- Resistance
- 0
-
+

+
+
Résistance
+
+
+ 0
+ planètes
+
+
+ 0
+ pts victoire
+
+
+
—
-
- 0
- First Order
-
-
+
+
Premier Ordre
+
+
+ 0
+ pts victoire
+
+
+ 0
+ planètes
+
+
+
+
@@ -134,6 +152,10 @@
Prochaine graine dans
--:--:--
+
+ Base de données depuis
+ —
+
@@ -178,6 +200,28 @@
+
+
+
⚡ Bonus de recharge planétaire
+
+
+ Résistance
+ 0.00
+ %
+
+ —
+
+ 0.00
+ %
+ Premier Ordre
+
+
+
+ Recharge effective (votre équipe) :
+ —
+
+
+
diff --git a/public/src/api.js b/public/src/api.js
index 65a1b9e..8b17cb2 100644
--- a/public/src/api.js
+++ b/public/src/api.js
@@ -65,3 +65,31 @@ export async function apiTickEconScores(seed, blue, red) {
if (!res.ok) throw new Error("econ_tick_failed");
return res.json();
}
+
+export async function apiFetchElementBonus() {
+ const res = await fetch("/api/element-bonus");
+ if (!res.ok) throw new Error("element_bonus_fetch_failed");
+ return res.json();
+}
+
+export async function apiTickElementBonus(seed, blue, red) {
+ const res = await fetch("/api/element-bonus/tick", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ seed, blue, red }),
+ });
+ if (!res.ok) throw new Error("element_bonus_tick_failed");
+ return res.json();
+}
+
+export async function apiFetchDbInfo() {
+ const res = await fetch("/api/db-info");
+ if (!res.ok) throw new Error("db_info_fetch_failed");
+ return res.json();
+}
+
+export async function apiFetchVictoryPoints() {
+ const res = await fetch("/api/victory-points");
+ if (!res.ok) throw new Error("vp_fetch_failed");
+ return res.json();
+}
diff --git a/public/src/economy.js b/public/src/economy.js
index d6e37bb..20b652a 100644
--- a/public/src/economy.js
+++ b/public/src/economy.js
@@ -1,4 +1,4 @@
-import { resources } from "./planetEconomy.js";
+import { resources, elements } from "./planetEconomy.js";
// ── Sort state ────────────────────────────────────────────────────────────────
@@ -64,6 +64,46 @@ export function computeTeamIncome(team, cells, resourceWorth) {
return { total, byResource };
}
+// ── Element bonus calculation ─────────────────────────────────────────────────
+
+/**
+ * Reverse map: French element label → config key
+ * e.g. "Matières premières" → "common", "Hydrocarbures" → "petrol"
+ */
+const ELEMENT_LABEL_TO_KEY = Object.fromEntries(
+ Object.entries(elements).map(([key, label]) => [label, key])
+);
+
+/**
+ * Compute cumulative element bonus for a team based on their planets' production.
+ * planet.production stores French label strings as keys (values from elements const).
+ * bonus = sum_over_planets( sum_over_elements( elementShare% / 100 * elementWorth[key] ) )
+ *
+ * @param {string} team
+ * @param {Map} cells
+ * @param {object} elementWorth - { common: 1, petrol: 3, ... }
+ * @returns {number} bonus value (use as %)
+ */
+export function computeTeamElementBonus(team, cells, elementWorth) {
+ let bonus = 0;
+ for (const [, meta] of cells) {
+ if (meta.discoveredBy !== team) continue;
+ if (!meta.hasPlanet || !meta.planet) continue;
+ const { production } = meta.planet;
+ if (!production) continue;
+ for (const [elementLabel, pct] of Object.entries(production)) {
+ // production keys are French labels; map back to config key
+ const elementKey = ELEMENT_LABEL_TO_KEY[elementLabel] ?? elementLabel;
+ const worth = elementWorth?.[elementKey] ?? 0;
+ if (worth === 0) continue;
+ bonus += (pct / 100) * worth;
+ }
+ }
+ return bonus;
+}
+
+export { elements };
+
// ── Resource table for the sidebar ───────────────────────────────────────────
/**
diff --git a/public/src/game.js b/public/src/game.js
index a38e790..d0497e3 100644
--- a/public/src/game.js
+++ b/public/src/game.js
@@ -1,7 +1,7 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
-import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores } from "./api.js";
-import { computeTeamIncome, renderResourceTable, setEconSort, getEconSort } from "./economy.js";
+import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints } from "./api.js";
+import { computeTeamIncome, computeTeamElementBonus, renderResourceTable, setEconSort, getEconSort } from "./economy.js";
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -27,6 +27,7 @@ export const GAME_CONFIG = {
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
+ elementWorth: {},
resourceWorth: { common: {}, rare: {} },
};
window.GAME_CONFIG = GAME_CONFIG;
@@ -149,6 +150,12 @@ const nextPeriodEl = document.getElementById("nextPeriodUtc");
const resetCountEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
+const vpBlueEl = document.getElementById("vpBlue");
+const vpRedEl = document.getElementById("vpRed");
+const dbCreatedAtEl = document.getElementById("dbCreatedAt");
+const elemBonusBlueEl = document.getElementById("elemBonusBlue");
+const elemBonusRedEl = document.getElementById("elemBonusRed");
+const effectiveCooldownEl = document.getElementById("effectiveCooldown");
const incomeBlueEl = document.getElementById("incomeBlue");
const incomeRedEl = document.getElementById("incomeRed");
const resourceTableEl = document.getElementById("resourceTableBody");
@@ -188,6 +195,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.elementWorth && typeof data.elementWorth === "object") {
+ GAME_CONFIG.elementWorth = data.elementWorth;
+ }
if (data.resourceWorth && typeof data.resourceWorth === "object") {
GAME_CONFIG.resourceWorth = data.resourceWorth;
}
@@ -232,8 +242,77 @@ export function lockTeamSwitcher() {
export async function fetchAndApplyScores() {
try {
const { blue, red } = await apiFetchScores();
- scoreBlueEl.textContent = String(blue ?? 0);
- scoreRedEl.textContent = String(red ?? 0);
+ if (scoreBlueEl) scoreBlueEl.textContent = String(blue ?? 0);
+ if (scoreRedEl) scoreRedEl.textContent = String(red ?? 0);
+ } catch { /* ignore */ }
+}
+
+export async function loadVictoryPoints() {
+ try {
+ const { blue, red } = await apiFetchVictoryPoints();
+ if (vpBlueEl) vpBlueEl.textContent = String(blue ?? 0);
+ if (vpRedEl) vpRedEl.textContent = String(red ?? 0);
+ } catch { /* ignore */ }
+}
+
+// ── Element bonus ─────────────────────────────────────────────────────────────
+
+let elemBonusBlue = 0;
+let elemBonusRed = 0;
+
+function updateEffectiveCooldownDisplay() {
+ const base = GAME_CONFIG.clickCooldownSeconds;
+ const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
+ const effective = base / (1 + bonus / 100);
+ if (elemBonusBlueEl) elemBonusBlueEl.textContent = elemBonusBlue.toFixed(2);
+ if (elemBonusRedEl) elemBonusRedEl.textContent = elemBonusRed.toFixed(2);
+ if (effectiveCooldownEl) {
+ effectiveCooldownEl.textContent = effective < 1
+ ? `${(effective * 1000).toFixed(0)}ms`
+ : `${effective.toFixed(2)}s`;
+ }
+}
+
+export async function loadElementBonus() {
+ try {
+ const { blue, red } = await apiFetchElementBonus();
+ elemBonusBlue = blue ?? 0;
+ elemBonusRed = red ?? 0;
+ updateEffectiveCooldownDisplay();
+ } catch { /* ignore */ }
+}
+
+export async function tickElementBonus() {
+ const worth = GAME_CONFIG.elementWorth;
+ const blueBonus = computeTeamElementBonus("blue", cells, worth);
+ const redBonus = computeTeamElementBonus("red", cells, worth);
+ try {
+ const result = await apiTickElementBonus(seedStr, blueBonus, redBonus);
+ elemBonusBlue = result.blue ?? blueBonus;
+ elemBonusRed = result.red ?? redBonus;
+ } catch {
+ elemBonusBlue = blueBonus;
+ elemBonusRed = redBonus;
+ }
+ updateEffectiveCooldownDisplay();
+}
+
+export function getEffectiveCooldown() {
+ const base = GAME_CONFIG.clickCooldownSeconds;
+ const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
+ return base / (1 + bonus / 100);
+}
+
+export async function loadDbInfo() {
+ try {
+ const { createdAt } = await apiFetchDbInfo();
+ if (dbCreatedAtEl && createdAt) {
+ const d = new Date(createdAt);
+ dbCreatedAtEl.textContent =
+ d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) +
+ " " +
+ d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
+ }
} catch { /* ignore */ }
}
@@ -385,7 +464,7 @@ function tickCooldown() {
}
export function startCooldown() {
- const secs = GAME_CONFIG.clickCooldownSeconds;
+ const secs = getEffectiveCooldown();
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
diff --git a/public/src/main.js b/public/src/main.js
index 34eeb9c..dfeaf19 100644
--- a/public/src/main.js
+++ b/public/src/main.js
@@ -9,6 +9,10 @@ import {
updateEconomyDisplay,
tickEconScore,
loadEconScores,
+ loadVictoryPoints,
+ loadDbInfo,
+ loadElementBonus,
+ tickElementBonus,
refreshFromServer,
refreshGridDisplay,
loadPlayfieldMask,
@@ -58,6 +62,7 @@ function scheduleScorePoll() {
scorePollTimer = window.setTimeout(async () => {
await fetchAndApplyScores();
tickEconScore(ECON_TICK_SECONDS);
+ tickElementBonus();
scheduleScorePoll();
}, ECON_TICK_SECONDS * 1_000);
}
@@ -144,6 +149,9 @@ async function boot() {
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
await loadEconScores();
+ await loadVictoryPoints();
+ await loadDbInfo();
+ await loadElementBonus();
updateEconomyDisplay();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
@@ -159,6 +167,9 @@ async function boot() {
scheduleConfigPoll();
scheduleScorePoll();
+ // Refresh VP every 30 s so new awards are reflected promptly
+ setInterval(loadVictoryPoints, 30_000);
+
// Refresh grid every second so all clients see new tiles promptly
setInterval(refreshGridDisplay, 1_000);
}
diff --git a/public/style.css b/public/style.css
index d1f9287..6fcbaff 100644
--- a/public/style.css
+++ b/public/style.css
@@ -276,61 +276,94 @@ body {
.scoreBoard {
display: flex;
align-items: center;
- justify-content: center;
- gap: 16px;
- padding: 10px 16px;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
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;
+}
+
+.team-logo {
+ width: 54px;
+ height: 54px;
+ display: block;
+ flex-shrink: 0;
}
.scoreTeam {
display: flex;
+ flex-direction: column;
align-items: center;
- justify-content: center;
- gap: 10px;
-}
-
-.team-logo {
- width: 64px;
- height: 64px;
- display: block;
+ gap: 4px;
+ flex: 1;
}
.scoreTeamName {
- font-size: 12px;
+ font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
- opacity: 0.8;
+ opacity: 0.75;
}
-.scoreTeam--blue .scoreTeamName {
- color: rgba(90, 200, 255, 0.9);
+.scoreTeam--blue .scoreTeamName { color: rgba(90, 200, 255, 0.9); }
+.scoreTeam--red .scoreTeamName { color: rgba(220, 75, 85, 0.9); }
+
+.scoreStats {
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+}
+
+.scoreStat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1px;
+}
+
+.scoreStatVal {
+ font-size: 20px;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.scoreStatLabel {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ opacity: 0.55;
}
.scoreTeam--blue .scoreValue {
color: rgba(90, 200, 255, 1);
- text-shadow: 0 0 20px rgba(90, 200, 255, 0.4);
+ text-shadow: 0 0 16px rgba(90, 200, 255, 0.4);
}
-.scoreTeam--red .scoreTeamName {
- color: rgba(220, 75, 85, 0.9);
+.scoreTeam--blue .scoreVP {
+ color: rgba(130, 230, 130, 1);
+ text-shadow: 0 0 16px rgba(90, 200, 130, 0.35);
}
.scoreTeam--red .scoreValue {
color: rgba(220, 75, 85, 1);
- text-shadow: 0 0 20px rgba(220, 75, 85, 0.4);
+ text-shadow: 0 0 16px rgba(220, 75, 85, 0.4);
+}
+
+.scoreTeam--red .scoreVP {
+ color: rgba(230, 150, 80, 1);
+ text-shadow: 0 0 16px rgba(220, 130, 60, 0.35);
}
.scoreSep {
- opacity: 0.4;
+ opacity: 0.35;
font-size: 18px;
+ font-weight: 800;
+ flex-shrink: 0;
}
/* ── Main app layout ──────────────────────────────────────────────────────── */
@@ -402,33 +435,36 @@ body {
.infoTable {
display: flex;
flex-direction: column;
- gap: 6px;
+ gap: 4px;
font-family: "Courier New", Courier, monospace;
}
.infoRow {
display: flex;
align-items: baseline;
- gap: 8px;
+ gap: 0;
min-height: 22px;
}
.infoKey {
- flex: 0 0 auto;
+ flex: 0 0 55%;
font-size: 11px;
opacity: 0.65;
white-space: nowrap;
- min-width: 130px;
text-align: right;
+ padding-right: 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.infoVal {
- flex: 1;
+ flex: 0 0 45%;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: "Courier New", Courier, monospace;
+ text-align: left;
}
.infoVal code {
@@ -801,6 +837,75 @@ button:hover {
font-weight: 700;
}
+/* ── Element bonus section ────────────────────────────────────────────────── */
+
+.elemBonusSection {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 14px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 220, 100, 0.15);
+ background: rgba(255, 200, 50, 0.04);
+ font-family: "Courier New", Courier, monospace;
+ font-size: 12px;
+}
+
+.elemBonusSectionTitle {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ opacity: 0.7;
+ margin-bottom: 2px;
+}
+
+.elemBonusRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+
+.elemBonusTeam {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.elemBonusTeam--blue .elemBonusLabel { color: rgba(90, 200, 255, 0.75); font-size: 10px; }
+.elemBonusTeam--blue .elemBonusVal { color: rgba(90, 200, 255, 1); font-weight: 800; font-size: 13px; }
+.elemBonusTeam--red .elemBonusLabel { color: rgba(220, 75, 85, 0.75); font-size: 10px; }
+.elemBonusTeam--red .elemBonusVal { color: rgba(220, 75, 85, 1); font-weight: 800; font-size: 13px; }
+
+.elemBonusUnit {
+ font-size: 10px;
+ opacity: 0.6;
+}
+
+.elemBonusSep {
+ opacity: 0.3;
+}
+
+.elemBonusDetail {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ padding-top: 6px;
+ margin-top: 2px;
+ font-size: 11px;
+}
+
+.elemBonusDetailLabel {
+ opacity: 0.55;
+}
+
+.elemBonusDetailVal {
+ font-weight: 700;
+ color: rgba(255, 220, 100, 0.9);
+}
+
/* ── Options / admin section ──────────────────────────────────────────────── */
.infoSection--options {
@@ -984,11 +1089,14 @@ canvas {
/* Scoreboard wraps nicely on narrow screens */
.scoreBoard {
flex-wrap: wrap;
- font-size: 18px;
}
.team-logo {
- width: 44px;
- height: 44px;
+ width: 40px;
+ height: 40px;
+ }
+
+ .scoreStatVal {
+ font-size: 16px;
}
}
\ No newline at end of file
diff --git a/server/configLoader.js b/server/configLoader.js
index 933643d..77fb3ad 100644
--- a/server/configLoader.js
+++ b/server/configLoader.js
@@ -13,6 +13,7 @@ let cached = {
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
+ elementWorth: {},
resourceWorth: { common: {}, rare: {} },
};
@@ -47,6 +48,9 @@ export function loadConfigFile() {
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
}
+ if (j.elementWorth && typeof j.elementWorth === "object") {
+ cached.elementWorth = j.elementWorth;
+ }
if (j.resourceWorth && typeof j.resourceWorth === "object") {
cached.resourceWorth = j.resourceWorth;
}
diff --git a/server/db/gameDb.js b/server/db/gameDb.js
index 7f5f514..602348a 100644
--- a/server/db/gameDb.js
+++ b/server/db/gameDb.js
@@ -37,6 +37,31 @@ export async function initGameSchema() {
PRIMARY KEY (world_seed, team)
);
`);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS team_element_bonus (
+ world_seed TEXT NOT NULL,
+ team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
+ bonus DOUBLE PRECISION NOT NULL DEFAULT 0,
+ PRIMARY KEY (world_seed, team)
+ );
+ `);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS db_metadata (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+ INSERT INTO db_metadata (created_at)
+ SELECT NOW() WHERE NOT EXISTS (SELECT 1 FROM db_metadata);
+ `);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS victory_points (
+ id SERIAL PRIMARY KEY,
+ awarded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ world_seed TEXT NOT NULL,
+ team TEXT NOT NULL CHECK (team IN ('blue', 'red'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_vp_team ON victory_points (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;
@@ -65,9 +90,30 @@ export async function ensureSeedEpoch() {
return worldSeed;
}
if (seedSlot !== lastSeedSlot) {
+ // Award a victory point to the team with the highest econ score before wiping
+ try {
+ const expiredSeed = `swg-${lastSeedSlot}`;
+ const econRows = await pool.query(
+ `SELECT team, score FROM team_econ_scores WHERE world_seed = $1`,
+ [expiredSeed]
+ );
+ const scores = { blue: 0, red: 0 };
+ for (const row of econRows.rows) scores[row.team] = Number(row.score);
+ if (scores.blue > 0 || scores.red > 0) {
+ const winner = scores.blue >= scores.red ? "blue" : "red";
+ await pool.query(
+ `INSERT INTO victory_points (world_seed, team) VALUES ($1, $2)`,
+ [expiredSeed, winner]
+ );
+ console.log(`[world] VP awarded to ${winner} for seed ${expiredSeed} (blue=${scores.blue.toFixed(3)}, red=${scores.red.toFixed(3)})`);
+ }
+ } catch (e) {
+ console.error("[world] VP award error:", e);
+ }
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]);
+ await pool.query("DELETE FROM team_element_bonus WHERE world_seed != $1", [worldSeed]);
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
lastSeedSlot = seedSlot;
}
@@ -147,6 +193,46 @@ export async function addEconScore(worldSeed, team, delta) {
);
}
+export async function getElementBonus(worldSeed) {
+ const { rows } = await pool.query(
+ `SELECT team, bonus FROM team_element_bonus WHERE world_seed = $1`,
+ [worldSeed]
+ );
+ const result = { blue: 0, red: 0 };
+ for (const row of rows) result[row.team] = Number(row.bonus);
+ return result;
+}
+
+export async function setElementBonus(worldSeed, team, bonus) {
+ await pool.query(
+ `INSERT INTO team_element_bonus (world_seed, team, bonus)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (world_seed, team) DO UPDATE
+ SET bonus = EXCLUDED.bonus`,
+ [worldSeed, team, bonus]
+ );
+}
+
+// ── DB metadata ───────────────────────────────────────────────────────────────
+
+export async function getDbCreatedAt() {
+ const { rows } = await pool.query(
+ `SELECT created_at FROM db_metadata ORDER BY id ASC LIMIT 1`
+ );
+ return rows[0]?.created_at ?? null;
+}
+
+// ── Victory points ────────────────────────────────────────────────────────────
+
+export async function getVictoryPoints() {
+ const { rows } = await pool.query(
+ `SELECT team, COUNT(*) AS cnt FROM victory_points GROUP BY team`
+ );
+ const result = { blue: 0, red: 0 };
+ for (const row of rows) result[row.team] = Number(row.cnt);
+ return result;
+}
+
// ── Scores ────────────────────────────────────────────────────────────────────
export async function getScores(worldSeed) {
@@ -157,4 +243,4 @@ export async function getScores(worldSeed) {
[worldSeed]
);
return rows;
-}
\ No newline at end of file
+}
diff --git a/server/routes/game.js b/server/routes/game.js
index ae7d387..ced709a 100644
--- a/server/routes/game.js
+++ b/server/routes/game.js
@@ -11,6 +11,10 @@ import {
getScores,
getEconScores,
addEconScore,
+ getElementBonus,
+ setElementBonus,
+ getDbCreatedAt,
+ getVictoryPoints,
} from "../db/gameDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
@@ -46,6 +50,7 @@ router.get("/config", async (req, res) => {
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
teamCooldownRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
+ elementWorth: cfg.elementWorth ?? {},
});
} catch (e) {
console.error(e);
@@ -180,6 +185,58 @@ router.post("/econ-scores/tick", async (req, res) => {
}
});
+// GET /api/element-bonus
+router.get("/element-bonus", async (_req, res) => {
+ try {
+ const worldSeed = await ensureSeedEpoch();
+ const bonus = await getElementBonus(worldSeed);
+ res.json(bonus);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
+// POST /api/element-bonus/tick body: { seed, blue, red }
+router.post("/element-bonus/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 setElementBonus(worldSeed, "blue", blue);
+ await setElementBonus(worldSeed, "red", red);
+ const bonus = await getElementBonus(worldSeed);
+ res.json(bonus);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
+// GET /api/db-info
+router.get("/db-info", async (_req, res) => {
+ try {
+ const createdAt = await getDbCreatedAt();
+ res.json({ createdAt });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
+// GET /api/victory-points
+router.get("/victory-points", async (_req, res) => {
+ try {
+ const vp = await getVictoryPoints();
+ res.json(vp);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
// GET /api/scores
router.get("/scores", async (_req, res) => {
try {