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 {