Private
Public Access
1
0

feat(gameplay): Adding a military power section to exploit population numbers and steal ennemy tiles

This commit is contained in:
gauvainboiche
2026-03-31 18:27:08 +02:00
parent e04560c7f9
commit 570d83c3c0
10 changed files with 587 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
{ {
"clickCooldownSeconds": 10, "clickCooldownSeconds": 0,
"databaseWipeoutIntervalSeconds": 604800, "databaseWipeoutIntervalSeconds": 604800,
"configReloadIntervalSeconds": 30, "configReloadIntervalSeconds": 30,
"elementWorth": { "elementWorth": {
@@ -12,6 +12,11 @@
"money": 0.7, "money": 0.7,
"science": 0.8 "science": 0.8
}, },
"militaryPower": {
"humans": 1,
"near": 0.5,
"aliens": 0.1
},
"resourceWorth": { "resourceWorth": {
"common": { "common": {
"rock": 100, "rock": 100,

View File

@@ -224,6 +224,25 @@
</div> </div>
</details> </details>
<!-- Military power (collapsible) -->
<details class="panel panelCollapsible">
<summary class="panelTitle panelTitleSummary">⚔️ Puissance Militaire</summary>
<div class="milPowerTotals">
<span class="milPowerTeam milPowerTeam--blue">
<span class="milPowerLabel">Résistance</span>
<span class="milPowerVal" id="milPowerBlue">0.0</span>
</span>
<span class="milPowerSep"></span>
<span class="milPowerTeam milPowerTeam--red">
<span class="milPowerVal" id="milPowerRed">0.0</span>
<span class="milPowerLabel">Premier Ordre</span>
</span>
</div>
<div id="militaryTableBody" class="econTableWrap">
<p class="econEmpty">Chargement…</p>
</div>
</details>
</aside> </aside>
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── --> <!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
@@ -232,6 +251,19 @@
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu"></button> <button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu"></button>
<canvas id="canvas" width="1000" height="1000"></canvas> <canvas id="canvas" width="1000" height="1000"></canvas>
<div id="hint" class="hint">Cliquez sur une tuile. Les stats seront vides à moins de cliquer.</div> <div id="hint" class="hint">Cliquez sur une tuile. Les stats seront vides à moins de cliquer.</div>
<!-- Military attack confirmation modal -->
<div class="attackOverlay hidden" id="attackOverlay">
<div class="attackModal">
<div class="attackModal__icon">⚔️</div>
<div class="attackModal__title">Attaque Militaire</div>
<div class="attackModal__body" id="attackModalBody"></div>
<div class="attackModal__actions">
<button type="button" class="attackModal__btn attackModal__btn--cancel" id="attackModalNo">Annuler</button>
<button type="button" class="attackModal__btn attackModal__btn--confirm" id="attackModalYes">Attaquer !</button>
</div>
</div>
</div>
</main> </main>
</div> </div>

View File

@@ -111,3 +111,28 @@ export async function apiFetchActivePlayers() {
if (!res.ok) throw new Error("active_players_fetch_failed"); if (!res.ok) throw new Error("active_players_fetch_failed");
return res.json(); 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();
}

View File

@@ -1,4 +1,4 @@
import { resources, elements } from "./planetEconomy.js"; import { resources, elements, population } from "./planetEconomy.js";
// ── Sort state (resources) ──────────────────────────────────────────────────── // ── Sort state (resources) ────────────────────────────────────────────────────
@@ -265,3 +265,109 @@ export function renderElementBonusTable(elementWorth, teamByElement) {
<tbody>${tableRows}</tbody> <tbody>${tableRows}</tbody>
</table>`; </table>`;
} }
// ── 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<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
* @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
* @returns {{ total: number, byType: Map<string, number> }}
* 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<string, number>} 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 `<p class="econEmpty">Aucune tuile conquise avec population militarisable.</p>`;
}
const tableRows = rows
.map(({ label, pct, mil, milStr }) => {
const milClass = mil > 0 ? " econ-income--positive" : "";
return `<tr>
<td class="econ-label">${label}</td>
<td class="econ-worth">${pct}%</td>
<td class="econ-income${milClass}">${milStr}</td>
</tr>`;
})
.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 `<th class="econTh${activeClass}" data-mil-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
})
.join("");
return `<table class="econTable">
<thead><tr>${headers}</tr></thead>
<tbody>${tableRows}</tbody>
</table>`;
}

View File

@@ -1,7 +1,7 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js"; import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js"; import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers } from "./api.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 } from "./economy.js"; import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort } from "./economy.js";
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@@ -29,6 +29,7 @@ export const GAME_CONFIG = {
seedPeriodEndsAtUtc: "", seedPeriodEndsAtUtc: "",
elementWorth: {}, elementWorth: {},
resourceWorth: { common: {}, rare: {} }, resourceWorth: { common: {}, rare: {} },
militaryPower: {},
}; };
window.GAME_CONFIG = GAME_CONFIG; window.GAME_CONFIG = GAME_CONFIG;
@@ -188,6 +189,13 @@ const econDeltaRedEl = document.getElementById("econDeltaRed");
const elemBonusTableEl = document.getElementById("elementBonusTableBody"); const elemBonusTableEl = document.getElementById("elementBonusTableBody");
const activeCountBlueEl = document.getElementById("activeCountBlue"); const activeCountBlueEl = document.getElementById("activeCountBlue");
const activeCountRedEl = document.getElementById("activeCountRed"); 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 ────────────────────────────────────────────────────────────── // ── Cell helpers ──────────────────────────────────────────────────────────────
export function cellKey(x, y) { return `${x},${y}`; } export function cellKey(x, y) { return `${x},${y}`; }
@@ -221,6 +229,9 @@ export function applyConfigPayload(data) {
if (data.resourceWorth && typeof data.resourceWorth === "object") { if (data.resourceWorth && typeof data.resourceWorth === "object") {
GAME_CONFIG.resourceWorth = data.resourceWorth; GAME_CONFIG.resourceWorth = data.resourceWorth;
} }
if (data.militaryPower && typeof data.militaryPower === "object") {
GAME_CONFIG.militaryPower = data.militaryPower;
}
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds); cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—"; seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
@@ -281,6 +292,21 @@ export async function fetchAndApplyActivePlayers() {
let elemBonusBlue = 0; let elemBonusBlue = 0;
let elemBonusRed = 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() { function updateEffectiveCooldownDisplay() {
const base = GAME_CONFIG.clickCooldownSeconds; const base = GAME_CONFIG.clickCooldownSeconds;
const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed; const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
@@ -362,6 +388,19 @@ export function updateEconomyDisplay() {
if (elemBonusTableEl) { if (elemBonusTableEl) {
elemBonusTableEl.innerHTML = renderElementBonusTable(elemWorth, teamElemBonus.byElement); 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 ──────────────────────────────────────────────────────────── // ── Economic score ────────────────────────────────────────────────────────────
@@ -609,8 +648,16 @@ function pickCell(ev) {
function refreshCursor(ev) { function refreshCursor(ev) {
const cell = pickCell(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"; 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 { } else {
canvas.style.cursor = "pointer"; 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)}`; 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<boolean>.
* 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 ────────────────────────────────────────────────────── // ── Canvas click handler ──────────────────────────────────────────────────────
async function onCanvasClick(ev) { async function onCanvasClick(ev) {
@@ -674,7 +748,45 @@ async function onCanvasClick(ev) {
if (!cell || !isExploitable(cell.x, cell.y)) return; if (!cell || !isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y); 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; } if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
@@ -777,6 +889,18 @@ elemBonusTableEl?.addEventListener("click", (ev) => {
updateEconomyDisplay(); 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("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; }); canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick); canvas.addEventListener("click", onCanvasClick);

View File

@@ -11,6 +11,7 @@ import {
loadVictoryPoints, loadVictoryPoints,
loadDbInfo, loadDbInfo,
loadElementBonus, loadElementBonus,
loadMilitaryDeductions,
refreshFromServer, refreshFromServer,
refreshGridDisplay, refreshGridDisplay,
loadPlayfieldMask, loadPlayfieldMask,
@@ -58,6 +59,7 @@ function scheduleScorePoll() {
await fetchAndApplyActivePlayers(); await fetchAndApplyActivePlayers();
await loadEconScores(); await loadEconScores();
await loadElementBonus(); await loadElementBonus();
await loadMilitaryDeductions();
scheduleScorePoll(); scheduleScorePoll();
}, ECON_TICK_SECONDS * 1_000); }, ECON_TICK_SECONDS * 1_000);
} }
@@ -108,6 +110,7 @@ async function boot() {
await loadVictoryPoints(); await loadVictoryPoints();
await loadDbInfo(); await loadDbInfo();
await loadElementBonus(); await loadElementBonus();
await loadMilitaryDeductions();
updateEconomyDisplay(); updateEconomyDisplay();
} catch { } catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build)."; hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";

View File

@@ -941,6 +941,136 @@ button:hover {
color: rgba(255, 220, 100, 0.9); 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 ────────────────────── */ /* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
.galaxyMain { .galaxyMain {

View File

@@ -7,7 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE_PATH = const CONFIG_FILE_PATH =
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json"); 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 = { let cached = {
clickCooldownSeconds: 5, clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600, databaseWipeoutIntervalSeconds: 21600,
@@ -15,6 +15,7 @@ let cached = {
configReloadIntervalSeconds: 30, configReloadIntervalSeconds: 30,
elementWorth: {}, elementWorth: {},
resourceWorth: { common: {}, rare: {} }, resourceWorth: { common: {}, rare: {} },
militaryPower: {},
}; };
let lastMtimeMs = 0; let lastMtimeMs = 0;
@@ -53,8 +54,9 @@ export function loadConfigFile() {
} }
if (j.resourceWorth && typeof j.resourceWorth === "object") { if (j.resourceWorth && typeof j.resourceWorth === "object") {
cached.resourceWorth = j.resourceWorth; cached.resourceWorth = j.resourceWorth;
} } if (j.militaryPower && typeof j.militaryPower === 'object') {
lastMtimeMs = st.mtimeMs; cached.militaryPower = j.militaryPower;
} lastMtimeMs = st.mtimeMs;
} catch (e) { } catch (e) {
if (e.code === "ENOENT") { if (e.code === "ENOENT") {
lastMtimeMs = 0; lastMtimeMs = 0;

View File

@@ -54,6 +54,26 @@ export async function initGameSchema() {
PRIMARY KEY (world_seed, team) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS db_metadata ( CREATE TABLE IF NOT EXISTS db_metadata (
id SERIAL PRIMARY KEY, 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 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_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_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.`); console.log(`[world] Slot ${lastSeedSlot}${seedSlot}; grid wiped, old cooldowns cleared.`);
lastSeedSlot = seedSlot; lastSeedSlot = seedSlot;
} }
@@ -287,3 +309,49 @@ export async function getActivePlayerCounts(worldSeed) {
for (const row of rows) result[row.team] = row.count; for (const row of rows) result[row.team] = row.count;
return result; 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]
);
}

View File

@@ -19,6 +19,11 @@ import {
getDbCreatedAt, getDbCreatedAt,
getVictoryPoints, getVictoryPoints,
getActivePlayerCounts, getActivePlayerCounts,
getMilitaryDeductions,
addMilitaryDeduction,
recordCellAttack,
getCellAttackCount,
setTileOwner,
} from "../db/gameDb.js"; } from "../db/gameDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js";
@@ -73,6 +78,7 @@ router.get("/config", async (req, res) => {
teamCooldownRemaining, teamCooldownRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {}, elementWorth: cfg.elementWorth ?? {},
militaryPower: cfg.militaryPower ?? {},
}); });
} catch (e) { } catch (e) {
console.error(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; export default router;