feat(gameplay): Adding a military power section to exploit population numbers and steal ennemy tiles
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -224,6 +224,25 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
|
||||
@@ -232,6 +251,19 @@
|
||||
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu">☰</button>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resources, elements } from "./planetEconomy.js";
|
||||
import { resources, elements, population } from "./planetEconomy.js";
|
||||
|
||||
// ── Sort state (resources) ────────────────────────────────────────────────────
|
||||
|
||||
@@ -265,3 +265,109 @@ export function renderElementBonusTable(elementWorth, teamByElement) {
|
||||
<tbody>${tableRows}</tbody>
|
||||
</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>`;
|
||||
}
|
||||
@@ -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<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 ──────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
@@ -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).";
|
||||
|
||||
130
public/style.css
130
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user