Private
Public Access
1
0

refacto: Changing click cooldown to daily actions for users and teams

This commit is contained in:
gauvainboiche
2026-04-01 16:30:35 +02:00
parent 33c3518ee4
commit 5aa347eb13
9 changed files with 294 additions and 101 deletions

View File

@@ -7,9 +7,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE_PATH =
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
/** @type {{ dailyActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
let cached = {
clickCooldownSeconds: 5,
dailyActionQuota: 100,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
@@ -38,8 +38,8 @@ export function loadConfigFile() {
}
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8");
const j = JSON.parse(raw);
if (typeof j.clickCooldownSeconds === "number" && j.clickCooldownSeconds >= 0) {
cached.clickCooldownSeconds = j.clickCooldownSeconds;
if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) {
cached.dailyActionQuota = Math.floor(j.dailyActionQuota);
}
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) {
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds;

View File

@@ -106,6 +106,13 @@ export async function initGameSchema() {
END $$;
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS team_action_quota (
team TEXT PRIMARY KEY CHECK (team IN ('blue', 'red')),
actions_remaining INTEGER NOT NULL DEFAULT 0,
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
);
`);
}
// ── World-seed epoch ──────────────────────────────────────────────────────────
@@ -355,3 +362,39 @@ export async function setTileOwner(worldSeed, x, y, team) {
[team, worldSeed, x, y]
);
}
// ── Team action quota (daily, independent of world seed) ─────────────────────
export async function getTeamActionsRow(team) {
const { rows } = await pool.query(
`SELECT actions_remaining, quota_reset_at FROM team_action_quota WHERE team = $1`,
[team]
);
return rows[0] ?? null;
}
export async function resetTeamActions(team, actionsRemaining, quotaResetAt) {
await pool.query(
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
VALUES ($1, $2, $3)
ON CONFLICT (team) DO UPDATE
SET actions_remaining = $2,
quota_reset_at = $3`,
[team, actionsRemaining, quotaResetAt]
);
}
/**
* Atomically decrements team actions_remaining by 1 if > 0.
* Returns the updated row, or null if already 0.
*/
export async function decrementTeamActions(team) {
const { rows } = await pool.query(
`UPDATE team_action_quota
SET actions_remaining = actions_remaining - 1
WHERE team = $1 AND actions_remaining > 0
RETURNING actions_remaining`,
[team]
);
return rows[0] ?? null;
}

View File

