From d345c025c05cccf944d39616d50108b1336ecfd0 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Fri, 3 Apr 2026 14:25:19 +0200 Subject: [PATCH] fix: Fixing the MP power bonus + seed maintenance --- .gitignore | 1 + README.md | 2 +- config/game.settings.json | 4 +- public/src/game.js | 37 ++++++----- server/configLoader.js | 79 +++++++++++++++++----- server/db/gameDb.js | 134 ++++++++++++++++++++++++++++++++++---- server/db/usersDb.js | 42 +++++++----- server/econTick.js | 47 ++++++++++++- server/index.js | 9 ++- server/routes/game.js | 109 ++++++++++++++++--------------- server/worldSeed.js | 17 +++-- 11 files changed, 349 insertions(+), 132 deletions(-) diff --git a/.gitignore b/.gitignore index 54dbab5..165de22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ data/postgres/ data/postgres_users/ +config/.timing_epoch .env .env.local .env.*.local \ No newline at end of file diff --git a/README.md b/README.md index a2465fd..4380617 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Two quota systems gate how fast teams can act: - Revealing a **new** (previously unseen by your team) cell costs **1 user action**. - Re-clicking a cell your team has already revealed is free (no action consumed). - The team's **element bonus** boosts the effective user quota: `floor(dailyActionQuota × (1 + elementBonus / 100))`. -- Military power can increase the team quota: `teamActionQuota + floor(max(0, netMilitaryPower) / 10_000)` extra actions. +- Military power can increase the team quota: `teamActionQuota + floor(max(0, netMilitaryPower) / 1_000)` extra actions. ### Reveal flow diff --git a/config/game.settings.json b/config/game.settings.json index a173bb7..f764e72 100644 --- a/config/game.settings.json +++ b/config/game.settings.json @@ -1,8 +1,8 @@ { "dailyActionQuota": 100, "teamActionQuota": 100, - "actionsResetIntervalHours": 24, - "databaseWipeoutIntervalSeconds": 604800, + "actionsResetIntervalSeconds": 30, + "databaseWipeoutIntervalCycles": 2, "configReloadIntervalSeconds": 30, "elementWorth": { "common": 0.1, diff --git a/public/src/game.js b/public/src/game.js index 568afc3..0766c14 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -22,9 +22,10 @@ const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)"; export const GAME_CONFIG = { dailyActionQuota: 100, - actionsResetIntervalHours: 12, + actionsResetIntervalSeconds: 3600, databaseWipeoutIntervalSeconds: 21600, configReloadIntervalSeconds: 30, + timingEpochSec: 0, worldSeed: "", seedPeriodEndsAtUtc: "", elementWorth: {}, @@ -288,11 +289,12 @@ 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; + if (data.actionsResetIntervalSeconds) { + GAME_CONFIG.actionsResetIntervalSeconds = Number(data.actionsResetIntervalSeconds) || 3600; } GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600; GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30); + GAME_CONFIG.timingEpochSec = Number(data.timingEpochSec) || 0; GAME_CONFIG.worldSeed = String(data.worldSeed ?? ""); GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? ""); if (data.elementWorth && typeof data.elementWorth === "object") { @@ -340,20 +342,16 @@ export function updateResetCountdown() { /** 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; + const intervalSec = GAME_CONFIG.actionsResetIntervalSeconds ?? 3600; + const epochSec = GAME_CONFIG.timingEpochSec ?? 0; + const nowSec = Math.floor(Date.now() / 1000); + const elapsed = Math.max(0, nowSec - epochSec); + const slot = Math.floor(elapsed / intervalSec); + const nextResetSec = epochSec + (slot + 1) * intervalSec; + const diff = nextResetSec - nowSec; + const hh = Math.floor(diff / 3600); + const mm = Math.floor((diff % 3600) / 60); + const ss = diff % 60; actionsResetCountdownEl.textContent = String(hh).padStart(2, "0") + ":" + String(mm).padStart(2, "0") + ":" + @@ -1318,13 +1316,16 @@ export async function refreshFromServer() { const seedChanged = await fetchConfig(); if (seedChanged) { resetEconScores(); - loadEconScores(); details.textContent = "Les stats sont vides jusqu'au clic sur une tuile."; details.classList.add("details--hidden"); hint.textContent = "Le monde a été réinitilisaté. Vous pouvez cliquer sur une tuile pour recommencer le jeu."; } await fetchGridForSeed(seedStr); await fetchAndApplyActivePlayers(); + await loadPlayerNames(); + await loadEconScores(); + await loadElementBonus(); + await loadMilitaryDeductions(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); diff --git a/server/configLoader.js b/server/configLoader.js index 6299f16..efbc408 100644 --- a/server/configLoader.js +++ b/server/configLoader.js @@ -7,21 +7,49 @@ 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, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */ -const VALID_RESET_HOURS = new Set([1, 2, 3, 4, 6, 8, 12, 24]); +const EPOCH_FILE_PATH = + process.env.EPOCH_FILE_PATH ?? path.join(path.dirname(CONFIG_FILE_PATH), ".timing_epoch"); let cached = { dailyActionQuota: 100, teamActionQuota: 100, - actionsResetIntervalHours: 12, - databaseWipeoutIntervalSeconds: 21600, + actionsResetIntervalSeconds: 3600, + databaseWipeoutIntervalCycles: 6, configReloadIntervalSeconds: 30, elementWorth: {}, resourceWorth: { common: {}, rare: {} }, militaryPower: {}, }; -let lastMtimeMs = 0; +/** Raw JSON content from the last successful read (for change detection). */ +let lastRawContent = ""; + +/** Unix seconds marking the origin of all timing slot calculations. */ +let timingEpochSec = 0; + +/** Signature of timing-relevant values from the last successful load. */ +let lastTimingSig = ""; + +function timingSignature() { + return `${cached.actionsResetIntervalSeconds}:${cached.databaseWipeoutIntervalCycles}`; +} + +function loadTimingEpoch() { + try { + const raw = fs.readFileSync(EPOCH_FILE_PATH, "utf8").trim(); + const v = Number(raw); + if (Number.isFinite(v) && v > 0) return v; + } catch { /* ignore */ } + return 0; +} + +function saveTimingEpoch(sec) { + try { + fs.writeFileSync(EPOCH_FILE_PATH, String(sec), "utf8"); + } catch (e) { + console.error("[config] Could not persist timing epoch:", e.message); + } +} function parseBool(v) { if (typeof v === "boolean") return v; @@ -35,11 +63,13 @@ function parseBool(v) { export function loadConfigFile() { try { - const st = fs.statSync(CONFIG_FILE_PATH); - if (st.mtimeMs === lastMtimeMs) { + // Always read the file — mtime is unreliable on Docker bind mounts (Windows). + // The file is tiny and polled every ~30 s, so the cost is negligible. + const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8"); + if (raw === lastRawContent) { return cached; } - const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8"); + lastRawContent = raw; const j = JSON.parse(raw); if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) { cached.dailyActionQuota = Math.floor(j.dailyActionQuota); @@ -47,12 +77,11 @@ 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.actionsResetIntervalSeconds === "number" && j.actionsResetIntervalSeconds >= 1) { + cached.actionsResetIntervalSeconds = Math.floor(j.actionsResetIntervalSeconds); } - if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) { - cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds; + if (typeof j.databaseWipeoutIntervalCycles === "number" && j.databaseWipeoutIntervalCycles >= 1) { + cached.databaseWipeoutIntervalCycles = Math.floor(j.databaseWipeoutIntervalCycles); } if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) { cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds; @@ -64,10 +93,25 @@ export function loadConfigFile() { cached.resourceWorth = j.resourceWorth; } if (j.militaryPower && typeof j.militaryPower === 'object') { cached.militaryPower = j.militaryPower; - } lastMtimeMs = st.mtimeMs; + } + // Detect timing changes and reset the epoch when they happen + const newSig = timingSignature(); + if (!timingEpochSec) { + // First load: try to restore from disk + timingEpochSec = loadTimingEpoch(); + } + if (!timingEpochSec || (lastTimingSig && newSig !== lastTimingSig)) { + // No persisted epoch, or timing values changed → new epoch + timingEpochSec = Math.floor(Date.now() / 1000); + saveTimingEpoch(timingEpochSec); + if (lastTimingSig) { + console.log(`[config] Timing values changed (${lastTimingSig} → ${newSig}), epoch reset to ${timingEpochSec}`); + } + } + lastTimingSig = newSig; } catch (e) { if (e.code === "ENOENT") { - lastMtimeMs = 0; + lastRawContent = ""; } else { console.error("[config]", e.message); } @@ -76,7 +120,10 @@ export function loadConfigFile() { } export function getConfig() { - return { ...cached }; + const c = { ...cached }; + c.databaseWipeoutIntervalSeconds = c.actionsResetIntervalSeconds * c.databaseWipeoutIntervalCycles; + c.timingEpochSec = timingEpochSec; + return c; } export function getConfigFilePath() { diff --git a/server/db/gameDb.js b/server/db/gameDb.js index dbe0aea..6b145df 100644 --- a/server/db/gameDb.js +++ b/server/db/gameDb.js @@ -4,6 +4,11 @@ import { computeWorldSeedState } from "../worldSeed.js"; import { nextResetUtc, truncateUserActionQuota, getUsersByIds } from "./usersDb.js"; let lastSeedSlot = null; +let lastWorldSeed = null; + +// ── Mutex for seed transitions ──────────────────────────────────────────────── +// Prevents concurrent ensureSeedEpoch() calls from racing on the wipe. +let seedTransitionPromise = null; // ── Schema ──────────────────────────────────────────────────────────────────── @@ -145,22 +150,39 @@ export async function initGameSchema() { ); CREATE INDEX IF NOT EXISTS idx_round_history_ended_at ON round_history (ended_at DESC); `); + // ── Persisted game settings snapshot (one row, id=1) ────────────────────── + await pool.query(` + CREATE TABLE IF NOT EXISTS game_settings_snapshot ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + config JSONB NOT NULL DEFAULT '{}', + timing_epoch_sec BIGINT NOT NULL DEFAULT 0, + world_seed TEXT NOT NULL DEFAULT '', + seed_period_ends_at TEXT NOT NULL DEFAULT '', + seed_period_starts_at TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + INSERT INTO game_settings_snapshot (id) VALUES (1) ON CONFLICT (id) DO NOTHING; + `); } // ── World-seed epoch ────────────────────────────────────────────────────────── -export async function ensureSeedEpoch() { +async function _ensureSeedEpochInner() { loadConfigFile(); - const rot = getConfig().databaseWipeoutIntervalSeconds; - const { seedSlot, worldSeed } = computeWorldSeedState(rot); - if (lastSeedSlot === null) { + const cfg = getConfig(); + const rot = cfg.databaseWipeoutIntervalSeconds; + const epoch = cfg.timingEpochSec ?? 0; + const { seedSlot, worldSeed } = computeWorldSeedState(rot, epoch); + if (lastWorldSeed === null) { lastSeedSlot = seedSlot; + lastWorldSeed = worldSeed; + await persistSettingsSnapshot(cfg, worldSeed, rot, epoch); return worldSeed; } - if (seedSlot !== lastSeedSlot) { + if (worldSeed !== lastWorldSeed) { // Award a victory point to the team with the highest econ score before wiping try { - const expiredSeed = `swg-${lastSeedSlot}`; + const expiredSeed = lastWorldSeed; const econRows = await pool.query( `SELECT team, score FROM team_econ_scores WHERE world_seed = $1`, [expiredSeed] @@ -191,22 +213,106 @@ export async function ensureSeedEpoch() { 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 = nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString(); + // Reset team quota to seed-scoped defaults; expiry is the seed period end so it + // never looks stale during a live epoch. + const seedPeriodEnd = new Date((epoch + (seedSlot + 1) * rot) * 1000).toISOString(); await pool.query( - `TRUNCATE team_action_quota; - INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at) - VALUES ('blue', $1, $2), ('red', $1, $2)`, - [cfg.teamActionQuota, nextNoon] + `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 = EXCLUDED.actions_remaining, + quota_reset_at = EXCLUDED.quota_reset_at`, + [cfg.teamActionQuota, seedPeriodEnd] ); + // Reset per-user quotas (these do use the short action-interval reset time) await truncateUserActionQuota(); - console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`); + console.log(`[world] Seed ${lastWorldSeed} → ${worldSeed}; grid wiped, old cooldowns cleared.`); lastSeedSlot = seedSlot; + lastWorldSeed = worldSeed; + await persistSettingsSnapshot(cfg, worldSeed, rot, epoch); } return worldSeed; } +/** + * Mutex-protected wrapper. Ensures only one seed transition runs at a time. + * Concurrent callers wait for the in-flight transition and get the same result. + */ +export async function ensureSeedEpoch() { + if (seedTransitionPromise) { + return seedTransitionPromise; + } + seedTransitionPromise = _ensureSeedEpochInner().finally(() => { + seedTransitionPromise = null; + }); + return seedTransitionPromise; +} + +// ── Persist / read game settings snapshot in DB ─────────────────────────────── + +async function persistSettingsSnapshot(cfg, worldSeed, rotationSeconds, epochSec) { + try { + const ws = computeWorldSeedState(rotationSeconds, epochSec); + const configJson = { + dailyActionQuota: cfg.dailyActionQuota, + teamActionQuota: cfg.teamActionQuota, + actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds, + databaseWipeoutIntervalCycles: cfg.databaseWipeoutIntervalCycles, + databaseWipeoutIntervalSeconds: rotationSeconds, + configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, + elementWorth: cfg.elementWorth ?? {}, + resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, + militaryPower: cfg.militaryPower ?? {}, + }; + await pool.query( + `UPDATE game_settings_snapshot + SET config = $1, + timing_epoch_sec = $2, + world_seed = $3, + seed_period_ends_at = $4, + seed_period_starts_at = $5, + updated_at = NOW() + WHERE id = 1`, + [JSON.stringify(configJson), epochSec, worldSeed, ws.seedPeriodEndsAtUtc, ws.seedPeriodStartsAtUtc] + ); + } catch (e) { + console.error("[db] Could not persist settings snapshot:", e.message); + } +} + +/** + * Reads the last persisted settings snapshot from DB. + * Used as a fallback when the in-memory config may not reflect file values. + */ +export async function getSettingsSnapshot() { + const { rows } = await pool.query( + `SELECT config, timing_epoch_sec, world_seed, seed_period_ends_at, seed_period_starts_at + FROM game_settings_snapshot WHERE id = 1` + ); + if (!rows[0]) return null; + const row = rows[0]; + const cfg = typeof row.config === "string" ? JSON.parse(row.config) : row.config; + return { + ...cfg, + timingEpochSec: Number(row.timing_epoch_sec), + worldSeed: row.world_seed, + seedPeriodEndsAtUtc: row.seed_period_ends_at, + seedPeriodStartsAtUtc: row.seed_period_starts_at, + }; +} + +/** + * Convenience wrapper: reads current in-memory config and persists it to DB. + * Called from index.js after config-file polls and on boot. + */ +export async function persistCurrentSettings() { + const cfg = getConfig(); + const rot = cfg.databaseWipeoutIntervalSeconds; + const epoch = cfg.timingEpochSec ?? 0; + const ws = computeWorldSeedState(rot, epoch); + await persistSettingsSnapshot(cfg, ws.worldSeed, rot, epoch); +} + // ── Grid cells ──────────────────────────────────────────────────────────────── export async function getGridCells(worldSeed) { diff --git a/server/db/usersDb.js b/server/db/usersDb.js index a11c7d8..e45f13e 100644 --- a/server/db/usersDb.js +++ b/server/db/usersDb.js @@ -29,28 +29,19 @@ export async function initUserActionQuotaSchema() { /** Returns the next noon (12:00:00) UTC after the current moment. */ export function nextNoonUtc() { - return nextResetUtc(12); + return nextResetUtc(43200); // 12 hours = 43200 seconds } /** - * 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) + * Returns the next reset timestamp UTC based on a repeating interval relative to an epoch. + * @param {number} intervalSeconds - the interval length in seconds (>= 1) + * @param {number} epochSec - Unix seconds origin for slot calculation */ -export function nextResetUtc(intervalHours) { - const now = new Date(); - 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 - )); +export function nextResetUtc(intervalSeconds, epochSec = 0) { + const nowSec = Math.floor(Date.now() / 1000); + const elapsed = Math.max(0, nowSec - epochSec); + const slot = Math.floor(elapsed / intervalSeconds); + return new Date((epochSec + (slot + 1) * intervalSeconds) * 1000); } // ── Queries ─────────────────────────────────────────────────────────────────── @@ -153,6 +144,21 @@ export async function resetAllUserActions(actionsRemaining, quotaResetAt) { ); } +/** + * Resets the quota for all users belonging to a specific team. + * Users who have no row yet get one inserted. + */ +export async function resetUserActionsByTeam(team, actionsRemaining, quotaResetAt) { + await usersPool.query( + `INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at) + SELECT id, $1, $2 FROM users WHERE team = $3 + ON CONFLICT (user_id) DO UPDATE + SET actions_remaining = $1, + quota_reset_at = $2`, + [actionsRemaining, quotaResetAt, team] + ); +} + /** Removes all rows from user_action_quota (used on world-seed wipeout). */ export async function truncateUserActionQuota() { await usersPool.query(`TRUNCATE user_action_quota`); diff --git a/server/econTick.js b/server/econTick.js index 60d7f26..9681bd3 100644 --- a/server/econTick.js +++ b/server/econTick.js @@ -4,13 +4,24 @@ import { getGridCells, addEconScore, setElementBonus, + getElementBonus, + getMilitaryDeductions, + resetTeamActions, } from "./db/gameDb.js"; -import { computeTeamIncome, computeTeamElementBonus } from "./helpers/economy.js"; +import { computeTeamIncome, computeTeamElementBonus, computeTeamMilitaryPower } from "./helpers/economy.js"; +import { nextResetUtc, resetUserActionsByTeam } from "./db/usersDb.js"; import { buildRealtimeSnapshot } from "./realtimeSnapshot.js"; -import { broadcast } from "./ws/hub.js"; +import { broadcast, broadcastToTeam } from "./ws/hub.js"; const TICK_SECONDS = 5; +const TEAMS = ["blue", "red"]; let lastTickSeed = null; +let lastQuotaSlot = null; + +function currentQuotaSlot(intervalSeconds, epochSec) { + const nowSec = Math.floor(Date.now() / 1000); + return Math.floor((nowSec - epochSec) / intervalSeconds); +} /** * Starts the server-side economy tick loop. @@ -50,6 +61,38 @@ export function startEconTick() { await setElementBonus(worldSeed, "blue", blueBonus); await setElementBonus(worldSeed, "red", redBonus); + // ── Actions quota reset (on every new interval slot) ───────────────── + const intervalSec = cfg.actionsResetIntervalSeconds ?? 3600; + const epochSec = cfg.timingEpochSec ?? 0; + const quotaSlot = currentQuotaSlot(intervalSec, epochSec); + + if (lastQuotaSlot !== null && quotaSlot !== lastQuotaSlot) { + const nextReset = nextResetUtc(intervalSec, epochSec).toISOString(); + const elementBonus = await getElementBonus(worldSeed); + const milDeductions = await getMilitaryDeductions(worldSeed); + + for (const team of TEAMS) { + // ── Team quota: base + military bonus, written to DB ────────────── + const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {}); + const milNet = milPower - (milDeductions[team] ?? 0); + // milNet is in "billions of military-weighted population". + // The UI displays milNet * 1000; the bonus formula applies to that display scale: + // floor(displayedValue / 1000) = floor(milNet * 1000 / 1000) = floor(milNet). + const milBonus = Math.floor(Math.max(0, milNet)); + const teamQuota = cfg.teamActionQuota + milBonus; + await resetTeamActions(team, teamQuota, nextReset); + broadcastToTeam(team, "team-quota-updated", { team, actionsRemaining: teamQuota }); + + // ── Per-user quota: element bonus scales the daily quota ────────── + const teamElemBonus = elementBonus[team] ?? 0; + const effectiveUserQuota = Math.floor(cfg.dailyActionQuota * (1 + teamElemBonus / 100)); + await resetUserActionsByTeam(team, effectiveUserQuota, nextReset); + } + + console.log(`[quota] Slot ${lastQuotaSlot} → ${quotaSlot}; user & team quotas reset.`); + } + lastQuotaSlot = quotaSlot; + const snapshot = await buildRealtimeSnapshot(worldSeed); broadcast("snapshot", snapshot); } catch (e) { diff --git a/server/index.js b/server/index.js index fda0ec0..d1c9faf 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,7 @@ import "dotenv/config"; import { createServer } from "http"; import { loadConfigFile, getConfig } from "./configLoader.js"; -import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js"; +import { initGameSchema, ensureSeedEpoch, persistCurrentSettings } from "./db/gameDb.js"; import { initUsersSchema } from "./db/usersDb.js"; import app from "./app.js"; import { startEconTick } from "./econTick.js"; @@ -14,9 +14,10 @@ function makeConfigSignature(cfg) { return JSON.stringify({ dailyActionQuota: cfg.dailyActionQuota, teamActionQuota: cfg.teamActionQuota, - actionsResetIntervalHours: cfg.actionsResetIntervalHours, + actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds, databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, + timingEpochSec: cfg.timingEpochSec ?? 0, elementWorth: cfg.elementWorth ?? {}, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, militaryPower: cfg.militaryPower ?? {}, @@ -37,14 +38,16 @@ function scheduleConfigPoll() { const cfg = getConfig(); const nextSig = makeConfigSignature(cfg); if (beforeSig && nextSig !== beforeSig) { + await persistCurrentSettings(); broadcast("config-updated", { worldSeed, config: { dailyActionQuota: cfg.dailyActionQuota, teamActionQuota: cfg.teamActionQuota, - actionsResetIntervalHours: cfg.actionsResetIntervalHours, + actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds, databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, + timingEpochSec: cfg.timingEpochSec ?? 0, elementWorth: cfg.elementWorth ?? {}, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, militaryPower: cfg.militaryPower ?? {}, diff --git a/server/routes/game.js b/server/routes/game.js index b15dcea..c6b9c50 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -26,13 +26,12 @@ import { getCellAttackCount, setTileOwner, getTeamActionsRow, - resetTeamActions, - decrementTeamActions, decrementTeamActionsBy, checkTeamVisibility, insertTeamVisibility, getTeamVisibleCells, getRoundHistory, + getSettingsSnapshot, } from "../db/gameDb.js"; import { nextResetUtc, @@ -53,7 +52,26 @@ router.get("/config", async (req, res) => { const worldSeed = await ensureSeedEpoch(); const cfg = getConfig(); const rot = cfg.databaseWipeoutIntervalSeconds; - const ws = computeWorldSeedState(rot); + const epoch = cfg.timingEpochSec ?? 0; + const ws = computeWorldSeedState(rot, epoch); + + // If in-memory config lost its worth tables (e.g. file-read failure), + // fall back to the last persisted snapshot stored in the DB. + let elementWorth = cfg.elementWorth ?? {}; + let resourceWorth = cfg.resourceWorth ?? { common: {}, rare: {} }; + let militaryPower = cfg.militaryPower ?? {}; + const worthEmpty = Object.keys(elementWorth).length === 0 + && Object.keys(resourceWorth.common ?? {}).length === 0; + if (worthEmpty) { + try { + const snap = await getSettingsSnapshot(); + if (snap) { + elementWorth = snap.elementWorth ?? elementWorth; + resourceWorth = snap.resourceWorth ?? resourceWorth; + militaryPower = snap.militaryPower ?? militaryPower; + } + } catch { /* best effort */ } + } let actionsRemaining = null; let teamActionsRemaining = null; @@ -77,19 +95,8 @@ router.get("/config", async (req, res) => { } // 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 = cfg.teamActionQuota + milBonus; - } else { - teamActionsRemaining = teamRow.actions_remaining; - } + teamActionsRemaining = teamRow?.actions_remaining ?? cfg.teamActionQuota; } res.json({ @@ -101,13 +108,35 @@ 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 ?? {}, + actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds ?? 3600, + timingEpochSec: epoch, + resourceWorth, + elementWorth, + militaryPower, }); } catch (e) { console.error(e); + // On error, try to return a meaningful fallback from the DB snapshot + try { + const snap = await getSettingsSnapshot(); + if (snap && snap.worldSeed) { + return res.json({ + dailyActionQuota: snap.dailyActionQuota ?? 100, + databaseWipeoutIntervalSeconds: snap.databaseWipeoutIntervalSeconds ?? 21600, + configReloadIntervalSeconds: snap.configReloadIntervalSeconds ?? 30, + worldSeed: snap.worldSeed, + seedPeriodEndsAtUtc: snap.seedPeriodEndsAtUtc, + seedPeriodStartsAtUtc: snap.seedPeriodStartsAtUtc, + actionsRemaining: null, + teamActionsRemaining: null, + actionsResetIntervalSeconds: snap.actionsResetIntervalSeconds ?? 3600, + timingEpochSec: snap.timingEpochSec ?? 0, + resourceWorth: snap.resourceWorth ?? { common: {}, rare: {} }, + elementWorth: snap.elementWorth ?? {}, + militaryPower: snap.militaryPower ?? {}, + }); + } + } catch { /* fall through to error */ } res.status(500).json({ error: "config_error" }); } }); @@ -122,7 +151,8 @@ router.get("/grid/:seed", async (req, res) => { error: "seed_expired", worldSeed, seedPeriodEndsAtUtc: computeWorldSeedState( - getConfig().databaseWipeoutIntervalSeconds + getConfig().databaseWipeoutIntervalSeconds, + getConfig().timingEpochSec ?? 0 ).seedPeriodEndsAtUtc, }); } @@ -172,7 +202,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, nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString()); + await resetUserActions(userId, effectiveQuota - 1, nextResetUtc(cfg.actionsResetIntervalSeconds ?? 3600, cfg.timingEpochSec ?? 0).toISOString()); } else { const updated = await decrementUserActions(userId); if (!updated) { @@ -234,26 +264,14 @@ router.post("/cell/capture", authMiddleware, async (req, res) => { const isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team; const cost = isOpponentControlled ? baseCost * 2 : baseCost; - // Consume team actions + // Consume team actions — team quota is epoch-scoped, never time-refreshed here. 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, 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 }); - } + const currentTeamActions = teamRow?.actions_remaining ?? cfg.teamActionQuota; + if (currentTeamActions < cost) { + return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: currentTeamActions }); + } + if (teamRow) { const updated = await decrementTeamActionsBy(team, cost); if (!updated) { return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 }); @@ -308,19 +326,8 @@ router.get("/team-quota", async (req, res) => { 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 = cfg.teamActionQuota + milBonus; - } else { - actionsRemaining = teamRow.actions_remaining; - } + const actionsRemaining = teamRow?.actions_remaining ?? cfg.teamActionQuota; res.json({ team, actionsRemaining }); } catch (e) { console.error(e); diff --git a/server/worldSeed.js b/server/worldSeed.js index c869167..c6ad29f 100644 --- a/server/worldSeed.js +++ b/server/worldSeed.js @@ -1,15 +1,18 @@ /** - * One world seed per UTC-aligned period: floor(utcUnixSeconds / rotationSeconds). - * Changing the slot triggers a full grid wipe on the server. + * One world seed per epoch-relative period. + * slot = floor((nowSec - epochSec) / rotationSeconds). + * Changing timing config resets epochSec, which resets the slot to 0. */ -export function computeWorldSeedState(rotationSeconds) { - const rot = Math.max(60, Math.floor(rotationSeconds)); +export function computeWorldSeedState(rotationSeconds, epochSec) { + const rot = Math.max(1, Math.floor(rotationSeconds)); + const epoch = Math.max(0, Math.floor(epochSec)); const nowSec = Math.floor(Date.now() / 1000); - const slot = Math.floor(nowSec / rot); - const periodStart = slot * rot; + const elapsed = Math.max(0, nowSec - epoch); + const slot = Math.floor(elapsed / rot); + const periodStart = epoch + slot * rot; const periodEnd = periodStart + rot; return { - worldSeed: `swg-${slot}`, + worldSeed: `swg-${epoch}-${slot}`, seedSlot: slot, seedPeriodEndsAtUtc: new Date(periodEnd * 1000).toISOString(), seedPeriodStartsAtUtc: new Date(periodStart * 1000).toISOString(),