diff --git a/config/game.settings.json b/config/game.settings.json
index 36ba2f4..a173bb7 100644
--- a/config/game.settings.json
+++ b/config/game.settings.json
@@ -1,6 +1,7 @@
{
"dailyActionQuota": 100,
"teamActionQuota": 100,
+ "actionsResetIntervalHours": 24,
"databaseWipeoutIntervalSeconds": 604800,
"configReloadIntervalSeconds": 30,
"elementWorth": {
diff --git a/public/index.html b/public/index.html
index 6e0c674..c6b4185 100644
--- a/public/index.html
+++ b/public/index.html
@@ -170,8 +170,8 @@
—
- Prochaine graine (UTC)
- —
+ Réinitialisation des actions dans
+ --:--:--
Prochaine graine dans
diff --git a/public/src/game.js b/public/src/game.js
index c7c3732..7e853e1 100644
--- a/public/src/game.js
+++ b/public/src/game.js
@@ -22,6 +22,7 @@ const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
export const GAME_CONFIG = {
dailyActionQuota: 100,
+ actionsResetIntervalHours: 12,
databaseWipeoutIntervalSeconds: 21600,
configReloadIntervalSeconds: 30,
worldSeed: "",
@@ -203,7 +204,7 @@ const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownCfgEl = document.getElementById("cooldownConfig");
const seedDisplayEl = document.getElementById("worldSeedDisplay");
-const nextPeriodEl = document.getElementById("nextPeriodUtc");
+const actionsResetCountdownEl = document.getElementById("actionsResetCountdown");
const resetCountEl = document.getElementById("refreshCountdown");
const vpBlueEl = document.getElementById("vpBlue");
const vpRedEl = document.getElementById("vpRed");
@@ -263,6 +264,9 @@ export function isOwnTile(key) { const m = cellMeta(key); return m !== nul
export function applyConfigPayload(data) {
GAME_CONFIG.dailyActionQuota = Number(data.dailyActionQuota) || 100;
+ if (data.actionsResetIntervalHours) {
+ GAME_CONFIG.actionsResetIntervalHours = Number(data.actionsResetIntervalHours) || 12;
+ }
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
@@ -287,11 +291,9 @@ export function applyConfigPayload(data) {
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"
- : "—";
updateResetCountdown();
+ updateActionsResetCountdown();
}
@@ -311,6 +313,29 @@ export function updateResetCountdown() {
String(ss).padStart(2, "0");
}
+/** Computes time until next actions-reset based on the configured interval and updates the display. */
+export function updateActionsResetCountdown() {
+ if (!actionsResetCountdownEl) return;
+ const intervalHours = GAME_CONFIG.actionsResetIntervalHours ?? 12;
+ const now = Date.now();
+ const date = new Date(now);
+ const utcHours = date.getUTCHours();
+ const slotsPassed = Math.floor(utcHours / intervalHours);
+ const nextSlotHour = (slotsPassed + 1) * intervalHours;
+ const next = nextSlotHour < 24
+ ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), nextSlotHour, 0, 0, 0)
+ : Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1, 0, 0, 0, 0);
+ const diff = next - now;
+ const s = Math.floor(diff / 1000);
+ const hh = Math.floor(s / 3600);
+ const mm = Math.floor((s % 3600) / 60);
+ const ss = s % 60;
+ actionsResetCountdownEl.textContent =
+ String(hh).padStart(2, "0") + ":" +
+ String(mm).padStart(2, "0") + ":" +
+ String(ss).padStart(2, "0");
+}
+
// ── Scores ────────────────────────────────────────────────────────────────────
diff --git a/public/src/main.js b/public/src/main.js
index db4809c..64849b8 100644
--- a/public/src/main.js
+++ b/public/src/main.js
@@ -2,6 +2,7 @@ import {
GAME_CONFIG,
seedStr,
updateResetCountdown,
+ updateActionsResetCountdown,
fetchConfig,
fetchGridForSeed,
fetchAndApplyActivePlayers,
@@ -221,8 +222,9 @@ async function boot() {
draw();
if (resetTimer) clearInterval(resetTimer);
- resetTimer = setInterval(updateResetCountdown, 1_000);
+ resetTimer = setInterval(() => { updateResetCountdown(); updateActionsResetCountdown(); }, 1_000);
updateResetCountdown();
+ updateActionsResetCountdown();
startRealtimeFlow();
diff --git a/server/configLoader.js b/server/configLoader.js
index 397bfe8..6299f16 100644
--- a/server/configLoader.js
+++ b/server/configLoader.js
@@ -8,9 +8,12 @@ const CONFIG_FILE_PATH =
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
/** @type {{ dailyActionQuota: number, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
+const VALID_RESET_HOURS = new Set([1, 2, 3, 4, 6, 8, 12, 24]);
+
let cached = {
dailyActionQuota: 100,
teamActionQuota: 100,
+ actionsResetIntervalHours: 12,
databaseWipeoutIntervalSeconds: 21600,
configReloadIntervalSeconds: 30,
elementWorth: {},
@@ -44,6 +47,10 @@ export function loadConfigFile() {
if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) {
cached.teamActionQuota = Math.floor(j.teamActionQuota);
}
+ if (typeof j.actionsResetIntervalHours === "number") {
+ const v = Math.floor(j.actionsResetIntervalHours);
+ if (VALID_RESET_HOURS.has(v)) cached.actionsResetIntervalHours = v;
+ }
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 69721b1..8b28ca4 100644
--- a/server/db/gameDb.js
+++ b/server/db/gameDb.js
@@ -1,7 +1,7 @@
import { pool } from "./pools.js";
import { loadConfigFile, getConfig } from "../configLoader.js";
import { computeWorldSeedState } from "../worldSeed.js";
-import { nextNoonUtc, resetAllUserActions, getUsersByIds } from "./usersDb.js";
+import { nextResetUtc, resetAllUserActions, getUsersByIds } from "./usersDb.js";
let lastSeedSlot = null;
@@ -187,7 +187,7 @@ export async function ensureSeedEpoch() {
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();
+ const nextNoon = nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString();
await pool.query(
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
VALUES ('blue', $1, $2), ('red', $1, $2)
diff --git a/server/db/usersDb.js b/server/db/usersDb.js
index b2403f0..f26d0a2 100644
--- a/server/db/usersDb.js
+++ b/server/db/usersDb.js
@@ -29,12 +29,28 @@ export async function initUserActionQuotaSchema() {
/** Returns the next noon (12:00:00) UTC after the current moment. */
export function nextNoonUtc() {
+ return nextResetUtc(12);
+}
+
+/**
+ * Returns the next reset timestamp UTC based on a repeating interval.
+ * @param {number} intervalHours - must be a divisor of 24 (1,2,3,4,6,8,12,24)
+ */
+export function nextResetUtc(intervalHours) {
const now = new Date();
- const noon = new Date(Date.UTC(
- now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0
+ const utcHours = now.getUTCHours();
+ const slotsPassed = Math.floor(utcHours / intervalHours);
+ const nextSlotHour = (slotsPassed + 1) * intervalHours;
+ if (nextSlotHour < 24) {
+ return new Date(Date.UTC(
+ now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(),
+ nextSlotHour, 0, 0, 0
+ ));
+ }
+ return new Date(Date.UTC(
+ now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1,
+ 0, 0, 0, 0
));
- if (noon <= now) noon.setUTCDate(noon.getUTCDate() + 1);
- return noon;
}
// ── Queries ───────────────────────────────────────────────────────────────────
diff --git a/server/index.js b/server/index.js
index 5a5d70d..fda0ec0 100644
--- a/server/index.js
+++ b/server/index.js
@@ -14,6 +14,7 @@ function makeConfigSignature(cfg) {
return JSON.stringify({
dailyActionQuota: cfg.dailyActionQuota,
teamActionQuota: cfg.teamActionQuota,
+ actionsResetIntervalHours: cfg.actionsResetIntervalHours,
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
elementWorth: cfg.elementWorth ?? {},
@@ -41,6 +42,7 @@ function scheduleConfigPoll() {
config: {
dailyActionQuota: cfg.dailyActionQuota,
teamActionQuota: cfg.teamActionQuota,
+ actionsResetIntervalHours: cfg.actionsResetIntervalHours,
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
elementWorth: cfg.elementWorth ?? {},
diff --git a/server/routes/game.js b/server/routes/game.js
index a02e94f..2ae6108 100644
--- a/server/routes/game.js
+++ b/server/routes/game.js
@@ -34,7 +34,7 @@ import {
getTeamVisibleCells,
} from "../db/gameDb.js";
import {
- nextNoonUtc,
+ nextResetUtc,
getUserActionsRow,
resetUserActions,
decrementUserActions,
@@ -100,6 +100,7 @@ router.get("/config", async (req, res) => {
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
actionsRemaining,
teamActionsRemaining,
+ actionsResetIntervalHours: cfg.actionsResetIntervalHours ?? 12,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {},
militaryPower: cfg.militaryPower ?? {},
@@ -170,7 +171,7 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
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());
+ await resetUserActions(userId, effectiveQuota - 1, nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString());
} else {
const updated = await decrementUserActions(userId);
if (!updated) {
@@ -247,7 +248,7 @@ router.post("/cell/capture", authMiddleware, async (req, res) => {
if (totalActions < cost) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: totalActions });
}
- await resetTeamActions(team, totalActions - cost, nextNoonUtc().toISOString());
+ await resetTeamActions(team, totalActions - cost, nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString());
} else {
if (teamRow.actions_remaining < cost) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: teamRow.actions_remaining });