@@ -13,6 +13,29 @@ export async function initUsersSchema() {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await initUserActionQuotaSchema();
}
export async function initUserActionQuotaSchema() {
await usersPool.query(`
CREATE TABLE IF NOT EXISTS user_action_quota (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
actions_remaining INTEGER NOT NULL DEFAULT 0,
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
);
`);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Returns the next noon (12:00:00) UTC after the current moment. */
export function nextNoonUtc() {
const now = new Date();
const noon = new Date(Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0
));
if (noon <= now) noon.setUTCDate(noon.getUTCDate() + 1);
return noon;
}
// ── Queries ───────────────────────────────────────────────────────────────────
@@ -50,4 +73,42 @@ export async function getTeamPlayerCounts() {
const result = { blue: 0, red: 0 };
for (const row of rows) result[row.team] = row.count;
return result;
}
// ── User action quota ─────────────────────────────────────────────────────────
/** Returns the current quota row for a user, or null if it doesn't exist. */
export async function getUserActionsRow(userId) {
const { rows } = await usersPool.query(
`SELECT actions_remaining, quota_reset_at FROM user_action_quota WHERE user_id = $1`,
[userId]
);
return rows[0] ?? null;
}
/** Insert or overwrite the quota row for a user. */
export async function resetUserActions(userId, actionsRemaining, quotaResetAt) {
await usersPool.query(
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET actions_remaining = $2,
quota_reset_at = $3`,
[userId, actionsRemaining, quotaResetAt]
);
}
/**
* Atomically decrements actions_remaining by 1 if > 0.
* Returns the updated row (with new actions_remaining), or null if already 0.
*/
export async function decrementUserActions(userId) {
const { rows } = await usersPool.query(
`UPDATE user_action_quota
SET actions_remaining = actions_remaining - 1
WHERE user_id = $1 AND actions_remaining > 0
RETURNING actions_remaining`,
[userId]
);
return rows[0] ?? null;
}

View File

@@ -122,3 +122,35 @@ export function computeTeamElementBonus(team, rows, elementWorth) {
}
return bonus;
}
// ── Military power computation ────────────────────────────────────────────────
const POP_LABEL_TO_KEY = new Map([
["Humains", "humans"],
["Presque'humains", "near"],
["Aliens", "aliens"],
]);
/**
* Compute total military power (in billions) for a team from DB grid rows.
*
* @param {string} team - "blue" or "red"
* @param {Array<{ has_planet: boolean, planet_json: object|null, discovered_by: string }>} rows
* @param {object} militaryPower - { humans: 10, near: 5, aliens: 1 }
* @returns {number} military power in billions
*/
export function computeTeamMilitaryPower(team, rows, militaryPower) {
let total = 0;
for (const row of rows) {
if (row.discovered_by !== team) continue;
if (!row.has_planet || !row.planet_json) continue;
const pop = row.planet_json.population;
if (!pop) continue;
const key = POP_LABEL_TO_KEY.get(pop.majority);
if (!key) continue;
const pct = militaryPower?.[key] ?? 0;
if (pct === 0) continue;
total += pop.billions * pct / 100;
}
return total;
}

View File

@@ -35,7 +35,7 @@ async function main() {
app.listen(PORT, () => {
const cfg = getConfig();
console.log(
`[server] Listening on :${PORT} cooldown=${cfg.clickCooldownSeconds}s wipe=${cfg.databaseWipeoutIntervalSeconds}s`
`[server] Listening on :${PORT} dailyQuota=${cfg.dailyActionQuota} wipe=${cfg.databaseWipeoutIntervalSeconds}s`
);
});

View File

@@ -24,8 +24,18 @@ import {
recordCellAttack,
getCellAttackCount,
setTileOwner,
getTeamActionsRow,
resetTeamActions,
decrementTeamActions,
} from "../db/gameDb.js";
import {
nextNoonUtc,
getUserActionsRow,
resetUserActions,
decrementUserActions,
} from "../db/usersDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
import { computeTeamMilitaryPower } from "../helpers/economy.js";
const router = express.Router();
@@ -37,45 +47,53 @@ router.get("/config", async (req, res) => {
const rot = cfg.databaseWipeoutIntervalSeconds;
const ws = computeWorldSeedState(rot);
let teamCooldownRemaining = 0;
let actionsRemaining = null;
let teamActionsRemaining = null;
const team = typeof req.query.team === "string" ? req.query.team : undefined;
if (team === "blue" || team === "red") {
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
// Use per-user cooldown if auth token provided, else fall back to team-wide
const authHeader = req.headers["authorization"];
if (authHeader && authHeader.startsWith("Bearer ")) {
try {
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
const row = await getUserCooldown(worldSeed, payload.userId);
if (row) {
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
}
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
const now = new Date();
const quotaRow = await getUserActionsRow(payload.userId);
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
actionsRemaining = effectiveQuota;
} else {
actionsRemaining = quotaRow.actions_remaining;
}
} catch { /* invalid token — return 0 */ }
} catch { /* invalid token — return null */ }
}
// Team-wide quota: compute current remaining without consuming
const now = new Date();
const teamRow = await getTeamActionsRow(team);
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
// Expired or unset: compute what it would be when refreshed
const rows = await getGridCells(worldSeed);
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
const milDeductions = await getMilitaryDeductions(worldSeed);
const milNet = milPower - (milDeductions[team] ?? 0);
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
teamActionsRemaining = 100 + milBonus;
} else {
const row = await getTeamCooldown(worldSeed, team);
if (row) {
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
}
}
teamActionsRemaining = teamRow.actions_remaining;
}
}
res.json({
clickCooldownSeconds: cfg.clickCooldownSeconds,
dailyActionQuota: cfg.dailyActionQuota,
databaseWipeoutIntervalSeconds: rot,
debugModeForTeams: cfg.debugModeForTeams,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
worldSeed: ws.worldSeed,
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
teamCooldownRemaining,
actionsRemaining,
teamActionsRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {},
militaryPower: cfg.militaryPower ?? {},
@@ -127,19 +145,23 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
}
const cfg = getConfig();
if (cfg.clickCooldownSeconds > 0) {
if (cfg.dailyActionQuota > 0) {
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
const cooldownRow = await getUserCooldown(worldSeed, userId);
if (cooldownRow) {
const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
const now = new Date();
const quotaRow = await getUserActionsRow(userId);
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
// First action of the day (or expired): reset to effectiveQuota - 1 (consuming 1 now)
await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString());
} else {
// Quota exists and still valid — try to consume
const updated = await decrementUserActions(userId);
if (!updated) {
return res.status(429).json({
error: "cooldown_active",
team,
remainingSeconds: Math.ceil(effectiveCooldown - secondsSince),
cooldownSeconds: effectiveCooldown,
error: "quota_exhausted",
actionsRemaining: 0,
});
}
}
@@ -149,8 +171,10 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team);
// Track activity for active-player count (kept for stat purposes)
await upsertUserCooldown(worldSeed, userId, team);
if (inserted) {
await upsertUserCooldown(worldSeed, userId, team);
return res.json(rowToCellPayload(inserted));
}
@@ -171,6 +195,35 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
}
});
// GET /api/team-quota?team=blue|red
router.get("/team-quota", async (req, res) => {
const team = typeof req.query.team === "string" ? req.query.team : "";
if (team !== "blue" && team !== "red") {
return res.status(400).json({ error: "invalid_team" });
}
try {
const worldSeed = await ensureSeedEpoch();
const cfg = getConfig();
const now = new Date();
const teamRow = await getTeamActionsRow(team);
let actionsRemaining;
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
const rows = await getGridCells(worldSeed);
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
const milDeductions = await getMilitaryDeductions(worldSeed);
const milNet = milPower - (milDeductions[team] ?? 0);
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
actionsRemaining = 100 + milBonus;
} else {
actionsRemaining = teamRow.actions_remaining;
}
res.json({ team, actionsRemaining });
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/econ-scores
router.get("/econ-scores", async (_req, res) => {
try {