fix: Fixing the MP power bonus + seed maintenance
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
data/postgres/
|
||||
data/postgres_users/
|
||||
config/.timing_epoch
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"dailyActionQuota": 100,
|
||||
"teamActionQuota": 100,
|
||||
"actionsResetIntervalHours": 24,
|
||||
"databaseWipeoutIntervalSeconds": 604800,
|
||||
"actionsResetIntervalSeconds": 30,
|
||||
"databaseWipeoutIntervalCycles": 2,
|
||||
"configReloadIntervalSeconds": 30,
|
||||
"elementWorth": {
|
||||
"common": 0.1,
|
||||
|
||||
+19
-18
@@ -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();
|
||||
|
||||
+63
-16
@@ -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() {
|
||||
|
||||
+120
-14
@@ -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) {
|
||||
|
||||
+24
-18
@@ -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`);
|
||||
|
||||
+45
-2
@@ -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) {
|
||||
|
||||
+6
-3
@@ -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 ?? {},
|
||||
|
||||
+57
-50
@@ -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);
|
||||
|
||||
+10
-7
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user