Private
Public Access
1
0

Adding repo after 2 days of vibecoding

This commit is contained in:
gauvainboiche
2026-03-29 11:16:46 +02:00
commit 4eac0f4415
948 changed files with 99537 additions and 0 deletions

487
server/index.js Normal file
View File

@@ -0,0 +1,487 @@
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);
});