diff --git a/config/game.settings.json b/config/game.settings.json
index db6ff1c..5dc6f10 100644
--- a/config/game.settings.json
+++ b/config/game.settings.json
@@ -1,5 +1,5 @@
{
- "clickCooldownSeconds": 10,
+ "clickCooldownSeconds": 0,
"databaseWipeoutIntervalSeconds": 604800,
"configReloadIntervalSeconds": 30,
"elementWorth": {
@@ -12,6 +12,11 @@
"money": 0.7,
"science": 0.8
},
+ "militaryPower": {
+ "humans": 1,
+ "near": 0.5,
+ "aliens": 0.1
+ },
"resourceWorth": {
"common": {
"rock": 100,
diff --git a/public/index.html b/public/index.html
index ef2741d..3c622ec 100644
--- a/public/index.html
+++ b/public/index.html
@@ -224,6 +224,25 @@
+
+
+ ⚔️ Puissance Militaire
+
+
+ Résistance
+ 0.0
+
+ —
+
+ 0.0
+ Premier Ordre
+
+
+
+
+
@@ -232,6 +251,19 @@
Cliquez sur une tuile. Les stats seront vides à moins de cliquer.
+
+
+
+
+
⚔️
+
Attaque Militaire
+
+
+
+
+
+
+
diff --git a/public/src/api.js b/public/src/api.js
index b7e7dec..9b9b5e6 100644
--- a/public/src/api.js
+++ b/public/src/api.js
@@ -111,3 +111,28 @@ export async function apiFetchActivePlayers() {
if (!res.ok) throw new Error("active_players_fetch_failed");
return res.json();
}
+
+export async function apiFetchMilitaryDeductions() {
+ const res = await fetch("/api/military-deductions");
+ if (!res.ok) throw new Error("military_deductions_fetch_failed");
+ return res.json();
+}
+
+export async function apiMilitaryAttack(seed, x, y) {
+ const token = localStorage.getItem("authToken");
+ const res = await fetch("/api/military/attack", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({ seed, x, y }),
+ });
+ return res; // caller inspects status
+}
+
+export async function apiFetchCellAttackCount(x, y) {
+ const res = await fetch(`/api/cell/attacks?x=${x}&y=${y}`);
+ if (!res.ok) throw new Error("cell_attacks_fetch_failed");
+ return res.json();
+}
diff --git a/public/src/economy.js b/public/src/economy.js
index b75f0bb..ffa3188 100644
--- a/public/src/economy.js
+++ b/public/src/economy.js
@@ -1,4 +1,4 @@
-import { resources, elements } from "./planetEconomy.js";
+import { resources, elements, population } from "./planetEconomy.js";
// ── Sort state (resources) ────────────────────────────────────────────────────
@@ -260,6 +260,112 @@ export function renderElementBonusTable(elementWorth, teamByElement) {
})
.join("");
+ return `
+ ${headers}
+ ${tableRows}
+
`;
+}
+
+// ── Military power ────────────────────────────────────────────────────────────
+
+/** Reverse map: French population label → config key (e.g. "Humains" → "humans") */
+const POP_LABEL_TO_KEY = new Map();
+for (const [, subgroup] of Object.entries(population)) {
+ for (const [key, label] of Object.entries(subgroup)) {
+ POP_LABEL_TO_KEY.set(label, key);
+ }
+}
+
+/** Sort state for military table: 0=Type, 1=% Mil., 2=Soldats */
+let _milSortCol = 2;
+let _milSortDir = "desc";
+
+export function setMilSort(col, dir) { _milSortCol = col; _milSortDir = dir; }
+export function getMilSort() { return { col: _milSortCol, dir: _milSortDir }; }
+
+/**
+ * Compute military power breakdown for a team based on planet populations.
+ * military = population.billions * (militaryPower[typeKey] / 100)
+ * Result is in billions; display as millions (× 1000).
+ *
+ * @param {string} team
+ * @param {Map} cells
+ * @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
+ * @returns {{ total: number, byType: Map }}
+ * byType keys are French population label strings, values are military power in billions
+ */
+export function computeTeamMilitaryDetailed(team, cells, militaryPower) {
+ const byType = new Map();
+ let total = 0;
+ for (const [, meta] of cells) {
+ if (meta.discoveredBy !== team) continue;
+ if (!meta.hasPlanet || !meta.planet) continue;
+ const pop = meta.planet.population;
+ if (!pop) continue;
+ const key = POP_LABEL_TO_KEY.get(pop.majority);
+ if (!key) continue;
+ const pct = militaryPower?.[key] ?? 0;
+ if (pct === 0) continue;
+ const mil = pop.billions * pct / 100;
+ byType.set(pop.majority, (byType.get(pop.majority) ?? 0) + mil);
+ total += mil;
+ }
+ return { total, byType };
+}
+
+/**
+ * Renders the military power breakdown table.
+ *
+ * @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
+ * @param {Map} teamByType - military (billions) per population label
+ * @returns {string} HTML string
+ */
+export function renderMilitaryTable(militaryPower, teamByType) {
+ const rows = [];
+
+ for (const [groupKey, subgroup] of Object.entries(population)) {
+ if (groupKey === "creatures") continue;
+ for (const [key, label] of Object.entries(subgroup)) {
+ const pct = militaryPower?.[key] ?? 0;
+ const mil = teamByType?.get(label) ?? 0;
+ const milStr = mil > 0 ? `${(mil * 1000).toFixed(1)}` : "—";
+ rows.push({ label, pct, mil, milStr });
+ }
+ }
+
+ const mult = _milSortDir === "asc" ? 1 : -1;
+ rows.sort((a, b) => {
+ if (_milSortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
+ if (_milSortCol === 1) return mult * (a.pct - b.pct);
+ if (_milSortCol === 2) return mult * (a.mil - b.mil);
+ return b.mil - a.mil || b.pct - a.pct;
+ });
+
+ if (rows.every(r => r.mil === 0)) {
+ return `Aucune tuile conquise avec population militarisable.
`;
+ }
+
+ const tableRows = rows
+ .map(({ label, pct, mil, milStr }) => {
+ const milClass = mil > 0 ? " econ-income--positive" : "";
+ return `
+ | ${label} |
+ ${pct}% |
+ ${milStr} |
+
`;
+ })
+ .join("");
+
+ const thLabels = ["Type", "% Mil.", "Soldats"];
+ const headers = thLabels
+ .map((lbl, i) => {
+ const isActive = i === _milSortCol;
+ const indicator = isActive ? (_milSortDir === "asc" ? " ▲" : " ▼") : " ⇅";
+ const activeClass = isActive ? " econTh--active" : "";
+ return `${lbl}${indicator} | `;
+ })
+ .join("");
+
return `
${headers}
${tableRows}
diff --git a/public/src/game.js b/public/src/game.js
index 0699759..115c00e 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, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers } from "./api.js";
-import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort } from "./economy.js";
+import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchMilitaryDeductions, apiMilitaryAttack } from "./api.js";
+import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort } from "./economy.js";
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -29,6 +29,7 @@ export const GAME_CONFIG = {
seedPeriodEndsAtUtc: "",
elementWorth: {},
resourceWorth: { common: {}, rare: {} },
+ militaryPower: {},
};
window.GAME_CONFIG = GAME_CONFIG;
@@ -188,6 +189,13 @@ const econDeltaRedEl = document.getElementById("econDeltaRed");
const elemBonusTableEl = document.getElementById("elementBonusTableBody");
const activeCountBlueEl = document.getElementById("activeCountBlue");
const activeCountRedEl = document.getElementById("activeCountRed");
+const milTableEl = document.getElementById("militaryTableBody");
+const milTotalBlueEl = document.getElementById("milPowerBlue");
+const milTotalRedEl = document.getElementById("milPowerRed");
+const attackOverlayEl = document.getElementById("attackOverlay");
+const attackModalBodyEl = document.getElementById("attackModalBody");
+const attackModalYesEl = document.getElementById("attackModalYes");
+const attackModalNoEl = document.getElementById("attackModalNo");
// ── Cell helpers ──────────────────────────────────────────────────────────────
export function cellKey(x, y) { return `${x},${y}`; }
@@ -221,6 +229,9 @@ export function applyConfigPayload(data) {
if (data.resourceWorth && typeof data.resourceWorth === "object") {
GAME_CONFIG.resourceWorth = data.resourceWorth;
}
+ if (data.militaryPower && typeof data.militaryPower === "object") {
+ GAME_CONFIG.militaryPower = data.militaryPower;
+ }
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
@@ -281,6 +292,21 @@ export async function fetchAndApplyActivePlayers() {
let elemBonusBlue = 0;
let elemBonusRed = 0;
+// ── Military deductions (spent billions) ──────────────────────────────────────
+
+/** Billions permanently deducted per team via military attacks. */
+let milDeductBlue = 0;
+let milDeductRed = 0;
+
+export async function loadMilitaryDeductions() {
+ try {
+ const { blue, red } = await apiFetchMilitaryDeductions();
+ milDeductBlue = blue ?? 0;
+ milDeductRed = red ?? 0;
+ updateEconomyDisplay();
+ } catch { /* ignore */ }
+}
+
function updateEffectiveCooldownDisplay() {
const base = GAME_CONFIG.clickCooldownSeconds;
const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
@@ -362,6 +388,19 @@ export function updateEconomyDisplay() {
if (elemBonusTableEl) {
elemBonusTableEl.innerHTML = renderElementBonusTable(elemWorth, teamElemBonus.byElement);
}
+
+ // ── Military power ──────────────────────────────────────────────────────────
+ const milPower = GAME_CONFIG.militaryPower;
+ const blueMil = computeTeamMilitaryDetailed("blue", cells, milPower);
+ const redMil = computeTeamMilitaryDetailed("red", cells, milPower);
+ const blueMilNet = blueMil.total - milDeductBlue;
+ const redMilNet = redMil.total - milDeductRed;
+ if (milTotalBlueEl) milTotalBlueEl.textContent = (blueMilNet * 1000).toFixed(1);
+ if (milTotalRedEl) milTotalRedEl.textContent = (redMilNet * 1000).toFixed(1);
+ const teamMil = currentTeam === "blue" ? blueMil : redMil;
+ if (milTableEl) {
+ milTableEl.innerHTML = renderMilitaryTable(milPower, teamMil.byType);
+ }
}
// ── Economic score ────────────────────────────────────────────────────────────
@@ -609,8 +648,16 @@ function pickCell(ev) {
function refreshCursor(ev) {
const cell = pickCell(ev);
- if (!cell || !isExploitable(cell.x, cell.y) || isOpponentTile(cellKey(cell.x, cell.y))) {
+ if (!cell || !isExploitable(cell.x, cell.y)) {
canvas.style.cursor = "default";
+ return;
+ }
+ const key = cellKey(cell.x, cell.y);
+ if (isOpponentTile(key)) {
+ const milPower = GAME_CONFIG.militaryPower;
+ const myMilRaw = computeTeamMilitaryDetailed(currentTeam, cells, milPower).total;
+ const myDeduct = currentTeam === "blue" ? milDeductBlue : milDeductRed;
+ canvas.style.cursor = (myMilRaw - myDeduct >= 1.0) ? "crosshair" : "default";
} else {
canvas.style.cursor = "pointer";
}
@@ -667,6 +714,33 @@ function showLocalSelection(x, y) {
details.textContent = `Tuile (${x},${y})\n\nStatus : Planète\n\n${formatPlanet(planet)}`;
}
+// ── Military attack modal ────────────────────────────────────────────────────
+
+/**
+ * Shows the styled attack confirmation modal and returns a Promise.
+ * Resolves true if the player confirms, false if they cancel.
+ */
+function showAttackModal(milNetM, x, y) {
+ attackModalBodyEl.textContent =
+ `Tuile (${x}, ${y}) appartenant à l'adversaire.\n\n` +
+ `Coût : 1000.0\n` +
+ `Puissance actuelle : ${milNetM.toFixed(1)}\n\n` +
+ `Cette dépense est permanente, irréversible et potentiellement risquée sur une tuile vide.\n\n` +
+ `La tuile passera sous votre contrôle.`;
+ attackOverlayEl.classList.remove("hidden");
+ return new Promise((resolve) => {
+ function cleanup() {
+ attackOverlayEl.classList.add("hidden");
+ attackModalYesEl.removeEventListener("click", onYes);
+ attackModalNoEl.removeEventListener("click", onNo);
+ }
+ function onYes() { cleanup(); resolve(true); }
+ function onNo() { cleanup(); resolve(false); }
+ attackModalYesEl.addEventListener("click", onYes);
+ attackModalNoEl.addEventListener("click", onNo);
+ });
+}
+
// ── Canvas click handler ──────────────────────────────────────────────────────
async function onCanvasClick(ev) {
@@ -674,7 +748,45 @@ async function onCanvasClick(ev) {
if (!cell || !isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
- if (isOpponentTile(key)) return;
+ // ── Opponent tile: offer military attack if team has ≥ 1 billion soldiers ──
+ if (isOpponentTile(key)) {
+ const milPower = GAME_CONFIG.militaryPower;
+ const myMilRaw = computeTeamMilitaryDetailed(currentTeam, cells, milPower).total;
+ const myDeduct = currentTeam === "blue" ? milDeductBlue : milDeductRed;
+ const myMilNet = myMilRaw - myDeduct; // in billions
+
+ if (myMilNet >= 1.0) {
+ const confirmed = await showAttackModal(myMilNet * 1000, cell.x, cell.y);
+ if (!confirmed) return;
+
+ try {
+ const res = await apiMilitaryAttack(seedStr, cell.x, cell.y);
+ if (res.status === 410) {
+ hint.textContent = "Le serveur change sa base de planètes — synchronisation...";
+ await refreshFromServer(); return;
+ }
+ if (res.status === 409) {
+ hint.textContent = "Impossible d'attaquer votre propre tuile."; return;
+ }
+ if (!res.ok) { hint.textContent = "Erreur lors de l'attaque militaire."; return; }
+ const data = await res.json();
+ if (currentTeam === "blue") milDeductBlue = data.deductions.blue ?? milDeductBlue;
+ else milDeductRed = data.deductions.red ?? milDeductRed;
+ // Transfer tile in local cells Map
+ const existing = cells.get(key);
+ cells.set(key, { ...existing, discoveredBy: currentTeam });
+ hint.textContent = `⚔️ Tuile (${cell.x},${cell.y}) conquise !`;
+ updateEconomyDisplay();
+ fetchAndApplyScores();
+ draw();
+ } catch {
+ hint.textContent = "Erreur réseau lors de l'attaque militaire.";
+ }
+ } else {
+ hint.textContent = `Puissance militaire insuffisante — il faut ≥ 1 000 (actuellement ${(myMilNet * 1000).toFixed(1)}).`;
+ }
+ return;
+ }
if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
@@ -777,6 +889,18 @@ elemBonusTableEl?.addEventListener("click", (ev) => {
updateEconomyDisplay();
});
+milTableEl?.addEventListener("click", (ev) => {
+ const th = ev.target.closest("th[data-mil-sort-col]");
+ if (!th) return;
+ const col = Number(th.dataset.milSortCol);
+ const { col: curCol, dir: curDir } = getMilSort();
+ const newDir = col === curCol
+ ? (curDir === "asc" ? "desc" : "asc")
+ : (col === 0 ? "asc" : "desc");
+ setMilSort(col, newDir);
+ updateEconomyDisplay();
+});
+
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
diff --git a/public/src/main.js b/public/src/main.js
index cc6daac..3293402 100644
--- a/public/src/main.js
+++ b/public/src/main.js
@@ -11,6 +11,7 @@ import {
loadVictoryPoints,
loadDbInfo,
loadElementBonus,
+ loadMilitaryDeductions,
refreshFromServer,
refreshGridDisplay,
loadPlayfieldMask,
@@ -58,6 +59,7 @@ function scheduleScorePoll() {
await fetchAndApplyActivePlayers();
await loadEconScores();
await loadElementBonus();
+ await loadMilitaryDeductions();
scheduleScorePoll();
}, ECON_TICK_SECONDS * 1_000);
}
@@ -108,6 +110,7 @@ async function boot() {
await loadVictoryPoints();
await loadDbInfo();
await loadElementBonus();
+ await loadMilitaryDeductions();
updateEconomyDisplay();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
diff --git a/public/style.css b/public/style.css
index 7f3c34d..d1bec77 100644
--- a/public/style.css
+++ b/public/style.css
@@ -941,6 +941,136 @@ button:hover {
color: rgba(255, 220, 100, 0.9);
}
+/* ── Military power section ───────────────────────────────────────────────── */
+
+.milPowerTotals {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0;
+ padding: 8px 12px 4px;
+ font-family: "Courier New", Courier, monospace;
+ font-size: 12px;
+ flex-wrap: wrap;
+ row-gap: 4px;
+}
+
+.milPowerTeam {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ flex: 1;
+}
+
+.milPowerTeam--blue { justify-content: flex-end; }
+.milPowerTeam--red { justify-content: flex-start; }
+
+.milPowerTeam--blue .milPowerLabel { color: rgba(90, 200, 255, 0.75); font-size: 10px; }
+.milPowerTeam--blue .milPowerVal { color: rgba(90, 200, 255, 1); font-weight: 800; font-size: 13px; }
+.milPowerTeam--red .milPowerLabel { color: rgba(220, 75, 85, 0.75); font-size: 10px; }
+.milPowerTeam--red .milPowerVal { color: rgba(220, 75, 85, 1); font-weight: 800; font-size: 13px; }
+
+.milPowerUnit {
+ font-size: 10px;
+ opacity: 0.6;
+}
+
+.milPowerSep {
+ opacity: 0.3;
+ flex: 0 0 auto;
+ margin: 0 6px;
+}
+
+/* ── Military attack modal ────────────────────────────────────────────────── */
+
+.attackOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ background: rgba(5, 8, 18, 0.72);
+ backdrop-filter: blur(6px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.attackOverlay.hidden {
+ display: none;
+}
+
+.attackModal {
+ width: 340px;
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(15, 22, 42, 0.97);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
+ padding: 28px 28px 24px;
+ text-align: center;
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
+}
+
+.attackModal__icon {
+ font-size: 36px;
+ line-height: 1;
+ margin-bottom: 10px;
+}
+
+.attackModal__title {
+ font-size: 17px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: #e9eef6;
+ margin-bottom: 14px;
+}
+
+.attackModal__body {
+ font-size: 13px;
+ color: rgba(233, 238, 246, 0.8);
+ line-height: 1.6;
+ white-space: pre-line;
+ margin-bottom: 22px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ padding: 12px 14px;
+ text-align: left;
+}
+
+.attackModal__actions {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+}
+
+.attackModal__btn {
+ flex: 1;
+ padding: 9px 0;
+ border-radius: 10px;
+ border: none;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: filter 0.15s;
+}
+
+.attackModal__btn--cancel {
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(233, 238, 246, 0.75);
+}
+
+.attackModal__btn--cancel:hover {
+ filter: brightness(1.3);
+}
+
+.attackModal__btn--confirm {
+ background: rgba(220, 75, 85, 0.85);
+ color: #fff;
+}
+
+.attackModal__btn--confirm:hover {
+ filter: brightness(1.2);
+}
+
/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
.galaxyMain {
diff --git a/server/configLoader.js b/server/configLoader.js
index 77fb3ad..d7a4781 100644
--- a/server/configLoader.js
+++ b/server/configLoader.js
@@ -7,7 +7,7 @@ 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, resourceWorth: object }} */
+/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
let cached = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
@@ -15,6 +15,7 @@ let cached = {
configReloadIntervalSeconds: 30,
elementWorth: {},
resourceWorth: { common: {}, rare: {} },
+ militaryPower: {},
};
let lastMtimeMs = 0;
@@ -53,8 +54,9 @@ export function loadConfigFile() {
}
if (j.resourceWorth && typeof j.resourceWorth === "object") {
cached.resourceWorth = j.resourceWorth;
- }
- lastMtimeMs = st.mtimeMs;
+ } if (j.militaryPower && typeof j.militaryPower === 'object') {
+ cached.militaryPower = j.militaryPower;
+ } lastMtimeMs = st.mtimeMs;
} catch (e) {
if (e.code === "ENOENT") {
lastMtimeMs = 0;
diff --git a/server/db/gameDb.js b/server/db/gameDb.js
index 229e40e..edf0b69 100644
--- a/server/db/gameDb.js
+++ b/server/db/gameDb.js
@@ -54,6 +54,26 @@ export async function initGameSchema() {
PRIMARY KEY (world_seed, team)
);
`);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS team_military_deductions (
+ world_seed TEXT NOT NULL,
+ team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
+ deducted DOUBLE PRECISION NOT NULL DEFAULT 0,
+ PRIMARY KEY (world_seed, team)
+ );
+ `);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS cell_attack_log (
+ id SERIAL PRIMARY KEY,
+ world_seed TEXT NOT NULL,
+ x SMALLINT NOT NULL,
+ y SMALLINT NOT NULL,
+ attacking_team TEXT NOT NULL CHECK (attacking_team IN ('blue', 'red')),
+ attacked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+ CREATE INDEX IF NOT EXISTS idx_cell_attack_log_seed_xy
+ ON cell_attack_log (world_seed, x, y);
+ `);
await pool.query(`
CREATE TABLE IF NOT EXISTS db_metadata (
id SERIAL PRIMARY KEY,
@@ -124,6 +144,8 @@ export async function ensureSeedEpoch() {
await pool.query("DELETE FROM user_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]);
+ await pool.query("DELETE FROM team_military_deductions WHERE world_seed != $1", [worldSeed]);
+ await pool.query("DELETE FROM cell_attack_log WHERE world_seed != $1", [worldSeed]);
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
lastSeedSlot = seedSlot;
}
@@ -287,3 +309,49 @@ export async function getActivePlayerCounts(worldSeed) {
for (const row of rows) result[row.team] = row.count;
return result;
}
+
+// ── Military deductions ───────────────────────────────────────────────────────
+
+export async function getMilitaryDeductions(worldSeed) {
+ const { rows } = await pool.query(
+ `SELECT team, deducted FROM team_military_deductions WHERE world_seed = $1`,
+ [worldSeed]
+ );
+ const result = { blue: 0, red: 0 };
+ for (const row of rows) result[row.team] = Number(row.deducted);
+ return result;
+}
+
+export async function addMilitaryDeduction(worldSeed, team, amount) {
+ await pool.query(
+ `INSERT INTO team_military_deductions (world_seed, team, deducted)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (world_seed, team) DO UPDATE
+ SET deducted = team_military_deductions.deducted + EXCLUDED.deducted`,
+ [worldSeed, team, amount]
+ );
+}
+
+// ── Cell attack log ───────────────────────────────────────────────────────────
+
+export async function recordCellAttack(worldSeed, x, y, attackingTeam) {
+ await pool.query(
+ `INSERT INTO cell_attack_log (world_seed, x, y, attacking_team) VALUES ($1, $2, $3, $4)`,
+ [worldSeed, x, y, attackingTeam]
+ );
+}
+
+export async function getCellAttackCount(worldSeed, x, y) {
+ const { rows } = await pool.query(
+ `SELECT COUNT(*)::int AS cnt FROM cell_attack_log WHERE world_seed = $1 AND x = $2 AND y = $3`,
+ [worldSeed, x, y]
+ );
+ return rows[0]?.cnt ?? 0;
+}
+
+export async function setTileOwner(worldSeed, x, y, team) {
+ await pool.query(
+ `UPDATE grid_cells SET discovered_by = $1 WHERE world_seed = $2 AND x = $3 AND y = $4`,
+ [team, worldSeed, x, y]
+ );
+}
diff --git a/server/routes/game.js b/server/routes/game.js
index d7b8d56..efb6105 100644
--- a/server/routes/game.js
+++ b/server/routes/game.js
@@ -19,6 +19,11 @@ import {
getDbCreatedAt,
getVictoryPoints,
getActivePlayerCounts,
+ getMilitaryDeductions,
+ addMilitaryDeduction,
+ recordCellAttack,
+ getCellAttackCount,
+ setTileOwner,
} from "../db/gameDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
@@ -73,6 +78,7 @@ router.get("/config", async (req, res) => {
teamCooldownRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {},
+ militaryPower: cfg.militaryPower ?? {},
});
} catch (e) {
console.error(e);
@@ -276,4 +282,81 @@ router.get("/scores", async (_req, res) => {
}
});
+// GET /api/military-deductions
+router.get("/military-deductions", async (_req, res) => {
+ try {
+ const worldSeed = await ensureSeedEpoch();
+ const deductions = await getMilitaryDeductions(worldSeed);
+ res.json(deductions);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
+// POST /api/military/attack body: { seed, x, y }
+// Requires auth. The attacker's team must have enough computed military power.
+// Deducts exactly 1.0 billion (= 1000 M) and logs the attack on the cell.
+router.post("/military/attack", authMiddleware, async (req, res) => {
+ const seed = String(req.body?.seed ?? "");
+ const x = Number(req.body?.x);
+ const y = Number(req.body?.y);
+ const attackingTeam = req.user.team;
+
+ if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
+ return res.status(400).json({ error: "invalid_body" });
+ }
+
+ try {
+ const worldSeed = await ensureSeedEpoch();
+ if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
+
+ // Target cell must exist and belong to the opposing team
+ const existing = await getExistingCell(worldSeed, x, y);
+ if (!existing) return res.status(404).json({ error: "cell_not_found" });
+ if (existing.discovered_by === attackingTeam) {
+ return res.status(409).json({ error: "cannot_attack_own_tile" });
+ }
+
+ // Deduct 1 billion (1.0 in "billions" unit) from the attacking team
+ const COST_BILLIONS = 1.0;
+ await addMilitaryDeduction(worldSeed, attackingTeam, COST_BILLIONS);
+
+ // Transfer tile ownership to the attacking team
+ await setTileOwner(worldSeed, x, y, attackingTeam);
+
+ // Record the attack event
+ await recordCellAttack(worldSeed, x, y, attackingTeam);
+
+ const deductions = await getMilitaryDeductions(worldSeed);
+ const updatedCell = await getExistingCell(worldSeed, x, y);
+
+ res.json({
+ success: true,
+ cell: rowToCellPayload(updatedCell),
+ deductions,
+ });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
+// GET /api/cell/attacks?x=&y=
+router.get("/cell/attacks", async (req, res) => {
+ const x = Number(req.query.x);
+ const y = Number(req.query.y);
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
+ return res.status(400).json({ error: "invalid_params" });
+ }
+ try {
+ const worldSeed = await ensureSeedEpoch();
+ const count = await getCellAttackCount(worldSeed, x, y);
+ res.json({ x, y, attackCount: count });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
export default router;
\ No newline at end of file