488 lines
17 KiB
JavaScript
488 lines
17 KiB
JavaScript
import express from "express";
|
|
import jwt from "jsonwebtoken";
|
|
import { getConfig } from "../configLoader.js";
|
|
import { computeWorldSeedState } from "../worldSeed.js";
|
|
import { authMiddleware, JWT_SECRET } from "../middleware/auth.js";
|
|
import {
|
|
ensureSeedEpoch,
|
|
getGridCells,
|
|
insertCell,
|
|
getExistingCell,
|
|
getTeamCooldown,
|
|
getUserCooldown,
|
|
upsertUserCooldown,
|
|
getScores,
|
|
getEconScores,
|
|
addEconScore,
|
|
getElementBonus,
|
|
setElementBonus,
|
|
getDbCreatedAt,
|
|
getVictoryPoints,
|
|
getActivePlayerCounts,
|
|
getMilitaryDeductions,
|
|
addMilitaryDeduction,
|
|
recordCellAttack,
|
|
getCellAttackCount,
|
|
setTileOwner,
|
|
getTeamActionsRow,
|
|
resetTeamActions,
|
|
decrementTeamActions,
|
|
decrementTeamActionsBy,
|
|
checkTeamVisibility,
|
|
insertTeamVisibility,
|
|
getTeamVisibleCells,
|
|
} 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();
|
|
|
|
// GET /api/config
|
|
router.get("/config", async (req, res) => {
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const cfg = getConfig();
|
|
const rot = cfg.databaseWipeoutIntervalSeconds;
|
|
const ws = computeWorldSeedState(rot);
|
|
|
|
let actionsRemaining = null;
|
|
let teamActionsRemaining = null;
|
|
const team = typeof req.query.team === "string" ? req.query.team : undefined;
|
|
if (team === "blue" || team === "red") {
|
|
const authHeader = req.headers["authorization"];
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
try {
|
|
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
|
|
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 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 = cfg.teamActionQuota + milBonus;
|
|
} else {
|
|
teamActionsRemaining = teamRow.actions_remaining;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
dailyActionQuota: cfg.dailyActionQuota,
|
|
databaseWipeoutIntervalSeconds: rot,
|
|
debugModeForTeams: cfg.debugModeForTeams,
|
|
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
|
worldSeed: ws.worldSeed,
|
|
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
|
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
|
actionsRemaining,
|
|
teamActionsRemaining,
|
|
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
|
elementWorth: cfg.elementWorth ?? {},
|
|
militaryPower: cfg.militaryPower ?? {},
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "config_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/grid/:seed (auth-aware: returns only team-visible cells)
|
|
router.get("/grid/:seed", async (req, res) => {
|
|
const seed = decodeURIComponent(req.params.seed || "");
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) {
|
|
return res.status(410).json({
|
|
error: "seed_expired",
|
|
worldSeed,
|
|
seedPeriodEndsAtUtc: computeWorldSeedState(
|
|
getConfig().databaseWipeoutIntervalSeconds
|
|
).seedPeriodEndsAtUtc,
|
|
});
|
|
}
|
|
let rows = [];
|
|
const authHeader = req.headers["authorization"];
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
try {
|
|
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
|
|
rows = await getTeamVisibleCells(worldSeed, payload.team);
|
|
} catch { /* invalid token — return empty grid */ }
|
|
}
|
|
res.json({ seed, cells: rows });
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// POST /api/cell/reveal — reveals a cell for THIS team only (private visibility)
|
|
router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
|
const seed = String(req.body?.seed ?? "");
|
|
const team = req.user.team;
|
|
const userId = req.user.userId;
|
|
const x = Number(req.body?.x);
|
|
const y = Number(req.body?.y);
|
|
|
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
|
return res.status(400).json({ error: "invalid_body" });
|
|
}
|
|
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) {
|
|
return res.status(410).json({ error: "seed_expired", worldSeed });
|
|
}
|
|
|
|
// Check if already revealed by this team (free re-view, no action cost)
|
|
const alreadyVisible = await checkTeamVisibility(worldSeed, team, x, y);
|
|
|
|
if (!alreadyVisible) {
|
|
// First reveal: deduct one user action
|
|
const cfg = getConfig();
|
|
if (cfg.dailyActionQuota > 0) {
|
|
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(userId);
|
|
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
|
|
await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString());
|
|
} else {
|
|
const updated = await decrementUserActions(userId);
|
|
if (!updated) {
|
|
return res.status(429).json({ error: "quota_exhausted", actionsRemaining: 0 });
|
|
}
|
|
}
|
|
}
|
|
// Mark as visible for this team
|
|
await insertTeamVisibility(worldSeed, team, x, y);
|
|
}
|
|
|
|
// Ensure cell data is in grid_cells (discovered_by stays NULL = neutral)
|
|
const cell = computeCell(seed, x, y);
|
|
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
|
|
await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson);
|
|
|
|
// Track activity for active-player count
|
|
await upsertUserCooldown(worldSeed, userId, team);
|
|
|
|
const existing = await getExistingCell(seed, x, y);
|
|
if (!existing) return res.status(500).json({ error: "insert_race" });
|
|
|
|
return res.json(rowToCellPayload(existing));
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// POST /api/cell/capture — spend team actions to capture a planet
|
|
router.post("/cell/capture", authMiddleware, async (req, res) => {
|
|
const seed = String(req.body?.seed ?? "");
|
|
const team = req.user.team;
|
|
const x = Number(req.body?.x);
|
|
const y = Number(req.body?.y);
|
|
|
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
|
return res.status(400).json({ error: "invalid_body" });
|
|
}
|
|
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
|
|
|
const existing = await getExistingCell(worldSeed, x, y);
|
|
if (!existing) return res.status(404).json({ error: "cell_not_found" });
|
|
if (!existing.has_planet) return res.status(409).json({ error: "no_planet" });
|
|
if (existing.discovered_by === team) return res.status(409).json({ error: "already_owned" });
|
|
|
|
// Compute capture cost (1 action per 10 billion population, doubled if opponent-controlled)
|
|
const planet = existing.planet_json;
|
|
const billions = planet?.population?.billions ?? 0;
|
|
const baseCost = Math.max(1, Math.ceil(billions / 10));
|
|
const isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team;
|
|
const cost = isOpponentControlled ? baseCost * 2 : baseCost;
|
|
|
|
// Consume team actions
|
|
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, nextNoonUtc().toISOString());
|
|
} else {
|
|
if (teamRow.actions_remaining < cost) {
|
|
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: teamRow.actions_remaining });
|
|
}
|
|
const updated = await decrementTeamActionsBy(team, cost);
|
|
if (!updated) {
|
|
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 });
|
|
}
|
|
}
|
|
|
|
// Transfer ownership to capturing team
|
|
await setTileOwner(worldSeed, x, y, team);
|
|
|
|
const updatedCell = await getExistingCell(worldSeed, x, y);
|
|
const updatedTeamRow = await getTeamActionsRow(team);
|
|
|
|
res.json({
|
|
success: true,
|
|
cell: rowToCellPayload(updatedCell),
|
|
teamActionsRemaining: updatedTeamRow?.actions_remaining ?? null,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// 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 = cfg.teamActionQuota + 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 {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const scores = await getEconScores(worldSeed);
|
|
res.json(scores);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// POST /api/econ-scores/tick body: { seed, blue, red }
|
|
router.post("/econ-scores/tick", async (req, res) => {
|
|
const seed = String(req.body?.seed ?? "");
|
|
const blue = Number(req.body?.blue ?? 0);
|
|
const red = Number(req.body?.red ?? 0);
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
|
await addEconScore(worldSeed, "blue", blue);
|
|
await addEconScore(worldSeed, "red", red);
|
|
const scores = await getEconScores(worldSeed);
|
|
res.json(scores);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/element-bonus
|
|
router.get("/element-bonus", async (_req, res) => {
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const bonus = await getElementBonus(worldSeed);
|
|
res.json(bonus);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// POST /api/element-bonus/tick body: { seed, blue, red }
|
|
router.post("/element-bonus/tick", async (req, res) => {
|
|
const seed = String(req.body?.seed ?? "");
|
|
const blue = Number(req.body?.blue ?? 0);
|
|
const red = Number(req.body?.red ?? 0);
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
|
await setElementBonus(worldSeed, "blue", blue);
|
|
await setElementBonus(worldSeed, "red", red);
|
|
const bonus = await getElementBonus(worldSeed);
|
|
res.json(bonus);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/db-info
|
|
router.get("/db-info", async (_req, res) => {
|
|
try {
|
|
const createdAt = await getDbCreatedAt();
|
|
res.json({ createdAt });
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/victory-points
|
|
router.get("/victory-points", async (_req, res) => {
|
|
try {
|
|
const vp = await getVictoryPoints();
|
|
res.json(vp);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/active-players
|
|
router.get("/active-players", async (_req, res) => {
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const counts = await getActivePlayerCounts(worldSeed);
|
|
res.json(counts);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/scores
|
|
router.get("/scores", async (_req, res) => {
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const rows = await getScores(worldSeed);
|
|
const scores = { blue: 0, red: 0 };
|
|
for (const row of rows) {
|
|
if (row.discovered_by === "blue") scores.blue = Number(row.cnt);
|
|
if (row.discovered_by === "red") scores.red = Number(row.cnt);
|
|
}
|
|
res.json(scores);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/military-deductions
|
|
router.get("/military-deductions", async (_req, res) => {
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const deductions = await getMilitaryDeductions(worldSeed);
|
|
res.json(deductions);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// POST /api/military/attack body: { seed, x, y }
|
|
// Requires auth. The attacker's team must have enough computed military power.
|
|
// Deducts exactly 1.0 billion (= 1000 M) and logs the attack on the cell.
|
|
router.post("/military/attack", authMiddleware, async (req, res) => {
|
|
const seed = String(req.body?.seed ?? "");
|
|
const x = Number(req.body?.x);
|
|
const y = Number(req.body?.y);
|
|
const attackingTeam = req.user.team;
|
|
|
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
|
return res.status(400).json({ error: "invalid_body" });
|
|
}
|
|
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
|
|
|
// Target cell must exist, have a planet, and belong to the opposing team
|
|
const existing = await getExistingCell(worldSeed, x, y);
|
|
if (!existing) return res.status(404).json({ error: "cell_not_found" });
|
|
if (!existing.discovered_by || existing.discovered_by === attackingTeam) {
|
|
return res.status(409).json({ error: "cannot_attack_own_or_neutral_tile" });
|
|
}
|
|
|
|
// Deduct 1 billion (1.0 in "billions" unit) from the attacking team
|
|
const COST_BILLIONS = 1.0;
|
|
await addMilitaryDeduction(worldSeed, attackingTeam, COST_BILLIONS);
|
|
|
|
// Transfer tile ownership to the attacking team
|
|
await setTileOwner(worldSeed, x, y, attackingTeam);
|
|
|
|
// Record the attack event
|
|
await recordCellAttack(worldSeed, x, y, attackingTeam);
|
|
|
|
const deductions = await getMilitaryDeductions(worldSeed);
|
|
const updatedCell = await getExistingCell(worldSeed, x, y);
|
|
|
|
res.json({
|
|
success: true,
|
|
cell: rowToCellPayload(updatedCell),
|
|
deductions,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/cell/attacks?x=&y=
|
|
router.get("/cell/attacks", async (req, res) => {
|
|
const x = Number(req.query.x);
|
|
const y = Number(req.query.y);
|
|
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
|
return res.status(400).json({ error: "invalid_params" });
|
|
}
|
|
try {
|
|
const worldSeed = await ensureSeedEpoch();
|
|
const count = await getCellAttackCount(worldSeed, x, y);
|
|
res.json({ x, y, attackCount: count });
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: "database_error" });
|
|
}
|
|
});
|
|
|
|
export default router; |