fix: Fixing the MP power bonus + seed maintenance

This commit is contained in:
gauvainboiche
2026-04-03 14:25:19 +02:00
parent b11446cf56
commit d345c025c0
11 changed files with 349 additions and 132 deletions
+1
View File
@@ -1,5 +1,6 @@
data/postgres/ data/postgres/
data/postgres_users/ data/postgres_users/
config/.timing_epoch
.env .env
.env.local .env.local
.env.*.local .env.*.local
+1 -1
View File
@@ -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**. - 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). - 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))`. - 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 ### Reveal flow
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"dailyActionQuota": 100, "dailyActionQuota": 100,
"teamActionQuota": 100, "teamActionQuota": 100,
"actionsResetIntervalHours": 24, "actionsResetIntervalSeconds": 30,
"databaseWipeoutIntervalSeconds": 604800, "databaseWipeoutIntervalCycles": 2,
"configReloadIntervalSeconds": 30, "configReloadIntervalSeconds": 30,
"elementWorth": { "elementWorth": {
"common": 0.1, "common": 0.1,
+19 -18
View File
@@ -22,9 +22,10 @@ const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
export const GAME_CONFIG = { export const GAME_CONFIG = {
dailyActionQuota: 100, dailyActionQuota: 100,
actionsResetIntervalHours: 12, actionsResetIntervalSeconds: 3600,
databaseWipeoutIntervalSeconds: 21600, databaseWipeoutIntervalSeconds: 21600,
configReloadIntervalSeconds: 30, configReloadIntervalSeconds: 30,
timingEpochSec: 0,
worldSeed: "", worldSeed: "",
seedPeriodEndsAtUtc: "", seedPeriodEndsAtUtc: "",
elementWorth: {}, elementWorth: {},
@@ -288,11 +289,12 @@ export function isOwnTile(key) { const m = cellMeta(key); return m !== nul
export function applyConfigPayload(data) { export function applyConfigPayload(data) {
GAME_CONFIG.dailyActionQuota = Number(data.dailyActionQuota) || 100; GAME_CONFIG.dailyActionQuota = Number(data.dailyActionQuota) || 100;
if (data.actionsResetIntervalHours) { if (data.actionsResetIntervalSeconds) {
GAME_CONFIG.actionsResetIntervalHours = Number(data.actionsResetIntervalHours) || 12; GAME_CONFIG.actionsResetIntervalSeconds = Number(data.actionsResetIntervalSeconds) || 3600;
} }
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600; GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30); 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.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? ""); GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
if (data.elementWorth && typeof data.elementWorth === "object") { 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. */ /** Computes time until next actions-reset based on the configured interval and updates the display. */
export function updateActionsResetCountdown() { export function updateActionsResetCountdown() {
if (!actionsResetCountdownEl) return; if (!actionsResetCountdownEl) return;
const intervalHours = GAME_CONFIG.actionsResetIntervalHours ?? 12; const intervalSec = GAME_CONFIG.actionsResetIntervalSeconds ?? 3600;
const now = Date.now(); const epochSec = GAME_CONFIG.timingEpochSec ?? 0;
const date = new Date(now); const nowSec = Math.floor(Date.now() / 1000);
const utcHours = date.getUTCHours(); const elapsed = Math.max(0, nowSec - epochSec);
const slotsPassed = Math.floor(utcHours / intervalHours); const slot = Math.floor(elapsed / intervalSec);
const nextSlotHour = (slotsPassed + 1) * intervalHours; const nextResetSec = epochSec + (slot + 1) * intervalSec;
const next = nextSlotHour < 24 const diff = nextResetSec - nowSec;
? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), nextSlotHour, 0, 0, 0) const hh = Math.floor(diff / 3600);
: Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1, 0, 0, 0, 0); const mm = Math.floor((diff % 3600) / 60);
const diff = next - now; const ss = diff % 60;
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 = actionsResetCountdownEl.textContent =
String(hh).padStart(2, "0") + ":" + String(hh).padStart(2, "0") + ":" +
String(mm).padStart(2, "0") + ":" + String(mm).padStart(2, "0") + ":" +
@@ -1318,13 +1316,16 @@ export async function refreshFromServer() {
const seedChanged = await fetchConfig(); const seedChanged = await fetchConfig();
if (seedChanged) { if (seedChanged) {
resetEconScores(); resetEconScores();
loadEconScores();
details.textContent = "Les stats sont vides jusqu'au clic sur une tuile."; details.textContent = "Les stats sont vides jusqu'au clic sur une tuile.";
details.classList.add("details--hidden"); details.classList.add("details--hidden");
hint.textContent = "Le monde a été réinitilisaté. Vous pouvez cliquer sur une tuile pour recommencer le jeu."; hint.textContent = "Le monde a été réinitilisaté. Vous pouvez cliquer sur une tuile pour recommencer le jeu.";
} }
await fetchGridForSeed(seedStr); await fetchGridForSeed(seedStr);
await fetchAndApplyActivePlayers(); await fetchAndApplyActivePlayers();
await loadPlayerNames();
await loadEconScores();
await loadElementBonus();
await loadMilitaryDeductions();
updateEconomyDisplay(); updateEconomyDisplay();
draw(); draw();
refreshCursorFromLast(); refreshCursorFromLast();
+63 -16
View File
@@ -7,21 +7,49 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE_PATH = const CONFIG_FILE_PATH =
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json"); 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 EPOCH_FILE_PATH =
const VALID_RESET_HOURS = new Set([1, 2, 3, 4, 6, 8, 12, 24]); process.env.EPOCH_FILE_PATH ?? path.join(path.dirname(CONFIG_FILE_PATH), ".timing_epoch");
let cached = { let cached = {
dailyActionQuota: 100, dailyActionQuota: 100,
teamActionQuota: 100, teamActionQuota: 100,
actionsResetIntervalHours: 12, actionsResetIntervalSeconds: 3600,
databaseWipeoutIntervalSeconds: 21600, databaseWipeoutIntervalCycles: 6,
configReloadIntervalSeconds: 30, configReloadIntervalSeconds: 30,
elementWorth: {}, elementWorth: {},
resourceWorth: { common: {}, rare: {} }, resourceWorth: { common: {}, rare: {} },
militaryPower: {}, 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) { function parseBool(v) {
if (typeof v === "boolean") return v; if (typeof v === "boolean") return v;
@@ -35,11 +63,13 @@ function parseBool(v) {
export function loadConfigFile() { export function loadConfigFile() {
try { try {
const st = fs.statSync(CONFIG_FILE_PATH); // Always read the file — mtime is unreliable on Docker bind mounts (Windows).
if (st.mtimeMs === lastMtimeMs) { // 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; return cached;
} }
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8"); lastRawContent = raw;
const j = JSON.parse(raw); const j = JSON.parse(raw);
if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) { if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) {
cached.dailyActionQuota = Math.floor(j.dailyActionQuota); cached.dailyActionQuota = Math.floor(j.dailyActionQuota);
@@ -47,12 +77,11 @@ export function loadConfigFile() {
if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) { if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) {
cached.teamActionQuota = Math.floor(j.teamActionQuota); cached.teamActionQuota = Math.floor(j.teamActionQuota);
} }
if (typeof j.actionsResetIntervalHours === "number") { if (typeof j.actionsResetIntervalSeconds === "number" && j.actionsResetIntervalSeconds >= 1) {
const v = Math.floor(j.actionsResetIntervalHours); cached.actionsResetIntervalSeconds = Math.floor(j.actionsResetIntervalSeconds);
if (VALID_RESET_HOURS.has(v)) cached.actionsResetIntervalHours = v;
} }
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) { if (typeof j.databaseWipeoutIntervalCycles === "number" && j.databaseWipeoutIntervalCycles >= 1) {
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds; cached.databaseWipeoutIntervalCycles = Math.floor(j.databaseWipeoutIntervalCycles);
} }
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) { if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds; cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
@@ -64,10 +93,25 @@ export function loadConfigFile() {
cached.resourceWorth = j.resourceWorth; cached.resourceWorth = j.resourceWorth;
} if (j.militaryPower && typeof j.militaryPower === 'object') { } if (j.militaryPower && typeof j.militaryPower === 'object') {
cached.militaryPower = j.militaryPower; 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) { } catch (e) {
if (e.code === "ENOENT") { if (e.code === "ENOENT") {
lastMtimeMs = 0; lastRawContent = "";
} else { } else {
console.error("[config]", e.message); console.error("[config]", e.message);
} }
@@ -76,7 +120,10 @@ export function loadConfigFile() {
} }
export function getConfig() { export function getConfig() {
return { ...cached }; const c = { ...cached };
c.databaseWipeoutIntervalSeconds = c.actionsResetIntervalSeconds * c.databaseWipeoutIntervalCycles;
c.timingEpochSec = timingEpochSec;
return c;
} }
export function getConfigFilePath() { export function getConfigFilePath() {
+120 -14
View File
@@ -4,6 +4,11 @@ import { computeWorldSeedState } from "../worldSeed.js";
import { nextResetUtc, truncateUserActionQuota, getUsersByIds } from "./usersDb.js"; import { nextResetUtc, truncateUserActionQuota, getUsersByIds } from "./usersDb.js";
let lastSeedSlot = null; let lastSeedSlot = null;
let lastWorldSeed = null;
// ── Mutex for seed transitions ────────────────────────────────────────────────
// Prevents concurrent ensureSeedEpoch() calls from racing on the wipe.
let seedTransitionPromise = null;
// ── Schema ──────────────────────────────────────────────────────────────────── // ── 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); 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 ────────────────────────────────────────────────────────── // ── World-seed epoch ──────────────────────────────────────────────────────────
export async function ensureSeedEpoch() { async function _ensureSeedEpochInner() {
loadConfigFile(); loadConfigFile();
const rot = getConfig().databaseWipeoutIntervalSeconds; const cfg = getConfig();
const { seedSlot, worldSeed } = computeWorldSeedState(rot); const rot = cfg.databaseWipeoutIntervalSeconds;
if (lastSeedSlot === null) { const epoch = cfg.timingEpochSec ?? 0;
const { seedSlot, worldSeed } = computeWorldSeedState(rot, epoch);
if (lastWorldSeed === null) {
lastSeedSlot = seedSlot; lastSeedSlot = seedSlot;
lastWorldSeed = worldSeed;
await persistSettingsSnapshot(cfg, worldSeed, rot, epoch);
return worldSeed; return worldSeed;
} }
if (seedSlot !== lastSeedSlot) { if (worldSeed !== lastWorldSeed) {
// Award a victory point to the team with the highest econ score before wiping // Award a victory point to the team with the highest econ score before wiping
try { try {
const expiredSeed = `swg-${lastSeedSlot}`; const expiredSeed = lastWorldSeed;
const econRows = await pool.query( const econRows = await pool.query(
`SELECT team, score FROM team_econ_scores WHERE world_seed = $1`, `SELECT team, score FROM team_econ_scores WHERE world_seed = $1`,
[expiredSeed] [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 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 cell_attack_log WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM team_cell_visibility 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 // Reset team quota to seed-scoped defaults; expiry is the seed period end so it
const cfg = getConfig(); // never looks stale during a live epoch.
const nextNoon = nextResetUtc(cfg.actionsResetIntervalHours ?? 12).toISOString(); const seedPeriodEnd = new Date((epoch + (seedSlot + 1) * rot) * 1000).toISOString();
await pool.query( await pool.query(
`TRUNCATE team_action_quota; `INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at) VALUES ('blue', $1, $2), ('red', $1, $2)
VALUES ('blue', $1, $2), ('red', $1, $2)`, ON CONFLICT (team) DO UPDATE
[cfg.teamActionQuota, nextNoon] 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(); 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; lastSeedSlot = seedSlot;
lastWorldSeed = worldSeed;
await persistSettingsSnapshot(cfg, worldSeed, rot, epoch);
} }
return worldSeed; 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 ──────────────────────────────────────────────────────────────── // ── Grid cells ────────────────────────────────────────────────────────────────
export async function getGridCells(worldSeed) { export async function getGridCells(worldSeed) {
+24 -18
View File
@@ -29,28 +29,19 @@ export async function initUserActionQuotaSchema() {
/** Returns the next noon (12:00:00) UTC after the current moment. */ /** Returns the next noon (12:00:00) UTC after the current moment. */
export function nextNoonUtc() { export function nextNoonUtc() {
return nextResetUtc(12); return nextResetUtc(43200); // 12 hours = 43200 seconds
} }
/** /**
* Returns the next reset timestamp UTC based on a repeating interval. * Returns the next reset timestamp UTC based on a repeating interval relative to an epoch.
* @param {number} intervalHours - must be a divisor of 24 (1,2,3,4,6,8,12,24) * @param {number} intervalSeconds - the interval length in seconds (>= 1)
* @param {number} epochSec - Unix seconds origin for slot calculation
*/ */
export function nextResetUtc(intervalHours) { export function nextResetUtc(intervalSeconds, epochSec = 0) {
const now = new Date(); const nowSec = Math.floor(Date.now() / 1000);
const utcHours = now.getUTCHours(); const elapsed = Math.max(0, nowSec - epochSec);
const slotsPassed = Math.floor(utcHours / intervalHours); const slot = Math.floor(elapsed / intervalSeconds);
const nextSlotHour = (slotsPassed + 1) * intervalHours; return new Date((epochSec + (slot + 1) * intervalSeconds) * 1000);
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
));
} }
// ── Queries ─────────────────────────────────────────────────────────────────── // ── 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). */ /** Removes all rows from user_action_quota (used on world-seed wipeout). */
export async function truncateUserActionQuota() { export async function truncateUserActionQuota() {
await usersPool.query(`TRUNCATE user_action_quota`); await usersPool.query(`TRUNCATE user_action_quota`);
+45 -2
View File
@@ -4,13 +4,24 @@ import {
getGridCells, getGridCells,
addEconScore, addEconScore,
setElementBonus, setElementBonus,
getElementBonus,
getMilitaryDeductions,
resetTeamActions,
} from "./db/gameDb.js"; } 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 { buildRealtimeSnapshot } from "./realtimeSnapshot.js";
import { broadcast } from "./ws/hub.js"; import { broadcast, broadcastToTeam } from "./ws/hub.js";
const TICK_SECONDS = 5; const TICK_SECONDS = 5;
const TEAMS = ["blue", "red"];
let lastTickSeed = null; 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. * Starts the server-side economy tick loop.
@@ -50,6 +61,38 @@ export function startEconTick() {
await setElementBonus(worldSeed, "blue", blueBonus); await setElementBonus(worldSeed, "blue", blueBonus);
await setElementBonus(worldSeed, "red", redBonus); 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); const snapshot = await buildRealtimeSnapshot(worldSeed);
broadcast("snapshot", snapshot); broadcast("snapshot", snapshot);
} catch (e) { } catch (e) {
+6 -3
View File
@@ -1,7 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import { createServer } from "http"; import { createServer } from "http";
import { loadConfigFile, getConfig } from "./configLoader.js"; 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 { initUsersSchema } from "./db/usersDb.js";
import app from "./app.js"; import app from "./app.js";
import { startEconTick } from "./econTick.js"; import { startEconTick } from "./econTick.js";
@@ -14,9 +14,10 @@ function makeConfigSignature(cfg) {
return JSON.stringify({ return JSON.stringify({
dailyActionQuota: cfg.dailyActionQuota, dailyActionQuota: cfg.dailyActionQuota,
teamActionQuota: cfg.teamActionQuota, teamActionQuota: cfg.teamActionQuota,
actionsResetIntervalHours: cfg.actionsResetIntervalHours, actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds,
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds, databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
timingEpochSec: cfg.timingEpochSec ?? 0,
elementWorth: cfg.elementWorth ?? {}, elementWorth: cfg.elementWorth ?? {},
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
militaryPower: cfg.militaryPower ?? {}, militaryPower: cfg.militaryPower ?? {},
@@ -37,14 +38,16 @@ function scheduleConfigPoll() {
const cfg = getConfig(); const cfg = getConfig();
const nextSig = makeConfigSignature(cfg); const nextSig = makeConfigSignature(cfg);
if (beforeSig && nextSig !== beforeSig) { if (beforeSig && nextSig !== beforeSig) {
await persistCurrentSettings();
broadcast("config-updated", { broadcast("config-updated", {
worldSeed, worldSeed,
config: { config: {
dailyActionQuota: cfg.dailyActionQuota, dailyActionQuota: cfg.dailyActionQuota,
teamActionQuota: cfg.teamActionQuota, teamActionQuota: cfg.teamActionQuota,
actionsResetIntervalHours: cfg.actionsResetIntervalHours, actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds,
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds, databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
timingEpochSec: cfg.timingEpochSec ?? 0,
elementWorth: cfg.elementWorth ?? {}, elementWorth: cfg.elementWorth ?? {},
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
militaryPower: cfg.militaryPower ?? {}, militaryPower: cfg.militaryPower ?? {},
+57 -50
View File
@@ -26,13 +26,12 @@ import {
getCellAttackCount, getCellAttackCount,
setTileOwner, setTileOwner,
getTeamActionsRow, getTeamActionsRow,
resetTeamActions,
decrementTeamActions,
decrementTeamActionsBy, decrementTeamActionsBy,
checkTeamVisibility, checkTeamVisibility,
insertTeamVisibility, insertTeamVisibility,
getTeamVisibleCells, getTeamVisibleCells,
getRoundHistory, getRoundHistory,
getSettingsSnapshot,
} from "../db/gameDb.js"; } from "../db/gameDb.js";
import { import {
nextResetUtc, nextResetUtc,
@@ -53,7 +52,26 @@ router.get("/config", async (req, res) => {
const worldSeed = await ensureSeedEpoch(); const worldSeed = await ensureSeedEpoch();
const cfg = getConfig(); const cfg = getConfig();
const rot = cfg.databaseWipeoutIntervalSeconds; 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 actionsRemaining = null;
let teamActionsRemaining = null; let teamActionsRemaining = null;
@@ -77,19 +95,8 @@ router.get("/config", async (req, res) => {
} }
// Team-wide quota: compute current remaining without consuming // Team-wide quota: compute current remaining without consuming
const now = new Date();
const teamRow = await getTeamActionsRow(team); const teamRow = await getTeamActionsRow(team);
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) { teamActionsRemaining = teamRow?.actions_remaining ?? cfg.teamActionQuota;
// 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;
}
} }
res.json({ res.json({
@@ -101,13 +108,35 @@ router.get("/config", async (req, res) => {
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc, seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
actionsRemaining, actionsRemaining,
teamActionsRemaining, teamActionsRemaining,
actionsResetIntervalHours: cfg.actionsResetIntervalHours ?? 12, actionsResetIntervalSeconds: cfg.actionsResetIntervalSeconds ?? 3600,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, timingEpochSec: epoch,
elementWorth: cfg.elementWorth ?? {}, resourceWorth,
militaryPower: cfg.militaryPower ?? {}, elementWorth,
militaryPower,
}); });
} catch (e) { } catch (e) {
console.error(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" }); res.status(500).json({ error: "config_error" });
} }
}); });
@@ -122,7 +151,8 @@ router.get("/grid/:seed", async (req, res) => {
error: "seed_expired", error: "seed_expired",
worldSeed, worldSeed,
seedPeriodEndsAtUtc: computeWorldSeedState( seedPeriodEndsAtUtc: computeWorldSeedState(
getConfig().databaseWipeoutIntervalSeconds getConfig().databaseWipeoutIntervalSeconds,
getConfig().timingEpochSec ?? 0
).seedPeriodEndsAtUtc, ).seedPeriodEndsAtUtc,
}); });
} }
@@ -172,7 +202,7 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
const now = new Date(); const now = new Date();
const quotaRow = await getUserActionsRow(userId); const quotaRow = await getUserActionsRow(userId);
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) { 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 { } else {
const updated = await decrementUserActions(userId); const updated = await decrementUserActions(userId);
if (!updated) { 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 isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team;
const cost = isOpponentControlled ? baseCost * 2 : baseCost; 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 cfg = getConfig();
const now = new Date();
const teamRow = await getTeamActionsRow(team); const teamRow = await getTeamActionsRow(team);
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) { const currentTeamActions = teamRow?.actions_remaining ?? cfg.teamActionQuota;
// Compute fresh quota if (currentTeamActions < cost) {
const rows = await getGridCells(worldSeed); return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: currentTeamActions });
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 });
} }
if (teamRow) {
const updated = await decrementTeamActionsBy(team, cost); const updated = await decrementTeamActionsBy(team, cost);
if (!updated) { if (!updated) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 }); return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 });
@@ -308,19 +326,8 @@ router.get("/team-quota", async (req, res) => {
try { try {
const worldSeed = await ensureSeedEpoch(); const worldSeed = await ensureSeedEpoch();
const cfg = getConfig(); const cfg = getConfig();
const now = new Date();
const teamRow = await getTeamActionsRow(team); const teamRow = await getTeamActionsRow(team);
let actionsRemaining; const actionsRemaining = teamRow?.actions_remaining ?? cfg.teamActionQuota;
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;
}
res.json({ team, actionsRemaining }); res.json({ team, actionsRemaining });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
+10 -7
View File
@@ -1,15 +1,18 @@
/** /**
* One world seed per UTC-aligned period: floor(utcUnixSeconds / rotationSeconds). * One world seed per epoch-relative period.
* Changing the slot triggers a full grid wipe on the server. * slot = floor((nowSec - epochSec) / rotationSeconds).
* Changing timing config resets epochSec, which resets the slot to 0.
*/ */
export function computeWorldSeedState(rotationSeconds) { export function computeWorldSeedState(rotationSeconds, epochSec) {
const rot = Math.max(60, Math.floor(rotationSeconds)); const rot = Math.max(1, Math.floor(rotationSeconds));
const epoch = Math.max(0, Math.floor(epochSec));
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const slot = Math.floor(nowSec / rot); const elapsed = Math.max(0, nowSec - epoch);
const periodStart = slot * rot; const slot = Math.floor(elapsed / rot);
const periodStart = epoch + slot * rot;
const periodEnd = periodStart + rot; const periodEnd = periodStart + rot;
return { return {
worldSeed: `swg-${slot}`, worldSeed: `swg-${epoch}-${slot}`,
seedSlot: slot, seedSlot: slot,
seedPeriodEndsAtUtc: new Date(periodEnd * 1000).toISOString(), seedPeriodEndsAtUtc: new Date(periodEnd * 1000).toISOString(),
seedPeriodStartsAtUtc: new Date(periodStart * 1000).toISOString(), seedPeriodStartsAtUtc: new Date(periodStart * 1000).toISOString(),