import express from "express"; import pg from "pg"; import path from "path"; import { fileURLToPath } from "url"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { fnv1a32, hash2u32, mulberry32 } from "../public/src/rng.js"; import { formatPlanet, generatePlanet } from "../public/src/planetGeneration.js"; import { loadConfigFile, getConfig } from "./configLoader.js"; import { computeWorldSeedState } from "./worldSeed.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const GRID_W = 100; const GRID_H = 100; const OUTER_RADIUS = 50; const INNER_RADIUS = 30; const PLANET_CHANCE = 0.1; const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me"; function cellCenter(x, y) { const cx = (GRID_W - 1) / 2; const cy = (GRID_H - 1) / 2; return { dx: x - cx, dy: y - cy }; } function isExploitable(x, y) { const { dx, dy } = cellCenter(x, y); const r = Math.hypot(dx, dy); return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5; } function hasPlanetAt(x, y, seedU32) { const h = hash2u32(x, y, seedU32); return h / 4294967296 < PLANET_CHANCE; } function computeCell(seedStr, x, y) { const seedU32 = fnv1a32(seedStr); const exploitable = isExploitable(x, y); if (!exploitable) { return { x, y, exploitable: false, hasPlanet: false, planet: null, formatted: null }; } const hp = hasPlanetAt(x, y, seedU32); if (!hp) { return { x, y, exploitable: true, hasPlanet: false, planet: null, formatted: null }; } const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5); const rng = mulberry32(h); const planet = generatePlanet(rng); return { x, y, exploitable: true, hasPlanet: true, planet, formatted: formatPlanet(planet), }; } const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid"; const USERS_DATABASE_URL = process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL }); let lastSeedSlot = null; async function initSchema() { await pool.query(` CREATE TABLE IF NOT EXISTS grid_cells ( id SERIAL PRIMARY KEY, world_seed TEXT NOT NULL, x SMALLINT NOT NULL CHECK (x >= 0 AND x < 100), y SMALLINT NOT NULL CHECK (y >= 0 AND y < 100), exploitable BOOLEAN NOT NULL, has_planet BOOLEAN NOT NULL, planet_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (world_seed, x, y) ); CREATE INDEX IF NOT EXISTS idx_grid_cells_seed ON grid_cells (world_seed); `); await pool.query(` CREATE TABLE IF NOT EXISTS team_cooldowns ( world_seed TEXT NOT NULL, team TEXT NOT NULL CHECK (team IN ('blue', 'red')), last_reveal TIMESTAMPTZ, PRIMARY KEY (world_seed, team) ); `); await pool.query(` ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT; UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL; ALTER TABLE grid_cells ALTER COLUMN discovered_by SET DEFAULT 'blue'; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check' ) THEN ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check CHECK (discovered_by IN ('blue', 'red')); END IF; END $$; ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL; `); } async function initUsersSchema() { await usersPool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, team TEXT NOT NULL CHECK (team IN ('blue', 'red')), role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); } function rowToCellPayload(row) { return { x: row.x, y: row.y, exploitable: row.exploitable, hasPlanet: row.has_planet, planet: row.planet_json ?? null, discoveredBy: row.discovered_by, }; } async function ensureSeedEpoch() { loadConfigFile(); const rot = getConfig().databaseWipeoutIntervalSeconds; const { seedSlot, worldSeed } = computeWorldSeedState(rot); if (lastSeedSlot === null) { lastSeedSlot = seedSlot; return worldSeed; } if (seedSlot !== lastSeedSlot) { await pool.query("TRUNCATE grid_cells RESTART IDENTITY"); await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]); console.log(`[world] Period slot ${lastSeedSlot} -> ${seedSlot}; grid wiped and cooldowns cleared for old seeds.`); lastSeedSlot = seedSlot; } return worldSeed; } function authMiddleware(req, res, next) { const authHeader = req.headers["authorization"]; if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).json({ error: "unauthorized" }); } const token = authHeader.slice(7); try { const payload = jwt.verify(token, JWT_SECRET); req.user = payload; next(); } catch { return res.status(401).json({ error: "invalid_token" }); } } const app = express(); app.use(express.json()); const publicDir = path.join(__dirname, "..", "public"); app.use(express.static(publicDir)); // ── Auth endpoints ────────────────────────────────────────────────────────── app.post("/api/auth/register", async (req, res) => { const { username, email, password, team } = req.body ?? {}; if (!username || !email || !password || !team) { return res.status(400).json({ error: "missing_fields" }); } if (team !== "blue" && team !== "red") { return res.status(400).json({ error: "invalid_team" }); } if (typeof username !== "string" || username.length < 2 || username.length > 32) { return res.status(400).json({ error: "invalid_username" }); } if (typeof password !== "string" || password.length < 6) { return res.status(400).json({ error: "password_too_short" }); } try { const passwordHash = await bcrypt.hash(password, 12); const result = await usersPool.query( `INSERT INTO users (username, email, password_hash, team) VALUES ($1, $2, $3, $4) RETURNING id, username, email, team, role`, [username.trim(), email.trim().toLowerCase(), passwordHash, team] ); const user = result.rows[0]; const token = jwt.sign( { userId: user.id, username: user.username, team: user.team, role: user.role }, JWT_SECRET, { expiresIn: "7d" } ); return res.status(201).json({ token, user: { id: user.id, username: user.username, team: user.team, role: user.role } }); } catch (e) { if (e.code === "23505") { if (e.constraint?.includes("email")) { return res.status(409).json({ error: "email_taken" }); } return res.status(409).json({ error: "username_taken" }); } console.error(e); return res.status(500).json({ error: "database_error" }); } }); app.post("/api/auth/login", async (req, res) => { const { username, password } = req.body ?? {}; if (!username || !password) { return res.status(400).json({ error: "missing_fields" }); } try { const result = await usersPool.query( `SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`, [username.trim()] ); if (result.rows.length === 0) { return res.status(401).json({ error: "invalid_credentials" }); } const user = result.rows[0]; const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.status(401).json({ error: "invalid_credentials" }); } const token = jwt.sign( { userId: user.id, username: user.username, team: user.team, role: user.role }, JWT_SECRET, { expiresIn: "7d" } ); return res.json({ token, user: { id: user.id, username: user.username, team: user.team, role: user.role } }); } catch (e) { console.error(e); return res.status(500).json({ error: "database_error" }); } }); app.get("/api/auth/me", authMiddleware, async (req, res) => { try { const result = await usersPool.query( `SELECT id, username, email, team, role FROM users WHERE id = $1`, [req.user.userId] ); if (result.rows.length === 0) { return res.status(404).json({ error: "user_not_found" }); } const user = result.rows[0]; // Re-issue token with fresh team/role in case admin changed it const token = jwt.sign( { userId: user.id, username: user.username, team: user.team, role: user.role }, JWT_SECRET, { expiresIn: "7d" } ); return res.json({ token, user }); } catch (e) { console.error(e); return res.status(500).json({ error: "database_error" }); } }); // ── Scores endpoint ───────────────────────────────────────────────────────── app.get("/api/scores", async (req, res) => { try { const worldSeed = await ensureSeedEpoch(); const { rows } = await pool.query( `SELECT discovered_by, COUNT(*) AS cnt FROM grid_cells WHERE world_seed = $1 GROUP BY discovered_by`, [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" }); } }); // ── Existing game endpoints ───────────────────────────────────────────────── app.get("/api/config", async (_req, res) => { try { const worldSeed = await ensureSeedEpoch(); const cfg = getConfig(); const rot = cfg.databaseWipeoutIntervalSeconds; const ws = computeWorldSeedState(rot); const team = typeof _req.query.team === 'string' ? _req.query.team : undefined; let teamCooldownRemaining = 0; if (team && (team === "blue" || team === "red")) { const cooldownResult = await pool.query( `SELECT last_reveal FROM team_cooldowns WHERE world_seed = $1 AND team = $2`, [worldSeed, team] ); if (cooldownResult.rows.length > 0) { const lastReveal = new Date(cooldownResult.rows[0].last_reveal); const secondsSinceLastReveal = (Date.now() - lastReveal.getTime()) / 1000; if (secondsSinceLastReveal < cfg.clickCooldownSeconds) { teamCooldownRemaining = Math.ceil(cfg.clickCooldownSeconds - secondsSinceLastReveal); } } } res.json({ clickCooldownSeconds: cfg.clickCooldownSeconds, databaseWipeoutIntervalSeconds: rot, debugModeForTeams: cfg.debugModeForTeams, configReloadIntervalSeconds: cfg.configReloadIntervalSeconds, worldSeed: ws.worldSeed, seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc, seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc, teamCooldownRemaining: teamCooldownRemaining }); } catch (e) { console.error(e); res.status(500).json({ error: "config_error" }); } }); app.get("/api/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, }); } const { rows } = await pool.query( `SELECT x, y, exploitable, has_planet, planet_json, discovered_by FROM grid_cells WHERE world_seed = $1`, [seed] ); res.json({ seed, cells: rows }); } catch (e) { console.error(e); res.status(500).json({ error: "database_error" }); } }); app.post("/api/cell/reveal", async (req, res) => { const seed = String(req.body?.seed ?? ""); const team = String(req.body?.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" }); } if (team !== "blue" && team !== "red") { return res.status(400).json({ error: "invalid_team" }); } try { const worldSeed = await ensureSeedEpoch(); if (seed !== worldSeed) { return res.status(410).json({ error: "seed_expired", worldSeed, }); } const cfg = getConfig(); const cooldownSeconds = cfg.clickCooldownSeconds; if (cooldownSeconds > 0) { const cooldownResult = await pool.query( `SELECT last_reveal FROM team_cooldowns WHERE world_seed = $1 AND team = $2`, [worldSeed, team] ); if (cooldownResult.rows.length > 0) { const lastReveal = new Date(cooldownResult.rows[0].last_reveal); const secondsSinceLastReveal = (Date.now() - lastReveal.getTime()) / 1000; if (secondsSinceLastReveal < cooldownSeconds) { const remaining = Math.ceil(cooldownSeconds - secondsSinceLastReveal); return res.status(429).json({ error: "cooldown_active", team, remainingSeconds: remaining, cooldownSeconds: cooldownSeconds }); } } } const cell = computeCell(seed, x, y); const planetJson = cell.planet ? JSON.stringify(cell.planet) : null; const ins = 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) ON CONFLICT (world_seed, x, y) DO NOTHING RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`, [seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team] ); if (ins.rows.length > 0) { await pool.query( `INSERT INTO team_cooldowns (world_seed, team, last_reveal) VALUES ($1, $2, NOW()) ON CONFLICT (world_seed, team) DO UPDATE SET last_reveal = NOW()`, [worldSeed, team] ); return res.json(rowToCellPayload(ins.rows[0])); } const existing = await pool.query( `SELECT x, y, exploitable, has_planet, planet_json, discovered_by FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`, [seed, x, y] ); const row = existing.rows[0]; if (!row) { return res.status(500).json({ error: "insert_race" }); } if (row.discovered_by !== team) { return res.status(409).json({ error: "taken_by_other_team", discoveredBy: row.discovered_by, cell: rowToCellPayload(row), }); } return res.json(rowToCellPayload(row)); } catch (e) { console.error(e); res.status(500).json({ error: "database_error" }); } }); app.get("*", (req, res) => { if (req.path.startsWith("/api")) { return res.status(404).json({ error: "not_found" }); } res.sendFile(path.join(publicDir, "index.html")); }); const port = Number(process.env.PORT ?? 8080); function scheduleConfigPoll() { loadConfigFile(); const ms = Math.max(5000, getConfig().configReloadIntervalSeconds * 1000); setTimeout(async () => { try { loadConfigFile(); await ensureSeedEpoch(); } catch (e) { console.error("[config poll]", e.message); } scheduleConfigPoll(); }, ms); } async function main() { loadConfigFile(); await initSchema(); await initUsersSchema(); await ensureSeedEpoch(); app.listen(port, () => { const cfg = getConfig(); console.log(`Listening on ${port}, config=${cfg.clickCooldownSeconds}s cooldown, wipe=${cfg.databaseWipeoutIntervalSeconds}s`); }); scheduleConfigPoll(); } main().catch((e) => { console.error(e); process.exit(1); });