From 84af90e81ea096b3bb43deebb1515eb1306598a4 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Sun, 29 Mar 2026 13:28:18 +0200 Subject: [PATCH] refacto: Keeping entrypoints clean and making files by purpose --- public/src/api.js | 51 +++ public/src/auth.js | 147 ++++++++ public/src/game.js | 416 +++++++++++++++++++++ public/src/main.js | 760 +++----------------------------------- server/app.js | 25 ++ server/db/gameDb.js | 128 +++++++ server/db/pools.js | 10 + server/db/usersDb.js | 45 +++ server/helpers/cell.js | 55 +++ server/index.js | 473 +----------------------- server/middleware/auth.js | 18 + server/routes/auth.js | 83 +++++ server/routes/game.js | 154 ++++++++ 13 files changed, 1198 insertions(+), 1167 deletions(-) create mode 100644 public/src/api.js create mode 100644 public/src/auth.js create mode 100644 public/src/game.js create mode 100644 server/app.js create mode 100644 server/db/gameDb.js create mode 100644 server/db/pools.js create mode 100644 server/db/usersDb.js create mode 100644 server/helpers/cell.js create mode 100644 server/middleware/auth.js create mode 100644 server/routes/auth.js create mode 100644 server/routes/game.js diff --git a/public/src/api.js b/public/src/api.js new file mode 100644 index 0000000..576600d --- /dev/null +++ b/public/src/api.js @@ -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}` }, + }); +} \ No newline at end of file diff --git a/public/src/auth.js b/public/src/auth.js new file mode 100644 index 0000000..3bca201 --- /dev/null +++ b/public/src/auth.js @@ -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 2–32 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); \ No newline at end of file diff --git a/public/src/game.js b/public/src/game.js new file mode 100644 index 0000000..2595c26 --- /dev/null +++ b/public/src/game.js @@ -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} */ +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(); }); \ No newline at end of file diff --git a/public/src/main.js b/public/src/main.js index 2147ee9..9ffd1fd 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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} */ -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 | 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 2–32 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(); \ No newline at end of file diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..bfd6d09 --- /dev/null +++ b/server/app.js @@ -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; \ No newline at end of file diff --git a/server/db/gameDb.js b/server/db/gameDb.js new file mode 100644 index 0000000..0050fae --- /dev/null +++ b/server/db/gameDb.js @@ -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; +} \ No newline at end of file diff --git a/server/db/pools.js b/server/db/pools.js new file mode 100644 index 0000000..7558feb --- /dev/null +++ b/server/db/pools.js @@ -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 }); \ No newline at end of file diff --git a/server/db/usersDb.js b/server/db/usersDb.js new file mode 100644 index 0000000..af22d3b --- /dev/null +++ b/server/db/usersDb.js @@ -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; +} \ No newline at end of file diff --git a/server/helpers/cell.js b/server/helpers/cell.js new file mode 100644 index 0000000..d7fa7b3 --- /dev/null +++ b/server/helpers/cell.js @@ -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, + }; +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index 1a6fc95..392de82 100644 --- a/server/index.js +++ b/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(); } diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..992fc17 --- /dev/null +++ b/server/middleware/auth.js @@ -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" }); + } +} \ No newline at end of file diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..d34025e --- /dev/null +++ b/server/routes/auth.js @@ -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; \ No newline at end of file diff --git a/server/routes/game.js b/server/routes/game.js new file mode 100644 index 0000000..261ce3f --- /dev/null +++ b/server/routes/game.js @@ -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; \ No newline at end of file