refacto: Keeping entrypoints clean and making files by purpose
This commit is contained in:
25
server/app.js
Normal file
25
server/app.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import authRouter from "./routes/auth.js";
|
||||
import gameRouter from "./routes/game.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = path.join(__dirname, "..", "public");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api", gameRouter);
|
||||
|
||||
// Catch-all: serve index.html for non-API routes (SPA fallback)
|
||||
app.get("*", (req, res) => {
|
||||
if (req.path.startsWith("/api")) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
|
||||
export default app;
|
||||
128
server/db/gameDb.js
Normal file
128
server/db/gameDb.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { pool } from "./pools.js";
|
||||
import { loadConfigFile, getConfig } from "../configLoader.js";
|
||||
import { computeWorldSeedState } from "../worldSeed.js";
|
||||
|
||||
let lastSeedSlot = null;
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initGameSchema() {
|
||||
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;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── World-seed epoch ──────────────────────────────────────────────────────────
|
||||
|
||||
export 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] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||
lastSeedSlot = seedSlot;
|
||||
}
|
||||
return worldSeed;
|
||||
}
|
||||
|
||||
// ── Grid cells ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getGridCells(worldSeed) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
||||
FROM grid_cells WHERE world_seed = $1`,
|
||||
[worldSeed]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) {
|
||||
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)
|
||||
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]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getExistingCell(seed, x, y) {
|
||||
const { rows } = 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]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ── Team cooldowns ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTeamCooldown(worldSeed, team) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT last_reveal FROM team_cooldowns WHERE world_seed = $1 AND team = $2`,
|
||||
[worldSeed, team]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function upsertTeamCooldown(worldSeed, team) {
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getScores(worldSeed) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT discovered_by, COUNT(*) AS cnt
|
||||
FROM grid_cells WHERE world_seed = $1
|
||||
GROUP BY discovered_by`,
|
||||
[worldSeed]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
10
server/db/pools.js
Normal file
10
server/db/pools.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import pg from "pg";
|
||||
|
||||
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";
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
export const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL });
|
||||
45
server/db/usersDb.js
Normal file
45
server/db/usersDb.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { usersPool } from "./pools.js";
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export 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()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createUser(username, email, passwordHash, team) {
|
||||
const { rows } = await usersPool.query(
|
||||
`INSERT INTO users (username, email, password_hash, team)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, username, email, team, role`,
|
||||
[username, email, passwordHash, team]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username) {
|
||||
const { rows } = await usersPool.query(
|
||||
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
|
||||
[username]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getUserById(id) {
|
||||
const { rows } = await usersPool.query(
|
||||
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
55
server/helpers/cell.js
Normal file
55
server/helpers/cell.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { fnv1a32, hash2u32, mulberry32 } from "../../public/src/rng.js";
|
||||
import { generatePlanet, formatPlanet } from "../../public/src/planetGeneration.js";
|
||||
|
||||
const GRID_W = 100;
|
||||
const GRID_H = 100;
|
||||
const OUTER_RADIUS = 50;
|
||||
const INNER_RADIUS = 30;
|
||||
const PLANET_CHANCE = 0.1;
|
||||
|
||||
export function cellCenter(x, y) {
|
||||
const cx = (GRID_W - 1) / 2;
|
||||
const cy = (GRID_H - 1) / 2;
|
||||
return { dx: x - cx, dy: y - cy };
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function hasPlanetAt(x, y, seedU32) {
|
||||
const h = hash2u32(x, y, seedU32);
|
||||
return h / 4294967296 < PLANET_CHANCE;
|
||||
}
|
||||
|
||||
export 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),
|
||||
};
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
473
server/index.js
473
server/index.js
@@ -1,463 +1,16 @@
|
||||
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";
|
||||
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
||||
import { initUsersSchema } from "./db/usersDb.js";
|
||||
import app from "./app.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
|
||||
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);
|
||||
// ── Config-file poll ──────────────────────────────────────────────────────────
|
||||
// Periodically re-reads game.settings.json and checks for a seed-epoch change.
|
||||
|
||||
function scheduleConfigPoll() {
|
||||
loadConfigFile();
|
||||
const ms = Math.max(5000, getConfig().configReloadIntervalSeconds * 1000);
|
||||
const ms = Math.max(5_000, getConfig().configReloadIntervalSeconds * 1_000);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
loadConfigFile();
|
||||
@@ -469,15 +22,21 @@ function scheduleConfigPoll() {
|
||||
}, ms);
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
loadConfigFile();
|
||||
await initSchema();
|
||||
await initGameSchema();
|
||||
await initUsersSchema();
|
||||
await ensureSeedEpoch();
|
||||
app.listen(port, () => {
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const cfg = getConfig();
|
||||
console.log(`Listening on ${port}, config=${cfg.clickCooldownSeconds}s cooldown, wipe=${cfg.databaseWipeoutIntervalSeconds}s`);
|
||||
console.log(
|
||||
`[server] Listening on :${PORT} cooldown=${cfg.clickCooldownSeconds}s wipe=${cfg.databaseWipeoutIntervalSeconds}s`
|
||||
);
|
||||
});
|
||||
|
||||
scheduleConfigPoll();
|
||||
}
|
||||
|
||||
|
||||
18
server/middleware/auth.js
Normal file
18
server/middleware/auth.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
export const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me";
|
||||
|
||||
export 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" });
|
||||
}
|
||||
}
|
||||
83
server/routes/auth.js
Normal file
83
server/routes/auth.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import express from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
|
||||
import { createUser, getUserByUsername, getUserById } from "../db/usersDb.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function issueToken(user) {
|
||||
return jwt.sign(
|
||||
{ userId: user.id, username: user.username, team: user.team, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: "7d" }
|
||||
);
|
||||
}
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post("/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 user = await createUser(username.trim(), email.trim().toLowerCase(), passwordHash, team);
|
||||
const token = issueToken(user);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post("/login", async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (!username || !password) return res.status(400).json({ error: "missing_fields" });
|
||||
try {
|
||||
const user = await getUserByUsername(username.trim());
|
||||
if (!user) return res.status(401).json({ error: "invalid_credentials" });
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: "invalid_credentials" });
|
||||
const token = issueToken(user);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get("/me", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await getUserById(req.user.userId);
|
||||
if (!user) return res.status(404).json({ error: "user_not_found" });
|
||||
const token = issueToken(user);
|
||||
return res.json({ token, user });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.status(500).json({ error: "database_error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
154
server/routes/game.js
Normal file
154
server/routes/game.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import express from "express";
|
||||
import { getConfig } from "../configLoader.js";
|
||||
import { computeWorldSeedState } from "../worldSeed.js";
|
||||
import {
|
||||
ensureSeedEpoch,
|
||||
getGridCells,
|
||||
insertCell,
|
||||
getExistingCell,
|
||||
getTeamCooldown,
|
||||
upsertTeamCooldown,
|
||||
getScores,
|
||||
} from "../db/gameDb.js";
|
||||
import { computeCell, rowToCellPayload } from "../helpers/cell.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 teamCooldownRemaining = 0;
|
||||
const team = typeof req.query.team === "string" ? req.query.team : undefined;
|
||||
if (team === "blue" || team === "red") {
|
||||
const row = await getTeamCooldown(worldSeed, team);
|
||||
if (row) {
|
||||
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
|
||||
if (secondsSince < cfg.clickCooldownSeconds) {
|
||||
teamCooldownRemaining = Math.ceil(cfg.clickCooldownSeconds - secondsSince);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
clickCooldownSeconds: cfg.clickCooldownSeconds,
|
||||
databaseWipeoutIntervalSeconds: rot,
|
||||
debugModeForTeams: cfg.debugModeForTeams,
|
||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||
worldSeed: ws.worldSeed,
|
||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||
teamCooldownRemaining,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: "config_error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/grid/:seed
|
||||
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,
|
||||
});
|
||||
}
|
||||
const rows = await getGridCells(seed);
|
||||
res.json({ seed, cells: rows });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: "database_error" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cell/reveal
|
||||
router.post("/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();
|
||||
if (cfg.clickCooldownSeconds > 0) {
|
||||
const cooldownRow = await getTeamCooldown(worldSeed, team);
|
||||
if (cooldownRow) {
|
||||
const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000;
|
||||
if (secondsSince < cfg.clickCooldownSeconds) {
|
||||
return res.status(429).json({
|
||||
error: "cooldown_active",
|
||||
team,
|
||||
remainingSeconds: Math.ceil(cfg.clickCooldownSeconds - secondsSince),
|
||||
cooldownSeconds: cfg.clickCooldownSeconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (inserted) {
|
||||
await upsertTeamCooldown(worldSeed, team);
|
||||
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);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user