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, getActivePlayerIds, getMilitaryDeductions, addMilitaryDeduction, recordCellAttack, getCellAttackCount, setTileOwner, getTeamActionsRow, resetTeamActions, decrementTeamActions, decrementTeamActionsBy, checkTeamVisibility, insertTeamVisibility, getTeamVisibleCells, } from "../db/gameDb.js"; import { nextNoonUtc, getUserActionsRow, resetUserActions, decrementUserActions, getUsersByIds, } from "../db/usersDb.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js"; import { computeTeamMilitaryPower } from "../helpers/economy.js"; import { broadcast, broadcastToTeam } from "../ws/hub.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, 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" }); broadcastToTeam(team, "cell-updated", { worldSeed, cell: rowToCellPayload(existing), }); 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, req.user.username); const updatedCell = await getExistingCell(worldSeed, x, y); const updatedTeamRow = await getTeamActionsRow(team); const updatedCellPayload = rowToCellPayload(updatedCell); // Team that made the capture always gets the update. broadcastToTeam(team, "cell-updated", { worldSeed, cell: updatedCellPayload, }); // Opponent receives the update only if that team had visibility on this cell. const opposingTeam = team === "blue" ? "red" : "blue"; const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y); if (opposingVisible) { broadcastToTeam(opposingTeam, "cell-updated", { worldSeed, cell: updatedCellPayload, }); } broadcastToTeam(team, "team-quota-updated", { team, actionsRemaining: updatedTeamRow?.actions_remaining ?? null, }); res.json({ success: true, cell: updatedCellPayload, 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/active-players/names router.get("/active-players/names", async (_req, res) => { try { const worldSeed = await ensureSeedEpoch(); const playerIds = await getActivePlayerIds(worldSeed); const allIds = [...playerIds.blue, ...playerIds.red]; const users = await getUsersByIds(allIds); const result = { blue: [], red: [] }; for (const user of users) { if (result[user.team]) result[user.team].push(user.username); } res.json(result); } 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, req.user.username); // Record the attack event await recordCellAttack(worldSeed, x, y, attackingTeam); const deductions = await getMilitaryDeductions(worldSeed); const updatedCell = await getExistingCell(worldSeed, x, y); const updatedCellPayload = rowToCellPayload(updatedCell); broadcast("military-deductions-updated", { worldSeed, deductions, }); broadcastToTeam(attackingTeam, "cell-updated", { worldSeed, cell: updatedCellPayload, }); const opposingTeam = attackingTeam === "blue" ? "red" : "blue"; const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y); if (opposingVisible) { broadcastToTeam(opposingTeam, "cell-updated", { worldSeed, cell: updatedCellPayload, }); } res.json({ success: true, cell: updatedCellPayload, 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;