Private
Public Access
1
0

refacto: Keeping entrypoints clean and making files by purpose

This commit is contained in:
gauvainboiche
2026-03-29 13:28:18 +02:00
parent 79cf3ca13e
commit 84af90e81e
13 changed files with 1198 additions and 1167 deletions

51
public/src/api.js Normal file
View File

@@ -0,0 +1,51 @@
// ── Raw HTTP wrappers ─────────────────────────────────────────────────────────
// These functions only perform fetch() calls and return the raw Response or
// parsed JSON. No state mutations, no DOM access.
export async function apiFetchConfig(team) {
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`);
if (!res.ok) throw new Error("config_fetch_failed");
return res.json();
}
export async function apiFetchScores() {
const res = await fetch("/api/scores");
if (!res.ok) throw new Error("scores_fetch_failed");
return res.json();
}
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
export async function apiFetchGrid(seed) {
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
}
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
export async function apiRevealCell(seed, x, y, team) {
return fetch("/api/cell/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, x, y, team }),
});
}
export async function apiLogin(username, password) {
return fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
}
export async function apiRegister(username, email, password, team) {
return fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
});
}
export async function apiGetMe(token) {
return fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
}

147
public/src/auth.js Normal file
View File

@@ -0,0 +1,147 @@
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
import { setCurrentTeam, updateTeamSegmented, refreshFromServer } from "./game.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const authOverlay = document.getElementById("authOverlay");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
// ── Auth state ────────────────────────────────────────────────────────────────
export let authToken = localStorage.getItem("authToken") ?? null;
export let currentUser = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function showError(el, msg) { el.textContent = msg; el.classList.remove("hidden"); }
function clearError(el) { el.textContent = ""; el.classList.add("hidden"); }
export function showAuthOverlay() { authOverlay.classList.remove("hidden"); }
export function hideAuthOverlay() { authOverlay.classList.add("hidden"); }
// ── Apply user (after login / register / session restore) ─────────────────────
export function applyUser(user, token) {
currentUser = user;
authToken = token;
localStorage.setItem("authToken", token);
setCurrentTeam(user.team);
userDisplayEl.textContent = `${user.username} [${user.team}]`;
logoutBtn.classList.remove("hidden");
updateTeamSegmented();
}
function logout() {
currentUser = null;
authToken = null;
localStorage.removeItem("authToken");
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
}
// ── Session restore ───────────────────────────────────────────────────────────
export async function tryRestoreSession() {
if (!authToken) return false;
try {
const res = await apiGetMe(authToken);
if (!res.ok) { localStorage.removeItem("authToken"); authToken = null; return false; }
const data = await res.json();
applyUser(data.user, data.token);
return true;
} catch {
return false;
}
}
// ── Tab switching ─────────────────────────────────────────────────────────────
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
clearError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearError(registerErrorEl);
});
// ── Login form ────────────────────────────────────────────────────────────────
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(loginErrorEl);
const username = loginUsernameEl.value.trim();
const password = loginPasswordEl.value;
if (!username || !password) return;
try {
const res = await apiLogin(username, password);
const data = await res.json();
if (!res.ok) {
const msgs = { invalid_credentials: "Invalid username or password.", missing_fields: "Please fill in all fields." };
showError(loginErrorEl, msgs[data.error] ?? "Login failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
showError(loginErrorEl, "Network error. Try again.");
}
});
// ── Register form ─────────────────────────────────────────────────────────────
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
try {
const res = await apiRegister(username, email, password, teamInput.value);
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short:"Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
};
showError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
showError(registerErrorEl, "Network error. Try again.");
}
});
// ── Logout ────────────────────────────────────────────────────────────────────
logoutBtn.addEventListener("click", logout);

416
public/src/game.js Normal file
View File

@@ -0,0 +1,416 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
// ── Constants ─────────────────────────────────────────────────────────────────
const GRID_W = 100;
const GRID_H = 100;
const OUTER_RADIUS = 50;
const INNER_RADIUS = 30;
const PLANET_CHANCE = 0.1;
const COLOR_OUTSIDE = "#000000";
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
// ── Shared game state ─────────────────────────────────────────────────────────
export const GAME_CONFIG = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
};
window.GAME_CONFIG = GAME_CONFIG;
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} */
export const cells = new Map();
export let currentTeam = "blue";
export function setCurrentTeam(t) { currentTeam = t; }
export let seedStr = "";
export let seedU32 = 0;
export function applySeed(str) {
seedStr = str;
seedU32 = fnv1a32(str || "fallback");
}
export const teamCooldownEndMs = { blue: 0, red: 0 };
let rafId = 0;
let lastPointerEvent = null;
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const details = document.getElementById("details");
const hint = document.getElementById("hint");
const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownCfgEl = document.getElementById("cooldownConfig");
const seedDisplayEl = document.getElementById("worldSeedDisplay");
const nextPeriodEl = document.getElementById("nextPeriodUtc");
const resetCountEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const teamCorner = document.getElementById("teamCorner");
const teamTrack = document.getElementById("teamSegmentedTrack");
const teamBlueBtn = document.getElementById("teamBlue");
const teamRedBtn = document.getElementById("teamRed");
// ── Cell helpers ──────────────────────────────────────────────────────────────
export function cellKey(x, y) { return `${x},${y}`; }
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;
}
function hasPlanetAt(x, y) {
const h = hash2u32(x, y, seedU32);
return h / 4294967296 < PLANET_CHANCE;
}
export function cellMeta(key) { return cells.get(key) ?? null; }
export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
// ── Config display ────────────────────────────────────────────────────────────
export function applyConfigPayload(data) {
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
nextPeriodEl.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
: "—";
updateResetCountdown();
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
}
export function updateResetCountdown() {
if (!GAME_CONFIG.seedPeriodEndsAtUtc) { resetCountEl.textContent = "--:--:--"; return; }
const diff = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime() - Date.now();
if (diff <= 0) { resetCountEl.textContent = "00:00:00"; return; }
const s = Math.floor(diff / 1000);
resetCountEl.textContent =
String(Math.floor(s / 3600)).padStart(2, "0") + ":" +
String(Math.floor((s % 3600) / 60)).padStart(2, "0") + ":" +
String(s % 60).padStart(2, "0");
}
// ── Scores ────────────────────────────────────────────────────────────────────
export async function fetchAndApplyScores() {
try {
const { blue, red } = await apiFetchScores();
scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
}
// ── Config fetch + apply ──────────────────────────────────────────────────────
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
export async function fetchConfig() {
const data = await apiFetchConfig(currentTeam);
const prevSeed = GAME_CONFIG.worldSeed;
applyConfigPayload(data);
applySeed(GAME_CONFIG.worldSeed);
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}
// ── Grid fetch ────────────────────────────────────────────────────────────────
export async function fetchGridForSeed(seed, depth = 0) {
if (depth > 4) throw new Error("grid_stale");
const res = await apiFetchGrid(seed);
if (res.status === 410) {
await fetchConfig();
return fetchGridForSeed(seedStr, depth + 1);
}
if (!res.ok) throw new Error("grid");
const data = await res.json();
cells.clear();
for (const row of data.cells || []) {
cells.set(cellKey(row.x, row.y), {
discoveredBy: row.discovered_by ?? row.discoveredBy,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
});
}
}
// ── Cooldown ──────────────────────────────────────────────────────────────────
export function cooldownActive() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
return Date.now() < teamCooldownEndMs[currentTeam];
}
function remainingSecs() {
return Math.max(0, (teamCooldownEndMs[currentTeam] - Date.now()) / 1000);
}
function tickCooldown() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) { countdownWrap.classList.add("hidden"); return; }
const left = remainingSecs();
if (left <= 0) {
countdownWrap.classList.add("hidden");
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
return;
}
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(Math.ceil(left));
refreshCursorFromLast();
rafId = requestAnimationFrame(tickCooldown);
}
export function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
refreshCursorFromLast();
return;
}
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(secs);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tickCooldown);
refreshCursorFromLast();
}
export function clearCooldown() {
teamCooldownEndMs.blue = 0;
teamCooldownEndMs.red = 0;
cancelAnimationFrame(rafId);
countdownWrap.classList.add("hidden");
}
// ── Draw ──────────────────────────────────────────────────────────────────────
export function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(0, 0, w, h);
const cw = w / GRID_W;
const ch = h / GRID_H;
for (let y = 0; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y)) { ctx.fillStyle = COLOR_OUTSIDE; ctx.fillRect(x * cw, y * ch, cw, ch); continue; }
const k = cellKey(x, y);
const meta = cellMeta(k);
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
else if (meta.discoveredBy !== currentTeam) ctx.fillStyle = COLOR_OPPONENT_GREY;
else ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
ctx.fillRect(x * cw, y * ch, cw, ch);
}
}
for (const [key, meta] of cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
const rng = mulberry32(h32);
ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`;
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
ctx.beginPath();
ctx.arc(xs * cw + cw / 2, ys * ch + ch / 2, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 1; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) {
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch);
}
for (let y = 1; y < GRID_H; y++) for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
}
ctx.stroke();
}
// ── Cursor ────────────────────────────────────────────────────────────────────
function pickCell(ev) {
const rect = canvas.getBoundingClientRect();
const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W);
const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H);
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
return { x, y };
}
function refreshCursor(ev) {
const cell = pickCell(ev);
if (!cell || !isExploitable(cell.x, cell.y) || isOpponentTile(cellKey(cell.x, cell.y))) {
canvas.style.cursor = "default";
} else {
canvas.style.cursor = "pointer";
}
}
function refreshCursorFromLast() {
if (lastPointerEvent) refreshCursor(lastPointerEvent);
}
// ── Selection display ─────────────────────────────────────────────────────────
function applyRevealPayload(cell) {
cells.set(cellKey(cell.x, cell.y), {
discoveredBy: cell.discoveredBy ?? currentTeam,
hasPlanet: Boolean(cell.hasPlanet),
planet: cell.planet ?? null,
});
details.classList.remove("details--hidden");
if (!cell.exploitable) {
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable.`;
return;
}
if (!cell.hasPlanet) {
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
}
function showLocalSelection(x, y) {
const k = cellKey(x, y);
details.classList.remove("details--hidden");
if (!isOwnTile(k)) return;
if (!isExploitable(x, y)) {
hint.textContent = `(${x},${y}) not exploitable.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable.`;
return;
}
const meta = cellMeta(k);
if (!meta?.hasPlanet) {
hint.textContent = `(${x},${y}) exploitable — empty.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
let planet = meta.planet;
if (!planet) {
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
planet = generatePlanet(mulberry32(h));
}
hint.textContent = `(${x},${y}) exploitable — planet.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
}
// ── Canvas click handler ──────────────────────────────────────────────────────
async function onCanvasClick(ev) {
const cell = pickCell(ev);
if (!cell || !isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) return;
if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
if (cooldownActive()) {
hint.textContent = "Cooldown active — reveal a tile your team already discovered to view stats.";
return;
}
try {
const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam);
if (res.status === 409) {
hint.textContent = "This tile was already discovered by the other team.";
await fetchGridForSeed(seedStr);
draw();
return;
}
if (res.status === 410) {
hint.textContent = "World period changed — syncing.";
await refreshFromServer();
return;
}
if (!res.ok) throw new Error("reveal");
applyRevealPayload(await res.json());
startCooldown();
draw();
fetchAndApplyScores();
} catch (e) {
if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; }
hint.textContent = "Could not save reveal — check server / database.";
}
}
// ── Team segmented control ────────────────────────────────────────────────────
export function updateTeamSegmented() {
teamTrack.dataset.active = currentTeam;
teamBlueBtn.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
// ── Full server refresh ───────────────────────────────────────────────────────
// Exported so auth.js / main.js can call it after login or on timer.
export async function refreshFromServer() {
try {
const seedChanged = await fetchConfig();
if (seedChanged) {
clearCooldown();
details.textContent = "Stats are hidden until you click a tile.";
details.classList.add("details--hidden");
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
}
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
draw();
refreshCursorFromLast();
} catch {
hint.textContent = "Could not sync with server. Is the API running?";
}
}
// ── Event listeners ───────────────────────────────────────────────────────────
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });

View File

@@ -1,697 +1,51 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import {
GAME_CONFIG,
seedStr,
updateTeamSegmented,
updateResetCountdown,
fetchConfig,
fetchGridForSeed,
fetchAndApplyScores,
refreshFromServer,
draw,
} from "./game.js";
const GRID_W = 100;
const GRID_H = 100;
const OUTER_RADIUS = 50;
const INNER_RADIUS = 30;
const PLANET_CHANCE = 0.1;
import {
tryRestoreSession,
showAuthOverlay,
hideAuthOverlay,
} from "./auth.js";
/** Outside donut: solid black, no grid. */
const COLOR_OUTSIDE = "#000000";
/** Exploitable, not yet discovered by anyone (classic ring). */
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
/** Current team's discovered tiles (blue). */
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
/** Current team's discovered tiles (red). */
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
/** Other team's tiles: empty look, not interactive. */
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const details = document.getElementById("details");
const hint = document.getElementById("hint");
const refreshBtn = document.getElementById("refreshBtn");
const teamCorner = document.getElementById("teamCorner");
const teamSegmentedTrack = document.getElementById("teamSegmentedTrack");
const teamBlue = document.getElementById("teamBlue");
const teamRed = document.getElementById("teamRed");
const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownConfigEl = document.getElementById("cooldownConfig");
const worldSeedDisplay = document.getElementById("worldSeedDisplay");
const nextPeriodUtc = document.getElementById("nextPeriodUtc");
const refreshCountdownEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
const hint = document.getElementById("hint");
const cooldownEl = document.getElementById("cooldownConfig");
// Auth elements
const authOverlay = document.getElementById("authOverlay");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, worldSeed: string, seedPeriodEndsAtUtc: string }} */
const GAME_CONFIG = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
};
window.GAME_CONFIG = GAME_CONFIG;
let seedStr = "";
let seedU32 = 0;
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object | null }>} */
const state = { cells: new Map() };
/** @type {"blue" | "red"} */
let currentTeam = "blue";
// Auth state
let authToken = localStorage.getItem("authToken") ?? null;
let currentUser = null; // { id, username, team, role }
let rafId = 0;
/** @type {MouseEvent | null} */
let lastPointerEvent = null;
// ── Polling ───────────────────────────────────────────────────────────────────
let configPollTimer = 0;
let scorePollTimer = 0;
/** @type {ReturnType<typeof setInterval> | null} */
let refreshTimer = null;
let scorePollTimer = 0;
let resetTimer = null;
const teamCooldownEndMs = { blue: 0, red: 0 };
// ── Helpers ──────────────────────────────────────────────────────────────────
function cellKey(x, y) { return `${x},${y}`; }
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) {
const h = hash2u32(x, y, seedU32);
return h / 4294967296 < PLANET_CHANCE;
}
function cellMeta(key) { return state.cells.get(key) ?? null; }
function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
// ── Auth ─────────────────────────────────────────────────────────────────────
function showAuthOverlay() {
authOverlay.classList.remove("hidden");
}
function hideAuthOverlay() {
authOverlay.classList.add("hidden");
}
function setAuthError(el, msg) {
el.textContent = msg;
el.classList.remove("hidden");
}
function clearAuthError(el) {
el.textContent = "";
el.classList.add("hidden");
}
function applyUser(user, token) {
currentUser = user;
authToken = token;
localStorage.setItem("authToken", token);
currentTeam = user.team;
userDisplayEl.textContent = `${user.username} [${user.team}]`;
logoutBtn.classList.remove("hidden");
updateTeamSegmented();
}
function logout() {
currentUser = null;
authToken = null;
localStorage.removeItem("authToken");
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
}
async function tryRestoreSession() {
if (!authToken) return false;
try {
const res = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) {
localStorage.removeItem("authToken");
authToken = null;
return false;
}
const data = await res.json();
applyUser(data.user, data.token);
return true;
} catch {
return false;
}
}
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
clearAuthError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearAuthError(registerErrorEl);
});
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(loginErrorEl);
const username = loginUsernameEl.value.trim();
const password = loginPasswordEl.value;
if (!username || !password) return;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
invalid_credentials: "Invalid username or password.",
missing_fields: "Please fill in all fields.",
};
setAuthError(loginErrorEl, msgs[data.error] ?? "Login failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(loginErrorEl, "Network error. Try again.");
}
});
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) {
setAuthError(registerErrorEl, "Please choose a team.");
return;
}
const team = teamInput.value;
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short: "Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
};
setAuthError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(registerErrorEl, "Network error. Try again.");
}
});
logoutBtn.addEventListener("click", logout);
// ── Cooldown ─────────────────────────────────────────────────────────────────
function cooldownBlocksNewReveal() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
return Date.now() < teamCooldownEndMs[currentTeam];
}
function refreshCursorFromLast() {
if (lastPointerEvent) refreshCursor(lastPointerEvent);
}
function remainingCooldownSeconds() {
const left = (teamCooldownEndMs[currentTeam] - Date.now()) / 1000;
return Math.max(0, left);
}
function tickCountdown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
countdownWrap.classList.add("hidden");
cancelAnimationFrame(rafId);
return;
}
const left = remainingCooldownSeconds();
if (left <= 0) {
countdownWrap.classList.add("hidden");
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
cancelAnimationFrame(rafId);
return;
}
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(Math.ceil(left));
refreshCursorFromLast();
rafId = requestAnimationFrame(tickCountdown);
}
function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
refreshCursorFromLast();
return;
}
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(secs);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tickCountdown);
refreshCursorFromLast();
}
// ── Config / seed ─────────────────────────────────────────────────────────────
function applyWorldSeedFromConfig() {
seedStr = GAME_CONFIG.worldSeed || "";
seedU32 = fnv1a32(seedStr || "fallback");
}
function applyDebugTeamVisibility() {
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
}
function applyConfigPayload(data) {
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
cooldownConfigEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
worldSeedDisplay.textContent = GAME_CONFIG.worldSeed || "—";
nextPeriodUtc.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
: "—";
updateRefreshCountdown();
applyDebugTeamVisibility();
}
function updateRefreshCountdown() {
if (!GAME_CONFIG.seedPeriodEndsAtUtc) {
refreshCountdownEl.textContent = "--:--:--";
return;
}
const endTime = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime();
const diffMs = endTime - Date.now();
if (diffMs <= 0) {
refreshCountdownEl.textContent = "00:00:00";
return;
}
const totalSeconds = Math.floor(diffMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
refreshCountdownEl.textContent =
String(hours).padStart(2, "0") + ":" +
String(minutes).padStart(2, "0") + ":" +
String(seconds).padStart(2, "0");
}
async function fetchConfig() {
const res = await fetch(`/api/config?team=${currentTeam}`);
if (!res.ok) throw new Error("config");
const data = await res.json();
const prevSeed = GAME_CONFIG.worldSeed;
applyConfigPayload(data);
applyWorldSeedFromConfig();
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}
// ── Scores ────────────────────────────────────────────────────────────────────
async function fetchScores() {
try {
const res = await fetch("/api/scores");
if (!res.ok) return;
const { blue, red } = await res.json();
scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
function scheduleConfigPoll() {
clearTimeout(configPollTimer);
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
configPollTimer = window.setTimeout(async () => {
try {
const changed = await fetchConfig();
if (changed) await refreshFromServer();
} catch { /* ignore */ }
scheduleConfigPoll();
}, ms);
}
function scheduleScorePoll() {
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchScores();
await fetchAndApplyScores();
scheduleScorePoll();
}, 5000);
}
// ── Grid ──────────────────────────────────────────────────────────────────────
async function fetchGridForSeed(seed, depth = 0) {
if (depth > 4) throw new Error("grid_stale");
const res = await fetch(`/api/grid/${encodeURIComponent(seed)}`);
if (res.status === 410) {
await fetchConfig();
applyWorldSeedFromConfig();
return fetchGridForSeed(GAME_CONFIG.worldSeed, depth + 1);
}
if (!res.ok) throw new Error("grid");
const data = await res.json();
state.cells.clear();
for (const row of data.cells || []) {
const k = cellKey(row.x, row.y);
const discoveredBy = row.discovered_by ?? row.discoveredBy;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
});
}
}
async function revealOnServer(x, y) {
const res = await fetch("/api/cell/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed: seedStr, x, y, team: currentTeam }),
});
if (res.status === 409) {
await res.json().catch(() => ({}));
const e = new Error("taken");
e.code = "TAKEN";
throw e;
}
if (res.status === 410) {
const body = await res.json().catch(() => ({}));
const e = new Error("seed_expired");
e.code = "SEED_EXPIRED";
e.worldSeed = body.worldSeed;
throw e;
}
if (!res.ok) throw new Error("reveal");
return res.json();
}
function resetUiForNewPeriod() {
teamCooldownEndMs.blue = 0;
teamCooldownEndMs.red = 0;
cancelAnimationFrame(rafId);
countdownWrap.classList.add("hidden");
details.textContent = "Stats are hidden until you click a tile.";
details.classList.add("details--hidden");
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
}
async function refreshFromServer() {
try {
const seedChanged = await fetchConfig();
applyWorldSeedFromConfig();
if (seedChanged) resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
await fetchScores();
draw();
refreshCursorFromLast();
} catch {
hint.textContent = "Could not sync with server. Is the API running?";
}
}
// ── Draw ──────────────────────────────────────────────────────────────────────
function fillColorForCell(k) {
const meta = cellMeta(k);
if (!meta) return COLOR_RING_IDLE;
if (meta.discoveredBy !== currentTeam) return COLOR_OPPONENT_GREY;
return currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
}
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(0, 0, w, h);
const cw = w / GRID_W;
const ch = h / GRID_H;
for (let y = 0; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y)) {
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(x * cw, y * ch, cw, ch);
continue;
}
const k = cellKey(x, y);
ctx.fillStyle = fillColorForCell(k);
ctx.fillRect(x * cw, y * ch, cw, ch);
}
}
for (const [key, meta] of state.cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
const rng = mulberry32(h32);
const hue = Math.floor(rng() * 360);
ctx.fillStyle = `hsl(${hue} 85% 65%)`;
const px = xs * cw + cw / 2;
const py = ys * ch + ch / 2;
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 1; x < GRID_W; x++) {
for (let y = 0; y < GRID_H; y++) {
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
const xx = x * cw;
ctx.moveTo(xx, y * ch);
ctx.lineTo(xx, (y + 1) * ch);
}
}
for (let y = 1; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
const yy = y * ch;
ctx.moveTo(x * cw, yy);
ctx.lineTo((x + 1) * cw, yy);
}
}
ctx.stroke();
}
// ── Cursor / click ────────────────────────────────────────────────────────────
function pickCellFromEvent(ev) {
const rect = canvas.getBoundingClientRect();
const sx = (ev.clientX - rect.left) / rect.width;
const sy = (ev.clientY - rect.top) / rect.height;
const x = Math.floor(sx * GRID_W);
const y = Math.floor(sy * GRID_H);
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
return { x, y };
}
function refreshCursor(ev) {
const cell = pickCellFromEvent(ev);
if (!cell || !isExploitable(cell.x, cell.y)) { canvas.style.cursor = "default"; return; }
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) { canvas.style.cursor = "default"; return; }
canvas.style.cursor = "pointer";
}
function applyRevealPayload(cell) {
const k = cellKey(cell.x, cell.y);
const discoveredBy = cell.discoveredBy ?? currentTeam;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(cell.hasPlanet),
planet: cell.planet ?? null,
});
details.classList.remove("details--hidden");
if (!cell.exploitable) {
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
if (!cell.hasPlanet) {
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
}
function showSelectionFromLocal(x, y) {
const k = cellKey(x, y);
details.classList.remove("details--hidden");
if (!isOwnTile(k)) return;
if (!isExploitable(x, y)) {
hint.textContent = `(${x},${y}) not exploitable.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
const meta = cellMeta(k);
if (!meta?.hasPlanet) {
hint.textContent = `(${x},${y}) exploitable — empty.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
let planet = meta.planet;
if (!planet) {
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
const rng = mulberry32(h);
planet = generatePlanet(rng);
}
hint.textContent = `(${x},${y}) exploitable — planet.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
}
async function onCanvasClick(ev) {
const cell = pickCellFromEvent(ev);
if (!cell) return;
if (!isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) return;
if (isOwnTile(key)) {
showSelectionFromLocal(cell.x, cell.y);
return;
}
if (cooldownBlocksNewReveal()) {
hint.textContent = "Cooldown — you cannot reveal new tiles yet. Click a tile your team already discovered to view its stats.";
return;
}
try {
const payload = await revealOnServer(cell.x, cell.y);
applyRevealPayload(payload);
startCooldown();
draw();
// Update scores after reveal
fetchScores();
} catch (e) {
if (e?.code === "TAKEN") {
hint.textContent = "This tile was already discovered by the other team.";
await fetchGridForSeed(seedStr);
draw();
return;
}
if (e?.code === "SEED_EXPIRED") {
hint.textContent = "World period changed — syncing.";
await refreshFromServer();
return;
}
hint.textContent = "Could not save reveal — check server / database.";
}
}
// ── Team segmented control ────────────────────────────────────────────────────
function updateTeamSegmented() {
teamSegmentedTrack.dataset.active = currentTeam;
teamBlue.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
teamRed.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
function setTeam(team) {
currentTeam = team;
updateTeamSegmented();
draw();
refreshCursorFromLast();
}
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
teamBlue.addEventListener("click", () => setTeam("blue"));
teamRed.addEventListener("click", () => setTeam("red"));
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Config poll ───────────────────────────────────────────────────────────────
function scheduleClientConfigPoll() {
clearTimeout(configPollTimer);
const ms = Math.max(5000, GAME_CONFIG.configReloadIntervalSeconds * 1000);
configPollTimer = window.setTimeout(async () => {
try {
const changed = await fetchConfig();
applyWorldSeedFromConfig();
if (changed) {
resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
draw();
refreshCursorFromLast();
}
} catch { /* ignore */ }
scheduleClientConfigPoll();
}, ms);
}, 5_000);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
@@ -699,50 +53,36 @@ function scheduleClientConfigPoll() {
async function boot() {
updateTeamSegmented();
// Try to restore session first
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
// Still load config/grid in the background for display
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
scheduleScorePoll();
return;
} else {
hideAuthOverlay();
}
hideAuthOverlay();
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
await fetchAndApplyScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
cooldownEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
if (resetTimer) clearInterval(resetTimer);
resetTimer = setInterval(updateResetCountdown, 1_000);
updateResetCountdown();
scheduleConfigPoll();
scheduleScorePoll();
}
// ── Global event listeners ────────────────────────────────────────────────────
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Start ─────────────────────────────────────────────────────────────────────
boot();

25
server/app.js Normal file
View 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
View 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
View 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
View 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
View 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,
};
}

View File

@@ -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
View 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
View 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
View 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;