refacto: Changing gameplay so teams don't know what the other team got + both teams can now chart the same cell without knowing + planet capture gameplay implemented
This commit is contained in:
@@ -7,9 +7,10 @@ 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, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
|
||||
/** @type {{ dailyActionQuota: number, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
|
||||
let cached = {
|
||||
dailyActionQuota: 100,
|
||||
teamActionQuota: 100,
|
||||
databaseWipeoutIntervalSeconds: 21600,
|
||||
debugModeForTeams: true,
|
||||
configReloadIntervalSeconds: 30,
|
||||
@@ -41,6 +42,9 @@ export function loadConfigFile() {
|
||||
if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) {
|
||||
cached.dailyActionQuota = Math.floor(j.dailyActionQuota);
|
||||
}
|
||||
if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) {
|
||||
cached.teamActionQuota = Math.floor(j.teamActionQuota);
|
||||
}
|
||||
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) {
|
||||
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pool } from "./pools.js";
|
||||
import { loadConfigFile, getConfig } from "../configLoader.js";
|
||||
import { computeWorldSeedState } from "../worldSeed.js";
|
||||
import { nextNoonUtc, resetAllUserActions } from "./usersDb.js";
|
||||
|
||||
let lastSeedSlot = null;
|
||||
|
||||
@@ -113,6 +114,32 @@ export async function initGameSchema() {
|
||||
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
|
||||
);
|
||||
`);
|
||||
// ── Per-team cell visibility (who has revealed what) ──────────────────────
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS team_cell_visibility (
|
||||
world_seed TEXT NOT NULL,
|
||||
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||
x SMALLINT NOT NULL,
|
||||
y SMALLINT NOT NULL,
|
||||
PRIMARY KEY (world_seed, team, x, y)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tcv_seed_team ON team_cell_visibility (world_seed, team);
|
||||
`);
|
||||
// ── Make discovered_by nullable (NULL = neutral, uncaptured) ─────────────
|
||||
await pool.query(`
|
||||
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP NOT NULL;
|
||||
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP DEFAULT;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check'
|
||||
) THEN
|
||||
ALTER TABLE grid_cells DROP CONSTRAINT grid_cells_discovered_by_check;
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check
|
||||
CHECK (discovered_by IS NULL OR discovered_by IN ('blue', 'red'));
|
||||
`);
|
||||
}
|
||||
|
||||
// ── World-seed epoch ──────────────────────────────────────────────────────────
|
||||
@@ -153,6 +180,19 @@ export async function ensureSeedEpoch() {
|
||||
await pool.query("DELETE FROM team_element_bonus WHERE world_seed != $1", [worldSeed]);
|
||||
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 = nextNoonUtc().toISOString();
|
||||
await pool.query(
|
||||
`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 = $1,
|
||||
quota_reset_at = $2`,
|
||||
[cfg.teamActionQuota, nextNoon]
|
||||
);
|
||||
await resetAllUserActions(cfg.dailyActionQuota, nextNoon);
|
||||
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||
lastSeedSlot = seedSlot;
|
||||
}
|
||||
@@ -170,17 +210,52 @@ export async function getGridCells(worldSeed) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) {
|
||||
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NULL)
|
||||
ON CONFLICT (world_seed, x, y) DO NOTHING
|
||||
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
|
||||
[seed, x, y, exploitable, hasPlanet, planetJson, team]
|
||||
[seed, x, y, exploitable, hasPlanet, planetJson]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ── Team cell visibility ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if team has already revealed this cell. */
|
||||
export async function checkTeamVisibility(worldSeed, team, x, y) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM team_cell_visibility WHERE world_seed = $1 AND team = $2 AND x = $3 AND y = $4`,
|
||||
[worldSeed, team, x, y]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Inserts a visibility record. Returns true if newly inserted (first reveal). */
|
||||
export async function insertTeamVisibility(worldSeed, team, x, y) {
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO team_cell_visibility (world_seed, team, x, y)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (world_seed, team, x, y) DO NOTHING`,
|
||||
[worldSeed, team, x, y]
|
||||
);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
/** Returns all grid cells that are visible to a given team. */
|
||||
export async function getTeamVisibleCells(worldSeed, team) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT gc.x, gc.y, gc.exploitable, gc.has_planet, gc.planet_json, gc.discovered_by
|
||||
FROM grid_cells gc
|
||||
JOIN team_cell_visibility tcv
|
||||
ON tcv.world_seed = gc.world_seed AND tcv.x = gc.x AND tcv.y = gc.y
|
||||
WHERE gc.world_seed = $1 AND tcv.team = $2`,
|
||||
[worldSeed, team]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getExistingCell(seed, x, y) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
||||
@@ -296,7 +371,7 @@ export async function getVictoryPoints() {
|
||||
export async function getScores(worldSeed) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT discovered_by, COUNT(*) AS cnt
|
||||
FROM grid_cells WHERE world_seed = $1
|
||||
FROM grid_cells WHERE world_seed = $1 AND discovered_by IS NOT NULL
|
||||
GROUP BY discovered_by`,
|
||||
[worldSeed]
|
||||
);
|
||||
@@ -398,3 +473,18 @@ export async function decrementTeamActions(team) {
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically decrements team actions_remaining by `amount` if sufficient.
|
||||
* Returns the updated row, or null if not enough actions.
|
||||
*/
|
||||
export async function decrementTeamActionsBy(team, amount) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE team_action_quota
|
||||
SET actions_remaining = actions_remaining - $2
|
||||
WHERE team = $1 AND actions_remaining >= $2
|
||||
RETURNING actions_remaining`,
|
||||
[team, amount]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -111,4 +111,19 @@ export async function decrementUserActions(userId) {
|
||||
[userId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets ALL users' quota to a given value (used on world-seed wipeout).
|
||||
* Users who have no row yet get one inserted.
|
||||
*/
|
||||
export async function resetAllUserActions(actionsRemaining, quotaResetAt) {
|
||||
await usersPool.query(
|
||||
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
|
||||
SELECT id, $1, $2 FROM users
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET actions_remaining = $1,
|
||||
quota_reset_at = $2`,
|
||||
[actionsRemaining, quotaResetAt]
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
getTeamActionsRow,
|
||||
resetTeamActions,
|
||||
decrementTeamActions,
|
||||
decrementTeamActionsBy,
|
||||
checkTeamVisibility,
|
||||
insertTeamVisibility,
|
||||
getTeamVisibleCells,
|
||||
} from "../db/gameDb.js";
|
||||
import {
|
||||
nextNoonUtc,
|
||||
@@ -78,7 +82,7 @@ router.get("/config", async (req, res) => {
|
||||
const milDeductions = await getMilitaryDeductions(worldSeed);
|
||||
const milNet = milPower - (milDeductions[team] ?? 0);
|
||||
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
|
||||
teamActionsRemaining = 100 + milBonus;
|
||||
teamActionsRemaining = cfg.teamActionQuota + milBonus;
|
||||
} else {
|
||||
teamActionsRemaining = teamRow.actions_remaining;
|
||||
}
|
||||
@@ -104,7 +108,7 @@ router.get("/config", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/grid/:seed
|
||||
// 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 {
|
||||
@@ -118,7 +122,14 @@ router.get("/grid/:seed", async (req, res) => {
|
||||
).seedPeriodEndsAtUtc,
|
||||
});
|
||||
}
|
||||
const rows = await getGridCells(seed);
|
||||
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);
|
||||
@@ -126,10 +137,10 @@ router.get("/grid/:seed", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cell/reveal
|
||||
// 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; // taken from verified JWT, not request body
|
||||
const team = req.user.team;
|
||||
const userId = req.user.userId;
|
||||
const x = Number(req.body?.x);
|
||||
const y = Number(req.body?.y);
|
||||
@@ -144,50 +155,42 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
||||
return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||
}
|
||||
|
||||
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);
|
||||
// Check if already revealed by this team (free re-view, no action cost)
|
||||
const alreadyVisible = await checkTeamVisibility(worldSeed, team, x, y);
|
||||
|
||||
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: "quota_exhausted",
|
||||
actionsRemaining: 0,
|
||||
});
|
||||
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;
|
||||
const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team);
|
||||
await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson);
|
||||
|
||||
// Track activity for active-player count (kept for stat purposes)
|
||||
// Track activity for active-player count
|
||||
await upsertUserCooldown(worldSeed, userId, team);
|
||||
|
||||
if (inserted) {
|
||||
return res.json(rowToCellPayload(inserted));
|
||||
}
|
||||
|
||||
const existing = await getExistingCell(seed, x, y);
|
||||
if (!existing) return res.status(500).json({ error: "insert_race" });
|
||||
|
||||
if (existing.discovered_by !== team) {
|
||||
return res.status(409).json({
|
||||
error: "taken_by_other_team",
|
||||
discoveredBy: existing.discovered_by,
|
||||
cell: rowToCellPayload(existing),
|
||||
});
|
||||
}
|
||||
return res.json(rowToCellPayload(existing));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -195,6 +198,76 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 : "";
|
||||
@@ -213,7 +286,7 @@ router.get("/team-quota", async (req, res) => {
|
||||
const milDeductions = await getMilitaryDeductions(worldSeed);
|
||||
const milNet = milPower - (milDeductions[team] ?? 0);
|
||||
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
|
||||
actionsRemaining = 100 + milBonus;
|
||||
actionsRemaining = cfg.teamActionQuota + milBonus;
|
||||
} else {
|
||||
actionsRemaining = teamRow.actions_remaining;
|
||||
}
|
||||
@@ -364,11 +437,11 @@ router.post("/military/attack", authMiddleware, async (req, res) => {
|
||||
const worldSeed = await ensureSeedEpoch();
|
||||
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||
|
||||
// Target cell must exist and belong to the opposing team
|
||||
// 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 === attackingTeam) {
|
||||
return res.status(409).json({ error: "cannot_attack_own_tile" });
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user