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 + +
+
+

Chargement…

+
+
+ @@ -232,6 +251,19 @@
Cliquez sur une tuile. Les stats seront vides à moins de cliquer.
+ + + 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