From 5aa347eb138c99970465aebdc2ec3223a6a5b696 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Wed, 1 Apr 2026 16:30:35 +0200 Subject: [PATCH] refacto: Changing click cooldown to daily actions for users and teams --- config/game.settings.json | 8 +-- public/index.html | 13 +++-- public/src/game.js | 111 ++++++++++++++++++------------------ server/configLoader.js | 8 +-- server/db/gameDb.js | 43 ++++++++++++++ server/db/usersDb.js | 61 ++++++++++++++++++++ server/helpers/economy.js | 32 +++++++++++ server/index.js | 2 +- server/routes/game.js | 117 +++++++++++++++++++++++++++----------- 9 files changed, 294 insertions(+), 101 deletions(-) diff --git a/config/game.settings.json b/config/game.settings.json index 00a9991..f7a89d6 100644 --- a/config/game.settings.json +++ b/config/game.settings.json @@ -1,5 +1,5 @@ { - "clickCooldownSeconds": 60, + "dailyActionQuota": 100, "databaseWipeoutIntervalSeconds": 604800, "configReloadIntervalSeconds": 30, "elementWorth": { @@ -13,9 +13,9 @@ "science": 0.8 }, "militaryPower": { - "humans": 1, - "near": 0.5, - "aliens": 0.1 + "humans": 10, + "near": 5, + "aliens": 1 }, "resourceWorth": { "common": { diff --git a/public/index.html b/public/index.html index 90ef920..ce8d5e8 100644 --- a/public/index.html +++ b/public/index.html @@ -158,14 +158,19 @@
- Prochain clic + Actions restantes 0 - s + +
+
+ Actions équipe restantes + +
- Délai entre deux clics + Actions par jour
@@ -216,7 +221,7 @@ Premier Ordre - Recharge : + Quota effectif/j :
diff --git a/public/src/game.js b/public/src/game.js index 0e2c4fd..dfef486 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -21,7 +21,7 @@ const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)"; // ── Shared game state ───────────────────────────────────────────────────────── export const GAME_CONFIG = { - clickCooldownSeconds: 5, + dailyActionQuota: 100, databaseWipeoutIntervalSeconds: 21600, debugModeForTeams: false, configReloadIntervalSeconds: 30, @@ -30,6 +30,7 @@ export const GAME_CONFIG = { elementWorth: {}, resourceWorth: { common: {}, rare: {} }, militaryPower: {}, + teamActionsRemaining: null, }; window.GAME_CONFIG = GAME_CONFIG; @@ -46,8 +47,10 @@ export function applySeed(str) { seedU32 = fnv1a32(str || "fallback"); } -export const teamCooldownEndMs = { blue: 0, red: 0 }; -let rafId = 0; +/** Remaining actions for the current user in today's quota window. */ +export let actionsRemaining = 0; +/** Remaining actions for the current team in today's team quota window. */ +export let teamActionsRemaining = null; let lastPointerEvent = null; // ── Tile fade-in animation ──────────────────────────────────────────────────── @@ -228,6 +231,7 @@ const attackOverlayEl = document.getElementById("attackOverlay"); const attackModalBodyEl = document.getElementById("attackModalBody"); const attackModalYesEl = document.getElementById("attackModalYes"); const attackModalNoEl = document.getElementById("attackModalNo"); +const teamQuotaEl = document.getElementById("teamActionsRemaining"); // ── Cell helpers ────────────────────────────────────────────────────────────── export function cellKey(x, y) { return `${x},${y}`; } @@ -249,7 +253,7 @@ export function isOwnTile(key) { const m = cellMeta(key); return m !== nul // ── Config display ──────────────────────────────────────────────────────────── export function applyConfigPayload(data) { - GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0; + GAME_CONFIG.dailyActionQuota = Number(data.dailyActionQuota) || 100; GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600; GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams); GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30); @@ -264,8 +268,16 @@ export function applyConfigPayload(data) { if (data.militaryPower && typeof data.militaryPower === "object") { GAME_CONFIG.militaryPower = data.militaryPower; } + if (data.actionsRemaining !== null && data.actionsRemaining !== undefined) { + actionsRemaining = Number(data.actionsRemaining); + updateActionsDisplay(); + } + if (data.teamActionsRemaining !== null && data.teamActionsRemaining !== undefined) { + teamActionsRemaining = Number(data.teamActionsRemaining); + updateTeamQuotaDisplay(); + } - cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds); + cooldownCfgEl.textContent = String(GAME_CONFIG.dailyActionQuota); seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—"; nextPeriodEl.textContent = GAME_CONFIG.seedPeriodEndsAtUtc ? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z" @@ -340,15 +352,13 @@ export async function loadMilitaryDeductions() { } function updateEffectiveCooldownDisplay() { - const base = GAME_CONFIG.clickCooldownSeconds; + const base = GAME_CONFIG.dailyActionQuota; const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed; - const effective = base / (1 + bonus / 100); + const effective = Math.floor(base * (1 + bonus / 100)); if (elemBonusBlueEl) elemBonusBlueEl.textContent = elemBonusBlue.toFixed(2); if (elemBonusRedEl) elemBonusRedEl.textContent = elemBonusRed.toFixed(2); if (effectiveCooldownEl) { - effectiveCooldownEl.textContent = effective < 1 - ? `${(effective * 1000).toFixed(0)}ms` - : `${effective.toFixed(2)}s`; + effectiveCooldownEl.textContent = String(effective); } } @@ -376,10 +386,10 @@ export async function tickElementBonus() { updateEffectiveCooldownDisplay(); } -export function getEffectiveCooldown() { - const base = GAME_CONFIG.clickCooldownSeconds; +export function getEffectiveQuota() { + const base = GAME_CONFIG.dailyActionQuota; const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed; - return base / (1 + bonus / 100); + return Math.floor(base * (1 + bonus / 100)); } export async function loadDbInfo() { @@ -534,56 +544,40 @@ export async function fetchGridForSeed(seed, depth = 0) { } } -// ── Cooldown ────────────────────────────────────────────────────────────────── +// ── Action quota ────────────────────────────────────────────────────────────── +/** Updates the actions remaining display in the sidebar. */ +function updateActionsDisplay() { + countdownEl.textContent = String(Math.max(0, actionsRemaining)); + refreshCursorFromLast(); +} + +/** Updates the team quota display in the sidebar. */ +function updateTeamQuotaDisplay() { + if (teamQuotaEl) { + teamQuotaEl.textContent = teamActionsRemaining !== null ? String(Math.max(0, teamActionsRemaining)) : "—"; + } + refreshCursorFromLast(); +} + +/** Returns true when the user has no actions left for today's quota window. */ export function cooldownActive() { - if (GAME_CONFIG.clickCooldownSeconds <= 0) return false; - return Date.now() < teamCooldownEndMs[currentTeam]; -} - -function remainingSecs() { - return Math.max(0, (teamCooldownEndMs[currentTeam] - Date.now()) / 1000); -} - -function tickCooldown() { - if (GAME_CONFIG.clickCooldownSeconds <= 0) { countdownWrap.classList.add("hidden"); return; } - const left = remainingSecs(); - if (left <= 0) { - countdownWrap.classList.add("hidden"); - countdownEl.textContent = "0"; - teamCooldownEndMs[currentTeam] = 0; - refreshCursorFromLast(); - // Auto-refresh from server when cooldown expires - refreshFromServer(); - return; - } - countdownWrap.classList.remove("hidden"); - countdownEl.textContent = String(Math.ceil(left)); - refreshCursorFromLast(); - rafId = requestAnimationFrame(tickCooldown); + return actionsRemaining <= 0; } +/** Decrements the local actions counter by 1 after a successful reveal. */ export function startCooldown() { - const secs = getEffectiveCooldown(); - if (secs <= 0) { - teamCooldownEndMs[currentTeam] = 0; - countdownWrap.classList.add("hidden"); - refreshCursorFromLast(); - return; - } - teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000; - countdownWrap.classList.remove("hidden"); - countdownEl.textContent = String(secs); - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(tickCooldown); + actionsRemaining = Math.max(0, actionsRemaining - 1); + updateActionsDisplay(); refreshCursorFromLast(); } +/** Resets local action state (called on world seed change or re-login). */ export function clearCooldown() { - teamCooldownEndMs.blue = 0; - teamCooldownEndMs.red = 0; - cancelAnimationFrame(rafId); - countdownWrap.classList.add("hidden"); + actionsRemaining = 0; + teamActionsRemaining = null; + updateActionsDisplay(); + updateTeamQuotaDisplay(); } // ── Draw ────────────────────────────────────────────────────────────────────── @@ -829,7 +823,7 @@ async function onCanvasClick(ev) { if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; } if (cooldownActive()) { - hint.textContent = "Clic indisponible — attendez la fin du délai ou cliquez sur une tuile déjà découverte."; + hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; return; } @@ -841,6 +835,12 @@ async function onCanvasClick(ev) { draw(); return; } + 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(); @@ -880,7 +880,6 @@ export async function refreshFromServer() { try { const seedChanged = await fetchConfig(); if (seedChanged) { - clearCooldown(); resetEconScores(); loadEconScores(); details.textContent = "Les stats sont vides jusqu'au clic sur une tuile."; diff --git a/server/configLoader.js b/server/configLoader.js index d7a4781..0ac995a 100644 --- a/server/configLoader.js +++ b/server/configLoader.js @@ -7,9 +7,9 @@ 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, militaryPower: object }} */ +/** @type {{ dailyActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */ let cached = { - clickCooldownSeconds: 5, + dailyActionQuota: 100, databaseWipeoutIntervalSeconds: 21600, debugModeForTeams: true, configReloadIntervalSeconds: 30, @@ -38,8 +38,8 @@ export function loadConfigFile() { } const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8"); const j = JSON.parse(raw); - if (typeof j.clickCooldownSeconds === "number" && j.clickCooldownSeconds >= 0) { - cached.clickCooldownSeconds = j.clickCooldownSeconds; + if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) { + cached.dailyActionQuota = Math.floor(j.dailyActionQuota); } 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 edf0b69..62b0ffc 100644 --- a/server/db/gameDb.js +++ b/server/db/gameDb.js @@ -106,6 +106,13 @@ export async function initGameSchema() { END $$; ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL; `); + await pool.query(` + CREATE TABLE IF NOT EXISTS team_action_quota ( + team TEXT PRIMARY KEY CHECK (team IN ('blue', 'red')), + actions_remaining INTEGER NOT NULL DEFAULT 0, + quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00' + ); + `); } // ── World-seed epoch ────────────────────────────────────────────────────────── @@ -355,3 +362,39 @@ export async function setTileOwner(worldSeed, x, y, team) { [team, worldSeed, x, y] ); } + +// ── Team action quota (daily, independent of world seed) ───────────────────── + +export async function getTeamActionsRow(team) { + const { rows } = await pool.query( + `SELECT actions_remaining, quota_reset_at FROM team_action_quota WHERE team = $1`, + [team] + ); + return rows[0] ?? null; +} + +export async function resetTeamActions(team, actionsRemaining, quotaResetAt) { + await pool.query( + `INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at) + VALUES ($1, $2, $3) + ON CONFLICT (team) DO UPDATE + SET actions_remaining = $2, + quota_reset_at = $3`, + [team, actionsRemaining, quotaResetAt] + ); +} + +/** + * Atomically decrements team actions_remaining by 1 if > 0. + * Returns the updated row, or null if already 0. + */ +export async function decrementTeamActions(team) { + const { rows } = await pool.query( + `UPDATE team_action_quota + SET actions_remaining = actions_remaining - 1 + WHERE team = $1 AND actions_remaining > 0 + RETURNING actions_remaining`, + [team] + ); + return rows[0] ?? null; +} diff --git a/server/db/usersDb.js b/server/db/usersDb.js index da4cad6..abc13c2 100644 --- a/server/db/usersDb.js +++ b/server/db/usersDb.js @@ -13,6 +13,29 @@ export async function initUsersSchema() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); + await initUserActionQuotaSchema(); +} + +export async function initUserActionQuotaSchema() { + await usersPool.query(` + CREATE TABLE IF NOT EXISTS user_action_quota ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + actions_remaining INTEGER NOT NULL DEFAULT 0, + quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00' + ); + `); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Returns the next noon (12:00:00) UTC after the current moment. */ +export function nextNoonUtc() { + const now = new Date(); + const noon = new Date(Date.UTC( + now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0 + )); + if (noon <= now) noon.setUTCDate(noon.getUTCDate() + 1); + return noon; } // ── Queries ─────────────────────────────────────────────────────────────────── @@ -50,4 +73,42 @@ export async function getTeamPlayerCounts() { const result = { blue: 0, red: 0 }; for (const row of rows) result[row.team] = row.count; return result; +} + +// ── User action quota ───────────────────────────────────────────────────────── + +/** Returns the current quota row for a user, or null if it doesn't exist. */ +export async function getUserActionsRow(userId) { + const { rows } = await usersPool.query( + `SELECT actions_remaining, quota_reset_at FROM user_action_quota WHERE user_id = $1`, + [userId] + ); + return rows[0] ?? null; +} + +/** Insert or overwrite the quota row for a user. */ +export async function resetUserActions(userId, actionsRemaining, quotaResetAt) { + await usersPool.query( + `INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE + SET actions_remaining = $2, + quota_reset_at = $3`, + [userId, actionsRemaining, quotaResetAt] + ); +} + +/** + * Atomically decrements actions_remaining by 1 if > 0. + * Returns the updated row (with new actions_remaining), or null if already 0. + */ +export async function decrementUserActions(userId) { + const { rows } = await usersPool.query( + `UPDATE user_action_quota + SET actions_remaining = actions_remaining - 1 + WHERE user_id = $1 AND actions_remaining > 0 + RETURNING actions_remaining`, + [userId] + ); + return rows[0] ?? null; } \ No newline at end of file diff --git a/server/helpers/economy.js b/server/helpers/economy.js index 718a729..21c7394 100644 --- a/server/helpers/economy.js +++ b/server/helpers/economy.js @@ -122,3 +122,35 @@ export function computeTeamElementBonus(team, rows, elementWorth) { } return bonus; } + +// ── Military power computation ──────────────────────────────────────────────── + +const POP_LABEL_TO_KEY = new Map([ + ["Humains", "humans"], + ["Presque'humains", "near"], + ["Aliens", "aliens"], +]); + +/** + * Compute total military power (in billions) for a team from DB grid rows. + * + * @param {string} team - "blue" or "red" + * @param {Array<{ has_planet: boolean, planet_json: object|null, discovered_by: string }>} rows + * @param {object} militaryPower - { humans: 10, near: 5, aliens: 1 } + * @returns {number} military power in billions + */ +export function computeTeamMilitaryPower(team, rows, militaryPower) { + let total = 0; + for (const row of rows) { + if (row.discovered_by !== team) continue; + if (!row.has_planet || !row.planet_json) continue; + const pop = row.planet_json.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; + total += pop.billions * pct / 100; + } + return total; +} diff --git a/server/index.js b/server/index.js index 34fbb4b..b43f1e7 100644 --- a/server/index.js +++ b/server/index.js @@ -35,7 +35,7 @@ async function main() { app.listen(PORT, () => { const cfg = getConfig(); console.log( - `[server] Listening on :${PORT} cooldown=${cfg.clickCooldownSeconds}s wipe=${cfg.databaseWipeoutIntervalSeconds}s` + `[server] Listening on :${PORT} dailyQuota=${cfg.dailyActionQuota} wipe=${cfg.databaseWipeoutIntervalSeconds}s` ); }); diff --git a/server/routes/game.js b/server/routes/game.js index efb6105..b0d68f2 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -24,8 +24,18 @@ import { recordCellAttack, getCellAttackCount, setTileOwner, + getTeamActionsRow, + resetTeamActions, + decrementTeamActions, } from "../db/gameDb.js"; +import { + nextNoonUtc, + getUserActionsRow, + resetUserActions, + decrementUserActions, +} from "../db/usersDb.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js"; +import { computeTeamMilitaryPower } from "../helpers/economy.js"; const router = express.Router(); @@ -37,45 +47,53 @@ router.get("/config", async (req, res) => { const rot = cfg.databaseWipeoutIntervalSeconds; const ws = computeWorldSeedState(rot); - let teamCooldownRemaining = 0; + let actionsRemaining = null; + let teamActionsRemaining = null; const team = typeof req.query.team === "string" ? req.query.team : undefined; if (team === "blue" || team === "red") { - const bonus = await getElementBonus(worldSeed); - const teamBonus = bonus[team] ?? 0; - const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100); - // Use per-user cooldown if auth token provided, else fall back to team-wide const authHeader = req.headers["authorization"]; if (authHeader && authHeader.startsWith("Bearer ")) { try { const payload = jwt.verify(authHeader.slice(7), JWT_SECRET); - const row = await getUserCooldown(worldSeed, payload.userId); - if (row) { - const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000; - if (secondsSince < effectiveCooldown) { - teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince); - } + 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(payload.userId); + if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) { + actionsRemaining = effectiveQuota; + } else { + actionsRemaining = quotaRow.actions_remaining; } - } catch { /* invalid token — return 0 */ } + } catch { /* invalid token — return null */ } + } + + // Team-wide quota: compute current remaining without consuming + const now = new Date(); + const teamRow = await getTeamActionsRow(team); + if (!teamRow || new Date(teamRow.quota_reset_at) <= now) { + // Expired or unset: compute what it would be when refreshed + 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); + teamActionsRemaining = 100 + milBonus; } else { - const row = await getTeamCooldown(worldSeed, team); - if (row) { - const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000; - if (secondsSince < effectiveCooldown) { - teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince); - } - } + teamActionsRemaining = teamRow.actions_remaining; } } res.json({ - clickCooldownSeconds: cfg.clickCooldownSeconds, + dailyActionQuota: cfg.dailyActionQuota, databaseWipeoutIntervalSeconds: rot, debugModeForTeams: cfg.debugModeForTeams, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, worldSeed: ws.worldSeed, seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc, seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc, - teamCooldownRemaining, + actionsRemaining, + teamActionsRemaining, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, elementWorth: cfg.elementWorth ?? {}, militaryPower: cfg.militaryPower ?? {}, @@ -127,19 +145,23 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => { } const cfg = getConfig(); - if (cfg.clickCooldownSeconds > 0) { + if (cfg.dailyActionQuota > 0) { const bonus = await getElementBonus(worldSeed); const teamBonus = bonus[team] ?? 0; - const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100); - const cooldownRow = await getUserCooldown(worldSeed, userId); - if (cooldownRow) { - const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000; - if (secondsSince < effectiveCooldown) { + 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) { + // 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: "cooldown_active", - team, - remainingSeconds: Math.ceil(effectiveCooldown - secondsSince), - cooldownSeconds: effectiveCooldown, + error: "quota_exhausted", + actionsRemaining: 0, }); } } @@ -149,8 +171,10 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => { const planetJson = cell.planet ? JSON.stringify(cell.planet) : null; const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team); + // Track activity for active-player count (kept for stat purposes) + await upsertUserCooldown(worldSeed, userId, team); + if (inserted) { - await upsertUserCooldown(worldSeed, userId, team); return res.json(rowToCellPayload(inserted)); } @@ -171,6 +195,35 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => { } }); +// GET /api/team-quota?team=blue|red +router.get("/team-quota", async (req, res) => { + const team = typeof req.query.team === "string" ? req.query.team : ""; + if (team !== "blue" && team !== "red") { + return res.status(400).json({ error: "invalid_team" }); + } + try { + const worldSeed = await ensureSeedEpoch(); + const cfg = getConfig(); + const now = new Date(); + const teamRow = await getTeamActionsRow(team); + let actionsRemaining; + if (!teamRow || new Date(teamRow.quota_reset_at) <= now) { + 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); + actionsRemaining = 100 + milBonus; + } else { + actionsRemaining = teamRow.actions_remaining; + } + res.json({ team, actionsRemaining }); + } catch (e) { + console.error(e); + res.status(500).json({ error: "database_error" }); + } +}); + // GET /api/econ-scores router.get("/econ-scores", async (_req, res) => { try {