import { fnv1a32, hash2u32, mulberry32 } from "./rng.js"; import { formatPlanet, generatePlanet } from "./planetGeneration.js"; const GRID_W = 100; const GRID_H = 100; const OUTER_RADIUS = 50; const INNER_RADIUS = 30; const PLANET_CHANCE = 0.1; /** 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)"; 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"); // 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; let configPollTimer = 0; let scorePollTimer = 0; /** @type {ReturnType | null} */ let refreshTimer = 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 scheduleScorePoll() { clearTimeout(scorePollTimer); scorePollTimer = window.setTimeout(async () => { await fetchScores(); 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); } // ── Boot ────────────────────────────────────────────────────────────────────── 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; } hideAuthOverlay(); 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(); } boot();