refacto: Changing click cooldown to daily actions for users and teams
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user