diff --git a/config/game.settings.json b/config/game.settings.json index f7a89d6..f263196 100644 --- a/config/game.settings.json +++ b/config/game.settings.json @@ -1,6 +1,7 @@ { "dailyActionQuota": 100, - "databaseWipeoutIntervalSeconds": 604800, + "teamActionQuota": 100, + "databaseWipeoutIntervalSeconds": 600, "configReloadIntervalSeconds": 30, "elementWorth": { "common": 0.1, diff --git a/public/graphism/playground.svg b/public/graphism/playground.svg index 249ae0f..d72a76b 100644 --- a/public/graphism/playground.svg +++ b/public/graphism/playground.svg @@ -22,14 +22,14 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="1.122" - inkscape:cx="75.311944" - inkscape:cy="524.95544" - inkscape:window-width="1718" - inkscape:window-height="1341" - inkscape:window-x="1712" - inkscape:window-y="231" - inkscape:window-maximized="0" + inkscape:zoom="0.79337381" + inkscape:cx="255.23908" + inkscape:cy="554.59355" + inkscape:window-width="3440" + inkscape:window-height="1351" + inkscape:window-x="-9" + inkscape:window-y="222" + inkscape:window-maximized="1" inkscape:current-layer="svg1" /><title id="title36">outer_regions diff --git a/public/index.html b/public/index.html index ce8d5e8..56a2312 100644 --- a/public/index.html +++ b/public/index.html @@ -93,10 +93,6 @@
Résistance
-
- 0 - Tuiles -
0 Points @@ -111,10 +107,6 @@ 0 Points
-
- 0 - Tuiles -
@@ -267,10 +259,10 @@ En explorant la galaxie, vous révélez des tuiles qui peuvent contenir des planètes. Chaque planète possède des ressources naturelles (minerais, bois, pétrole, etc.) dont la valeur contribue au revenu de votre équipe.

⚡ Bonus d'exploration
- Les planètes produisent des éléments (matières premières, carburant, nourriture, science…) qui offrent un bonus cumulatif. Ce bonus réduit le temps de recharge entre deux clics, permettant d'explorer plus vite.

+ Les planètes produisent des éléments (matières premières, carburant, nourriture, science…) qui offrent un bonus cumulatif. Ce bonus augmente le pourcentage d'actions disponibles pour vous.

⚔️ Puissance militaire
- La population des planètes conquises fournit des unités militaires. Lorsque vous accumulez suffisamment de troupes, vous pouvez lancer une attaque sur une tuile ennemie pour la capturer. Chaque attaque consomme une partie de vos forces.

+ La population des planètes conquises fournit de la puissance militaire. Cette puissance augmente le quota d'actions d'équipe pour capturer des planètes.

@@ -313,6 +305,19 @@ + + + diff --git a/public/src/api.js b/public/src/api.js index 9b9b5e6..b9e1615 100644 --- a/public/src/api.js +++ b/public/src/api.js @@ -10,15 +10,11 @@ export async function apiFetchConfig(team) { return res.json(); } -export async function apiFetchScores() { - const res = await fetch("/api/scores"); - if (!res.ok) throw new Error("scores_fetch_failed"); - return res.json(); -} - /** Returns the raw Response so the caller can inspect status codes (410, etc.). */ export async function apiFetchGrid(seed) { - return fetch(`/api/grid/${encodeURIComponent(seed)}`); + const token = localStorage.getItem("authToken"); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + return fetch(`/api/grid/${encodeURIComponent(seed)}`, { headers }); } /** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */ @@ -136,3 +132,16 @@ export async function apiFetchCellAttackCount(x, y) { if (!res.ok) throw new Error("cell_attacks_fetch_failed"); return res.json(); } + +/** Returns the raw Response so the caller can inspect status codes. */ +export async function apiCaptureCell(seed, x, y) { + const token = localStorage.getItem("authToken"); + return fetch("/api/cell/capture", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ seed, x, y }), + }); +} diff --git a/public/src/economy.js b/public/src/economy.js index ffa3188..c044ce9 100644 --- a/public/src/economy.js +++ b/public/src/economy.js @@ -60,7 +60,7 @@ export function computeTeamIncome(team, cells, resourceWorth) { let total = 0; for (const [, meta] of cells) { - if (meta.discoveredBy !== team) continue; + if (meta.controlledBy !== team) continue; if (!meta.hasPlanet || !meta.planet) continue; const { naturalResources } = meta.planet; if (!naturalResources) continue; @@ -102,7 +102,7 @@ const ELEMENT_LABEL_TO_KEY = Object.fromEntries( export function computeTeamElementBonus(team, cells, elementWorth) { let bonus = 0; for (const [, meta] of cells) { - if (meta.discoveredBy !== team) continue; + if (meta.controlledBy !== team) continue; if (!meta.hasPlanet || !meta.planet) continue; const { production } = meta.planet; if (!production) continue; @@ -130,7 +130,7 @@ export function computeTeamElementBonusDetailed(team, cells, elementWorth) { const byElement = new Map(); let total = 0; for (const [, meta] of cells) { - if (meta.discoveredBy !== team) continue; + if (meta.controlledBy !== team) continue; if (!meta.hasPlanet || !meta.planet) continue; const { production } = meta.planet; if (!production) continue; @@ -298,7 +298,7 @@ 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.controlledBy !== team) continue; if (!meta.hasPlanet || !meta.planet) continue; const pop = meta.planet.population; if (!pop) continue; diff --git a/public/src/game.js b/public/src/game.js index dfef486..d481df3 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -1,6 +1,6 @@ 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, apiFetchMilitaryDeductions, apiMilitaryAttack } from "./api.js"; +import { apiFetchConfig, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell } from "./api.js"; import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort } from "./economy.js"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -34,7 +34,7 @@ export const GAME_CONFIG = { }; window.GAME_CONFIG = GAME_CONFIG; -/** @type {Map} */ +/** @type {Map} */ export const cells = new Map(); export let currentTeam = "blue"; @@ -206,8 +206,6 @@ const cooldownCfgEl = document.getElementById("cooldownConfig"); const seedDisplayEl = document.getElementById("worldSeedDisplay"); const nextPeriodEl = document.getElementById("nextPeriodUtc"); const resetCountEl = document.getElementById("refreshCountdown"); -const scoreBlueEl = document.getElementById("scoreBlue"); -const scoreRedEl = document.getElementById("scoreRed"); const vpBlueEl = document.getElementById("vpBlue"); const vpRedEl = document.getElementById("vpRed"); const dbCreatedAtEl = document.getElementById("dbCreatedAt"); @@ -231,6 +229,10 @@ const attackOverlayEl = document.getElementById("attackOverlay"); const attackModalBodyEl = document.getElementById("attackModalBody"); const attackModalYesEl = document.getElementById("attackModalYes"); const attackModalNoEl = document.getElementById("attackModalNo"); +const captureOverlayEl = document.getElementById("captureOverlay"); +const captureModalBodyEl = document.getElementById("captureModalBody"); +const captureModalYesEl = document.getElementById("captureModalYes"); +const captureModalNoEl = document.getElementById("captureModalNo"); const teamQuotaEl = document.getElementById("teamActionsRemaining"); // ── Cell helpers ────────────────────────────────────────────────────────────── @@ -247,8 +249,8 @@ function hasPlanetAt(x, y) { } export function cellMeta(key) { return cells.get(key) ?? null; } -export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; } -export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; } +export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.controlledBy !== null && m.controlledBy !== currentTeam; } +export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.controlledBy === currentTeam; } // ── Config display ──────────────────────────────────────────────────────────── @@ -306,14 +308,6 @@ export function updateResetCountdown() { // ── Scores ──────────────────────────────────────────────────────────────────── -export async function fetchAndApplyScores() { - try { - const { blue, red } = await apiFetchScores(); - if (scoreBlueEl) scoreBlueEl.textContent = String(blue ?? 0); - if (scoreRedEl) scoreRedEl.textContent = String(red ?? 0); - } catch { /* ignore */ } -} - export async function loadVictoryPoints() { try { const { blue, red } = await apiFetchVictoryPoints(); @@ -537,7 +531,7 @@ export async function fetchGridForSeed(seed, depth = 0) { cells.clear(); for (const row of data.cells || []) { cells.set(cellKey(row.x, row.y), { - discoveredBy: row.discovered_by ?? row.discoveredBy, + controlledBy: row.discovered_by ?? row.discoveredBy ?? null, hasPlanet: Boolean(row.has_planet), planet: row.planet_json ?? null, }); @@ -622,20 +616,22 @@ export function draw() { const meta = cellMeta(k); const alpha = tileAlpha(k); ctx.globalAlpha = alpha; - if (!meta) ctx.fillStyle = COLOR_RING_IDLE; - else if (meta.discoveredBy !== currentTeam) ctx.fillStyle = COLOR_OPPONENT_GREY; - else ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED; + if (!meta) ctx.fillStyle = COLOR_RING_IDLE; + else if (!meta.hasPlanet) ctx.fillStyle = COLOR_OPPONENT_GREY; // empty or unexploitable + else if (meta.controlledBy === null) ctx.fillStyle = COLOR_OPPONENT_GREY; // neutral planet + else if (meta.controlledBy === currentTeam) ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED; + else ctx.fillStyle = currentTeam === "blue" ? COLOR_RED_DISCOVERED : COLOR_BLUE_DISCOVERED; // opponent-controlled ctx.fillRect(x * cw, y * ch, cw, ch); ctx.globalAlpha = 1; } } - // 5. Draw planet dots on own discovered tiles + // 5. Draw planet dots on all revealed tiles with planets for (const [key, meta] of cells) { - if (meta.discoveredBy !== currentTeam) continue; + if (!meta.hasPlanet) continue; const [xs, ys] = key.split(",").map(Number); if (xs < xMin || xs > xMax || ys < yMin || ys > yMax) continue; - if (!isExploitable(xs, ys) || !meta.hasPlanet) continue; + if (!isExploitable(xs, ys)) continue; const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337); const rng = mulberry32(h32); ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`; @@ -682,13 +678,15 @@ function refreshCursor(ev) { 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 { + const meta = cells.get(key); + if (!meta) { + // Not yet revealed — can reveal if quota available + canvas.style.cursor = cooldownActive() ? "default" : "pointer"; + } else if (meta.hasPlanet && meta.controlledBy !== currentTeam) { + // Capturable (neutral or opponent-controlled) planet canvas.style.cursor = "pointer"; + } else { + canvas.style.cursor = "default"; } } @@ -702,7 +700,7 @@ function applyRevealPayload(cell) { const _revealKey = cellKey(cell.x, cell.y); markTileReveal(_revealKey); cells.set(_revealKey, { - discoveredBy: cell.discoveredBy ?? currentTeam, + controlledBy: cell.discoveredBy ?? null, hasPlanet: Boolean(cell.hasPlanet), planet: cell.planet ?? null, }); @@ -717,21 +715,26 @@ function applyRevealPayload(cell) { details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Vide`; return; } + const controlStatus = cell.discoveredBy === null + ? "Neutre (non capturée)" + : cell.discoveredBy === currentTeam + ? `Contrôlée (${currentTeam === "blue" ? "Résistance" : "Premier Ordre"})` + : `Contrôlée par l'adversaire`; hint.textContent = `(${cell.x},${cell.y}) Planète présente`; - details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Planète\n\n${formatPlanet(cell.planet)}`; + details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(cell.planet)}`; } function showLocalSelection(x, y) { const k = cellKey(x, y); + const meta = cellMeta(k); details.classList.remove("details--hidden"); - if (!isOwnTile(k)) return; + if (!meta) return; if (!isExploitable(x, y)) { hint.textContent = `(${x},${y}) Inexploitable`; details.textContent = `Tuile (${x},${y})\n\nStatus : Inexploitable`; return; } - const meta = cellMeta(k); - if (!meta?.hasPlanet) { + if (!meta.hasPlanet) { hint.textContent = `(${x},${y}) Vide`; details.textContent = `Tuile (${x},${y})\n\nStatus : Vide`; return; @@ -741,8 +744,13 @@ function showLocalSelection(x, y) { const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5); planet = generatePlanet(mulberry32(h)); } + const controlStatus = meta.controlledBy === null + ? "Neutre (non capturée)" + : meta.controlledBy === currentTeam + ? `Contrôlée (${currentTeam === "blue" ? "Résistance" : "Premier Ordre"})` + : `Contrôlée par l'adversaire`; hint.textContent = `(${x},${y}) Planète présente`; - details.textContent = `Tuile (${x},${y})\n\nStatus : Planète\n\n${formatPlanet(planet)}`; + details.textContent = `Tuile (${x},${y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(planet)}`; } // ── Military attack modal ──────────────────────────────────────────────────── @@ -772,89 +780,133 @@ function showAttackModal(milNetM, x, y) { }); } +// ── Planet capture modal ───────────────────────────────────────────────────── + +/** + * Shows the capture confirmation modal. + * Returns a Promise — true if confirmed, false if cancelled. + */ +function showCaptureModal(planet, controlledBy, cost, x, y) { + const opponentName = currentTeam === "blue" ? "Premier Ordre" : "Résistance"; + const controlLabel = controlledBy === null + ? "Planète neutre" + : `Planète contrôlée par ${opponentName} (coût doublé)`; + captureModalBodyEl.textContent = + `Planète (${x}, ${y}) — ${planet?.name ?? "Inconnue"}\n\n` + + `${controlLabel}\n\n` + + `Coût : ${cost} action${cost > 1 ? "s" : ""} d'équipe\n` + + `Actions d'équipe disponibles : ${teamActionsRemaining ?? "?"}\n\n` + + `La planète passera sous votre contrôle. Ses ressources, bonus et puissance militaire\n` + + `seront calculés pour votre équipe.`; + captureOverlayEl.classList.remove("hidden"); + return new Promise((resolve) => { + function cleanup() { + captureOverlayEl.classList.add("hidden"); + captureModalYesEl.removeEventListener("click", onYes); + captureModalNoEl.removeEventListener("click", onNo); + } + function onYes() { cleanup(); resolve(true); } + function onNo() { cleanup(); resolve(false); } + captureModalYesEl.addEventListener("click", onYes); + captureModalNoEl.addEventListener("click", onNo); + }); +} + // ── Canvas click handler ────────────────────────────────────────────────────── async function onCanvasClick(ev) { const cell = pickCell(ev); if (!cell || !isExploitable(cell.x, cell.y)) return; const key = cellKey(cell.x, cell.y); + const meta = cells.get(key); - // ── 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); - markTileReveal(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 (cooldownActive()) { - hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; - return; - } - - try { - const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam); - if (res.status === 409) { - hint.textContent = "Cette tuile a déjà été découverte par votre adversaire."; - await fetchGridForSeed(seedStr); - draw(); - return; - } - if (res.status === 429) { - actionsRemaining = 0; - updateActionsDisplay(); + // ── Not yet revealed: first click = spend user action to reveal ── + if (!meta) { + if (cooldownActive()) { hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; return; } + try { + const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam); + if (res.status === 429) { + actionsRemaining = 0; + updateActionsDisplay(); + hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; + return; + } + if (res.status === 410) { + hint.textContent = "Le serveur change sa base de planètes — synchronisation..."; + await refreshFromServer(); return; + } + if (!res.ok) throw new Error("reveal"); + applyRevealPayload(await res.json()); + startCooldown(); + updateEconomyDisplay(); + draw(); + } catch (e) { + if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; } + hint.textContent = "Could not save reveal — check server / database."; + } + return; + } + + // ── Already revealed: empty cell or own planet — show info only ── + if (!meta.hasPlanet || meta.controlledBy === currentTeam) { + showLocalSelection(cell.x, cell.y); + return; + } + + // ── Revealed planet, neutral or opponent-controlled: offer capture ── + showLocalSelection(cell.x, cell.y); + + const popBillions = meta.planet?.population?.billions ?? 0; + const baseCost = Math.max(1, Math.ceil(popBillions / 10)); + const cost = (meta.controlledBy !== null) ? baseCost * 2 : baseCost; + + if (teamActionsRemaining !== null && teamActionsRemaining < cost) { + hint.textContent = `Planète repérée. Actions d'équipe insuffisantes pour capturer (coût : ${cost}, disponibles : ${teamActionsRemaining}).`; + return; + } + + const confirmed = await showCaptureModal(meta.planet, meta.controlledBy, cost, cell.x, cell.y); + if (!confirmed) return; + + try { + const res = await apiCaptureCell(seedStr, cell.x, cell.y); if (res.status === 410) { hint.textContent = "Le serveur change sa base de planètes — synchronisation..."; - await refreshFromServer(); + await refreshFromServer(); return; + } + if (res.status === 429) { + const data = await res.json(); + teamActionsRemaining = data.teamActionsRemaining ?? teamActionsRemaining; + updateTeamQuotaDisplay(); + hint.textContent = `Actions d'équipe insuffisantes (coût : ${data.cost ?? cost}, disponibles : ${data.teamActionsRemaining ?? "?"}).`; return; } - if (!res.ok) throw new Error("reveal"); - applyRevealPayload(await res.json()); - startCooldown(); + if (res.status === 409) { + const data = await res.json(); + if (data.error === "already_owned") { + hint.textContent = "Cette planète vous appartient déjà."; + } else { + hint.textContent = "Impossible de capturer cette tuile."; + } + return; + } + if (!res.ok) { hint.textContent = "Erreur lors de la capture."; return; } + const data = await res.json(); + markTileReveal(key); + cells.set(key, { ...meta, controlledBy: currentTeam }); + if (data.teamActionsRemaining !== undefined && data.teamActionsRemaining !== null) { + teamActionsRemaining = data.teamActionsRemaining; + updateTeamQuotaDisplay(); + } + hint.textContent = `🏴 Planète (${cell.x},${cell.y}) capturée !`; + showLocalSelection(cell.x, cell.y); updateEconomyDisplay(); draw(); - fetchAndApplyScores(); - } catch (e) { - if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; } - hint.textContent = "Could not save reveal — check server / database."; + } catch { + hint.textContent = "Erreur réseau lors de la capture."; } } @@ -887,7 +939,6 @@ export async function refreshFromServer() { hint.textContent = "Le monde a été réinitilisaté. Vous pouvez cliquer sur une tuile pour recommencer le jeu."; } await fetchGridForSeed(seedStr); - await fetchAndApplyScores(); await fetchAndApplyActivePlayers(); updateEconomyDisplay(); draw(); diff --git a/public/src/main.js b/public/src/main.js index 3293402..e2d9622 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -4,7 +4,6 @@ import { updateResetCountdown, fetchConfig, fetchGridForSeed, - fetchAndApplyScores, fetchAndApplyActivePlayers, updateEconomyDisplay, loadEconScores, @@ -55,7 +54,6 @@ const ECON_TICK_SECONDS = 5; function scheduleScorePoll() { clearTimeout(scorePollTimer); scorePollTimer = window.setTimeout(async () => { - await fetchAndApplyScores(); await fetchAndApplyActivePlayers(); await loadEconScores(); await loadElementBonus(); @@ -104,7 +102,6 @@ async function boot() { try { await fetchConfig(); await fetchGridForSeed(seedStr); - await fetchAndApplyScores(); await fetchAndApplyActivePlayers(); await loadEconScores(); await loadVictoryPoints(); diff --git a/public/style.css b/public/style.css index 9a021b7..2311e89 100644 --- a/public/style.css +++ b/public/style.css @@ -352,8 +352,8 @@ body { } .scoreTeam--blue .scoreVP { - color: rgba(130, 230, 130, 1); - text-shadow: 0 0 16px rgba(90, 200, 130, 0.35); + color: rgba(90, 200, 255, 1); + text-shadow: 0 0 16px rgba(90, 200, 255, 0.4); } .scoreTeam--red .scoreValue { @@ -362,8 +362,8 @@ body { } .scoreTeam--red .scoreVP { - color: rgba(230, 150, 80, 1); - text-shadow: 0 0 16px rgba(220, 130, 60, 0.35); + color: rgba(220, 75, 85, 1); + text-shadow: 0 0 16px rgba(220, 75, 85, 0.4); } .scoreSep { diff --git a/server/configLoader.js b/server/configLoader.js index 0ac995a..b767fdf 100644 --- a/server/configLoader.js +++ b/server/configLoader.js @@ -7,9 +7,10 @@ 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 {{ dailyActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */ +/** @type {{ dailyActionQuota: number, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */ let cached = { dailyActionQuota: 100, + teamActionQuota: 100, databaseWipeoutIntervalSeconds: 21600, debugModeForTeams: true, configReloadIntervalSeconds: 30, @@ -41,6 +42,9 @@ export function loadConfigFile() { if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) { cached.dailyActionQuota = Math.floor(j.dailyActionQuota); } + if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) { + cached.teamActionQuota = Math.floor(j.teamActionQuota); + } if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) { cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds; } diff --git a/server/db/gameDb.js b/server/db/gameDb.js index 62b0ffc..f54b92d 100644 --- a/server/db/gameDb.js +++ b/server/db/gameDb.js @@ -1,6 +1,7 @@ import { pool } from "./pools.js"; import { loadConfigFile, getConfig } from "../configLoader.js"; import { computeWorldSeedState } from "../worldSeed.js"; +import { nextNoonUtc, resetAllUserActions } from "./usersDb.js"; let lastSeedSlot = null; @@ -113,6 +114,32 @@ export async function initGameSchema() { quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00' ); `); + // ── Per-team cell visibility (who has revealed what) ────────────────────── + await pool.query(` + CREATE TABLE IF NOT EXISTS team_cell_visibility ( + world_seed TEXT NOT NULL, + team TEXT NOT NULL CHECK (team IN ('blue', 'red')), + x SMALLINT NOT NULL, + y SMALLINT NOT NULL, + PRIMARY KEY (world_seed, team, x, y) + ); + CREATE INDEX IF NOT EXISTS idx_tcv_seed_team ON team_cell_visibility (world_seed, team); + `); + // ── Make discovered_by nullable (NULL = neutral, uncaptured) ───────────── + await pool.query(` + ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP NOT NULL; + ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP DEFAULT; + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check' + ) THEN + ALTER TABLE grid_cells DROP CONSTRAINT grid_cells_discovered_by_check; + END IF; + END $$; + ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check + CHECK (discovered_by IS NULL OR discovered_by IN ('blue', 'red')); + `); } // ── World-seed epoch ────────────────────────────────────────────────────────── @@ -153,6 +180,19 @@ export async function ensureSeedEpoch() { 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]); + await pool.query("DELETE FROM team_cell_visibility WHERE world_seed != $1", [worldSeed]); + // Reset both team and user quotas to server defaults (no bonuses) for the new period + const cfg = getConfig(); + const nextNoon = nextNoonUtc().toISOString(); + await pool.query( + `INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at) + VALUES ('blue', $1, $2), ('red', $1, $2) + ON CONFLICT (team) DO UPDATE + SET actions_remaining = $1, + quota_reset_at = $2`, + [cfg.teamActionQuota, nextNoon] + ); + await resetAllUserActions(cfg.dailyActionQuota, nextNoon); console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`); lastSeedSlot = seedSlot; } @@ -170,17 +210,52 @@ export async function getGridCells(worldSeed) { return rows; } -export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) { +export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson) { const { rows } = await pool.query( `INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, NULL) ON CONFLICT (world_seed, x, y) DO NOTHING RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`, - [seed, x, y, exploitable, hasPlanet, planetJson, team] + [seed, x, y, exploitable, hasPlanet, planetJson] ); return rows[0] ?? null; } +// ── Team cell visibility ────────────────────────────────────────────────────── + +/** Returns true if team has already revealed this cell. */ +export async function checkTeamVisibility(worldSeed, team, x, y) { + const { rows } = await pool.query( + `SELECT 1 FROM team_cell_visibility WHERE world_seed = $1 AND team = $2 AND x = $3 AND y = $4`, + [worldSeed, team, x, y] + ); + return rows.length > 0; +} + +/** Inserts a visibility record. Returns true if newly inserted (first reveal). */ +export async function insertTeamVisibility(worldSeed, team, x, y) { + const { rowCount } = await pool.query( + `INSERT INTO team_cell_visibility (world_seed, team, x, y) + VALUES ($1, $2, $3, $4) + ON CONFLICT (world_seed, team, x, y) DO NOTHING`, + [worldSeed, team, x, y] + ); + return rowCount > 0; +} + +/** Returns all grid cells that are visible to a given team. */ +export async function getTeamVisibleCells(worldSeed, team) { + const { rows } = await pool.query( + `SELECT gc.x, gc.y, gc.exploitable, gc.has_planet, gc.planet_json, gc.discovered_by + FROM grid_cells gc + JOIN team_cell_visibility tcv + ON tcv.world_seed = gc.world_seed AND tcv.x = gc.x AND tcv.y = gc.y + WHERE gc.world_seed = $1 AND tcv.team = $2`, + [worldSeed, team] + ); + return rows; +} + export async function getExistingCell(seed, x, y) { const { rows } = await pool.query( `SELECT x, y, exploitable, has_planet, planet_json, discovered_by @@ -296,7 +371,7 @@ export async function getVictoryPoints() { export async function getScores(worldSeed) { const { rows } = await pool.query( `SELECT discovered_by, COUNT(*) AS cnt - FROM grid_cells WHERE world_seed = $1 + FROM grid_cells WHERE world_seed = $1 AND discovered_by IS NOT NULL GROUP BY discovered_by`, [worldSeed] ); @@ -398,3 +473,18 @@ export async function decrementTeamActions(team) { ); return rows[0] ?? null; } + +/** + * Atomically decrements team actions_remaining by `amount` if sufficient. + * Returns the updated row, or null if not enough actions. + */ +export async function decrementTeamActionsBy(team, amount) { + const { rows } = await pool.query( + `UPDATE team_action_quota + SET actions_remaining = actions_remaining - $2 + WHERE team = $1 AND actions_remaining >= $2 + RETURNING actions_remaining`, + [team, amount] + ); + return rows[0] ?? null; +} diff --git a/server/db/usersDb.js b/server/db/usersDb.js index abc13c2..9d3d76f 100644 --- a/server/db/usersDb.js +++ b/server/db/usersDb.js @@ -111,4 +111,19 @@ export async function decrementUserActions(userId) { [userId] ); return rows[0] ?? null; +} + +/** + * Resets ALL users' quota to a given value (used on world-seed wipeout). + * Users who have no row yet get one inserted. + */ +export async function resetAllUserActions(actionsRemaining, quotaResetAt) { + await usersPool.query( + `INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at) + SELECT id, $1, $2 FROM users + ON CONFLICT (user_id) DO UPDATE + SET actions_remaining = $1, + quota_reset_at = $2`, + [actionsRemaining, quotaResetAt] + ); } \ No newline at end of file diff --git a/server/routes/game.js b/server/routes/game.js index b0d68f2..33cbfe8 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -27,6 +27,10 @@ import { getTeamActionsRow, resetTeamActions, decrementTeamActions, + decrementTeamActionsBy, + checkTeamVisibility, + insertTeamVisibility, + getTeamVisibleCells, } from "../db/gameDb.js"; import { nextNoonUtc, @@ -78,7 +82,7 @@ router.get("/config", async (req, res) => { const milDeductions = await getMilitaryDeductions(worldSeed); const milNet = milPower - (milDeductions[team] ?? 0); const milBonus = Math.floor(Math.max(0, milNet) / 10000); - teamActionsRemaining = 100 + milBonus; + teamActionsRemaining = cfg.teamActionQuota + milBonus; } else { teamActionsRemaining = teamRow.actions_remaining; } @@ -104,7 +108,7 @@ router.get("/config", async (req, res) => { } }); -// GET /api/grid/:seed +// GET /api/grid/:seed (auth-aware: returns only team-visible cells) router.get("/grid/:seed", async (req, res) => { const seed = decodeURIComponent(req.params.seed || ""); try { @@ -118,7 +122,14 @@ router.get("/grid/:seed", async (req, res) => { ).seedPeriodEndsAtUtc, }); } - const rows = await getGridCells(seed); + let rows = []; + const authHeader = req.headers["authorization"]; + if (authHeader && authHeader.startsWith("Bearer ")) { + try { + const payload = jwt.verify(authHeader.slice(7), JWT_SECRET); + rows = await getTeamVisibleCells(worldSeed, payload.team); + } catch { /* invalid token — return empty grid */ } + } res.json({ seed, cells: rows }); } catch (e) { console.error(e); @@ -126,10 +137,10 @@ router.get("/grid/:seed", async (req, res) => { } }); -// POST /api/cell/reveal +// POST /api/cell/reveal — reveals a cell for THIS team only (private visibility) router.post("/cell/reveal", authMiddleware, async (req, res) => { const seed = String(req.body?.seed ?? ""); - const team = req.user.team; // taken from verified JWT, not request body + const team = req.user.team; const userId = req.user.userId; const x = Number(req.body?.x); const y = Number(req.body?.y); @@ -144,50 +155,42 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => { return res.status(410).json({ error: "seed_expired", worldSeed }); } - const cfg = getConfig(); - if (cfg.dailyActionQuota > 0) { - const bonus = await getElementBonus(worldSeed); - const teamBonus = bonus[team] ?? 0; - const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100)); - const now = new Date(); - const quotaRow = await getUserActionsRow(userId); + // Check if already revealed by this team (free re-view, no action cost) + const alreadyVisible = await checkTeamVisibility(worldSeed, team, x, y); - if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) { - // First action of the day (or expired): reset to effectiveQuota - 1 (consuming 1 now) - await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString()); - } else { - // Quota exists and still valid — try to consume - const updated = await decrementUserActions(userId); - if (!updated) { - return res.status(429).json({ - error: "quota_exhausted", - actionsRemaining: 0, - }); + if (!alreadyVisible) { + // First reveal: deduct one user action + const cfg = getConfig(); + if (cfg.dailyActionQuota > 0) { + const bonus = await getElementBonus(worldSeed); + const teamBonus = bonus[team] ?? 0; + const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100)); + const now = new Date(); + const quotaRow = await getUserActionsRow(userId); + if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) { + await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString()); + } else { + const updated = await decrementUserActions(userId); + if (!updated) { + return res.status(429).json({ error: "quota_exhausted", actionsRemaining: 0 }); + } } } + // Mark as visible for this team + await insertTeamVisibility(worldSeed, team, x, y); } + // Ensure cell data is in grid_cells (discovered_by stays NULL = neutral) const cell = computeCell(seed, x, y); const planetJson = cell.planet ? JSON.stringify(cell.planet) : null; - const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team); + await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson); - // Track activity for active-player count (kept for stat purposes) + // Track activity for active-player count await upsertUserCooldown(worldSeed, userId, team); - if (inserted) { - return res.json(rowToCellPayload(inserted)); - } - const existing = await getExistingCell(seed, x, y); if (!existing) return res.status(500).json({ error: "insert_race" }); - if (existing.discovered_by !== team) { - return res.status(409).json({ - error: "taken_by_other_team", - discoveredBy: existing.discovered_by, - cell: rowToCellPayload(existing), - }); - } return res.json(rowToCellPayload(existing)); } catch (e) { console.error(e); @@ -195,6 +198,76 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => { } }); +// POST /api/cell/capture — spend team actions to capture a planet +router.post("/cell/capture", authMiddleware, async (req, res) => { + const seed = String(req.body?.seed ?? ""); + const team = req.user.team; + const x = Number(req.body?.x); + const y = Number(req.body?.y); + + 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 }); + + const existing = await getExistingCell(worldSeed, x, y); + if (!existing) return res.status(404).json({ error: "cell_not_found" }); + if (!existing.has_planet) return res.status(409).json({ error: "no_planet" }); + if (existing.discovered_by === team) return res.status(409).json({ error: "already_owned" }); + + // Compute capture cost (1 action per 10 billion population, doubled if opponent-controlled) + const planet = existing.planet_json; + const billions = planet?.population?.billions ?? 0; + const baseCost = Math.max(1, Math.ceil(billions / 10)); + const isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team; + const cost = isOpponentControlled ? baseCost * 2 : baseCost; + + // Consume team actions + const cfg = getConfig(); + const now = new Date(); + const teamRow = await getTeamActionsRow(team); + if (!teamRow || new Date(teamRow.quota_reset_at) <= now) { + // Compute fresh quota + const rows = await getGridCells(worldSeed); + const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {}); + const milDeductions = await getMilitaryDeductions(worldSeed); + const milNet = milPower - (milDeductions[team] ?? 0); + const milBonus = Math.floor(Math.max(0, milNet) / 10000); + const totalActions = cfg.teamActionQuota + milBonus; + if (totalActions < cost) { + return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: totalActions }); + } + await resetTeamActions(team, totalActions - cost, nextNoonUtc().toISOString()); + } else { + if (teamRow.actions_remaining < cost) { + return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: teamRow.actions_remaining }); + } + const updated = await decrementTeamActionsBy(team, cost); + if (!updated) { + return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 }); + } + } + + // Transfer ownership to capturing team + await setTileOwner(worldSeed, x, y, team); + + const updatedCell = await getExistingCell(worldSeed, x, y); + const updatedTeamRow = await getTeamActionsRow(team); + + res.json({ + success: true, + cell: rowToCellPayload(updatedCell), + teamActionsRemaining: updatedTeamRow?.actions_remaining ?? null, + }); + } catch (e) { + console.error(e); + res.status(500).json({ error: "database_error" }); + } +}); + // GET /api/team-quota?team=blue|red router.get("/team-quota", async (req, res) => { const team = typeof req.query.team === "string" ? req.query.team : ""; @@ -213,7 +286,7 @@ router.get("/team-quota", async (req, res) => { const milDeductions = await getMilitaryDeductions(worldSeed); const milNet = milPower - (milDeductions[team] ?? 0); const milBonus = Math.floor(Math.max(0, milNet) / 10000); - actionsRemaining = 100 + milBonus; + actionsRemaining = cfg.teamActionQuota + milBonus; } else { actionsRemaining = teamRow.actions_remaining; } @@ -364,11 +437,11 @@ router.post("/military/attack", authMiddleware, async (req, res) => { 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 + // Target cell must exist, have a planet, 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" }); + if (!existing.discovered_by || existing.discovered_by === attackingTeam) { + return res.status(409).json({ error: "cannot_attack_own_or_neutral_tile" }); } // Deduct 1 billion (1.0 in "billions" unit) from the attacking team