refacto: Keeping entrypoints clean and making files by purpose
This commit is contained in:
51
public/src/api.js
Normal file
51
public/src/api.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// ── Raw HTTP wrappers ─────────────────────────────────────────────────────────
|
||||||
|
// These functions only perform fetch() calls and return the raw Response or
|
||||||
|
// parsed JSON. No state mutations, no DOM access.
|
||||||
|
|
||||||
|
export async function apiFetchConfig(team) {
|
||||||
|
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`);
|
||||||
|
if (!res.ok) throw new Error("config_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchScores() {
|
||||||
|
const res = await fetch("/api/scores");
|
||||||
|
if (!res.ok) throw new Error("scores_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
|
||||||
|
export async function apiFetchGrid(seed) {
|
||||||
|
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
|
||||||
|
export async function apiRevealCell(seed, x, y, team) {
|
||||||
|
return fetch("/api/cell/reveal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ seed, x, y, team }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiLogin(username, password) {
|
||||||
|
return fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRegister(username, email, password, team) {
|
||||||
|
return fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, email, password, team }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetMe(token) {
|
||||||
|
return fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
147
public/src/auth.js
Normal file
147
public/src/auth.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
|
||||||
|
import { setCurrentTeam, updateTeamSegmented, refreshFromServer } from "./game.js";
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const authOverlay = document.getElementById("authOverlay");
|
||||||
|
const tabLogin = document.getElementById("tabLogin");
|
||||||
|
const tabRegister = document.getElementById("tabRegister");
|
||||||
|
const loginForm = document.getElementById("loginForm");
|
||||||
|
const registerForm = document.getElementById("registerForm");
|
||||||
|
const loginUsernameEl = document.getElementById("loginUsername");
|
||||||
|
const loginPasswordEl = document.getElementById("loginPassword");
|
||||||
|
const loginErrorEl = document.getElementById("loginError");
|
||||||
|
const regUsernameEl = document.getElementById("regUsername");
|
||||||
|
const regEmailEl = document.getElementById("regEmail");
|
||||||
|
const regPasswordEl = document.getElementById("regPassword");
|
||||||
|
const registerErrorEl = document.getElementById("registerError");
|
||||||
|
const userDisplayEl = document.getElementById("userDisplay");
|
||||||
|
const logoutBtn = document.getElementById("logoutBtn");
|
||||||
|
|
||||||
|
// ── Auth state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export let authToken = localStorage.getItem("authToken") ?? null;
|
||||||
|
export let currentUser = null;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showError(el, msg) { el.textContent = msg; el.classList.remove("hidden"); }
|
||||||
|
function clearError(el) { el.textContent = ""; el.classList.add("hidden"); }
|
||||||
|
|
||||||
|
export function showAuthOverlay() { authOverlay.classList.remove("hidden"); }
|
||||||
|
export function hideAuthOverlay() { authOverlay.classList.add("hidden"); }
|
||||||
|
|
||||||
|
// ── Apply user (after login / register / session restore) ─────────────────────
|
||||||
|
|
||||||
|
export function applyUser(user, token) {
|
||||||
|
currentUser = user;
|
||||||
|
authToken = token;
|
||||||
|
localStorage.setItem("authToken", token);
|
||||||
|
setCurrentTeam(user.team);
|
||||||
|
userDisplayEl.textContent = `${user.username} [${user.team}]`;
|
||||||
|
logoutBtn.classList.remove("hidden");
|
||||||
|
updateTeamSegmented();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
currentUser = null;
|
||||||
|
authToken = null;
|
||||||
|
localStorage.removeItem("authToken");
|
||||||
|
userDisplayEl.textContent = "—";
|
||||||
|
logoutBtn.classList.add("hidden");
|
||||||
|
showAuthOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session restore ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function tryRestoreSession() {
|
||||||
|
if (!authToken) return false;
|
||||||
|
try {
|
||||||
|
const res = await apiGetMe(authToken);
|
||||||
|
if (!res.ok) { localStorage.removeItem("authToken"); authToken = null; return false; }
|
||||||
|
const data = await res.json();
|
||||||
|
applyUser(data.user, data.token);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tabLogin.addEventListener("click", () => {
|
||||||
|
tabLogin.classList.add("authTab--active");
|
||||||
|
tabRegister.classList.remove("authTab--active");
|
||||||
|
loginForm.classList.remove("hidden");
|
||||||
|
registerForm.classList.add("hidden");
|
||||||
|
clearError(loginErrorEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
tabRegister.addEventListener("click", () => {
|
||||||
|
tabRegister.classList.add("authTab--active");
|
||||||
|
tabLogin.classList.remove("authTab--active");
|
||||||
|
registerForm.classList.remove("hidden");
|
||||||
|
loginForm.classList.add("hidden");
|
||||||
|
clearError(registerErrorEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Login form ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loginForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError(loginErrorEl);
|
||||||
|
const username = loginUsernameEl.value.trim();
|
||||||
|
const password = loginPasswordEl.value;
|
||||||
|
if (!username || !password) return;
|
||||||
|
try {
|
||||||
|
const res = await apiLogin(username, password);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
const msgs = { invalid_credentials: "Invalid username or password.", missing_fields: "Please fill in all fields." };
|
||||||
|
showError(loginErrorEl, msgs[data.error] ?? "Login failed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyUser(data.user, data.token);
|
||||||
|
hideAuthOverlay();
|
||||||
|
await refreshFromServer();
|
||||||
|
} catch {
|
||||||
|
showError(loginErrorEl, "Network error. Try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Register form ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError(registerErrorEl);
|
||||||
|
const username = regUsernameEl.value.trim();
|
||||||
|
const email = regEmailEl.value.trim();
|
||||||
|
const password = regPasswordEl.value;
|
||||||
|
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
|
||||||
|
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
|
||||||
|
try {
|
||||||
|
const res = await apiRegister(username, email, password, teamInput.value);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
const msgs = {
|
||||||
|
username_taken: "This username is already taken.",
|
||||||
|
email_taken: "This email is already registered.",
|
||||||
|
password_too_short:"Password must be at least 6 characters.",
|
||||||
|
invalid_username: "Username must be 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);
|
||||||
416
public/src/game.js
Normal file
416
public/src/game.js
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
||||||
|
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
||||||
|
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GRID_W = 100;
|
||||||
|
const GRID_H = 100;
|
||||||
|
const OUTER_RADIUS = 50;
|
||||||
|
const INNER_RADIUS = 30;
|
||||||
|
const PLANET_CHANCE = 0.1;
|
||||||
|
|
||||||
|
const COLOR_OUTSIDE = "#000000";
|
||||||
|
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
|
||||||
|
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
|
||||||
|
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
|
||||||
|
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
|
||||||
|
|
||||||
|
// ── Shared game state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GAME_CONFIG = {
|
||||||
|
clickCooldownSeconds: 5,
|
||||||
|
databaseWipeoutIntervalSeconds: 21600,
|
||||||
|
debugModeForTeams: true,
|
||||||
|
configReloadIntervalSeconds: 30,
|
||||||
|
worldSeed: "",
|
||||||
|
seedPeriodEndsAtUtc: "",
|
||||||
|
};
|
||||||
|
window.GAME_CONFIG = GAME_CONFIG;
|
||||||
|
|
||||||
|
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} */
|
||||||
|
export const cells = new Map();
|
||||||
|
|
||||||
|
export let currentTeam = "blue";
|
||||||
|
export function setCurrentTeam(t) { currentTeam = t; }
|
||||||
|
|
||||||
|
export let seedStr = "";
|
||||||
|
export let seedU32 = 0;
|
||||||
|
export function applySeed(str) {
|
||||||
|
seedStr = str;
|
||||||
|
seedU32 = fnv1a32(str || "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamCooldownEndMs = { blue: 0, red: 0 };
|
||||||
|
let rafId = 0;
|
||||||
|
let lastPointerEvent = null;
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: false });
|
||||||
|
const details = document.getElementById("details");
|
||||||
|
const hint = document.getElementById("hint");
|
||||||
|
const countdownEl = document.getElementById("countdown");
|
||||||
|
const countdownWrap = document.getElementById("countdownWrap");
|
||||||
|
const cooldownCfgEl = document.getElementById("cooldownConfig");
|
||||||
|
const seedDisplayEl = document.getElementById("worldSeedDisplay");
|
||||||
|
const nextPeriodEl = document.getElementById("nextPeriodUtc");
|
||||||
|
const resetCountEl = document.getElementById("refreshCountdown");
|
||||||
|
const scoreBlueEl = document.getElementById("scoreBlue");
|
||||||
|
const scoreRedEl = document.getElementById("scoreRed");
|
||||||
|
const teamCorner = document.getElementById("teamCorner");
|
||||||
|
const teamTrack = document.getElementById("teamSegmentedTrack");
|
||||||
|
const teamBlueBtn = document.getElementById("teamBlue");
|
||||||
|
const teamRedBtn = document.getElementById("teamRed");
|
||||||
|
|
||||||
|
// ── Cell helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cellKey(x, y) { return `${x},${y}`; }
|
||||||
|
|
||||||
|
function cellCenter(x, y) {
|
||||||
|
const cx = (GRID_W - 1) / 2;
|
||||||
|
const cy = (GRID_H - 1) / 2;
|
||||||
|
return { dx: x - cx, dy: y - cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExploitable(x, y) {
|
||||||
|
const { dx, dy } = cellCenter(x, y);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPlanetAt(x, y) {
|
||||||
|
const h = hash2u32(x, y, seedU32);
|
||||||
|
return h / 4294967296 < PLANET_CHANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellMeta(key) { return cells.get(key) ?? null; }
|
||||||
|
export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
|
||||||
|
export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
|
||||||
|
|
||||||
|
// ── Config display ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function applyConfigPayload(data) {
|
||||||
|
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
|
||||||
|
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
|
||||||
|
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
|
||||||
|
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
|
||||||
|
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
|
||||||
|
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
|
||||||
|
|
||||||
|
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
||||||
|
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
||||||
|
nextPeriodEl.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
|
||||||
|
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
updateResetCountdown();
|
||||||
|
|
||||||
|
if (GAME_CONFIG.debugModeForTeams) {
|
||||||
|
teamCorner.classList.remove("teamCorner--hidden");
|
||||||
|
} else {
|
||||||
|
teamCorner.classList.add("teamCorner--hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateResetCountdown() {
|
||||||
|
if (!GAME_CONFIG.seedPeriodEndsAtUtc) { resetCountEl.textContent = "--:--:--"; return; }
|
||||||
|
const diff = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime() - Date.now();
|
||||||
|
if (diff <= 0) { resetCountEl.textContent = "00:00:00"; return; }
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
resetCountEl.textContent =
|
||||||
|
String(Math.floor(s / 3600)).padStart(2, "0") + ":" +
|
||||||
|
String(Math.floor((s % 3600) / 60)).padStart(2, "0") + ":" +
|
||||||
|
String(s % 60).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchAndApplyScores() {
|
||||||
|
try {
|
||||||
|
const { blue, red } = await apiFetchScores();
|
||||||
|
scoreBlueEl.textContent = String(blue ?? 0);
|
||||||
|
scoreRedEl.textContent = String(red ?? 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config fetch + apply ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
||||||
|
export async function fetchConfig() {
|
||||||
|
const data = await apiFetchConfig(currentTeam);
|
||||||
|
const prevSeed = GAME_CONFIG.worldSeed;
|
||||||
|
applyConfigPayload(data);
|
||||||
|
applySeed(GAME_CONFIG.worldSeed);
|
||||||
|
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid fetch ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchGridForSeed(seed, depth = 0) {
|
||||||
|
if (depth > 4) throw new Error("grid_stale");
|
||||||
|
const res = await apiFetchGrid(seed);
|
||||||
|
if (res.status === 410) {
|
||||||
|
await fetchConfig();
|
||||||
|
return fetchGridForSeed(seedStr, depth + 1);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error("grid");
|
||||||
|
const data = await res.json();
|
||||||
|
cells.clear();
|
||||||
|
for (const row of data.cells || []) {
|
||||||
|
cells.set(cellKey(row.x, row.y), {
|
||||||
|
discoveredBy: row.discovered_by ?? row.discoveredBy,
|
||||||
|
hasPlanet: Boolean(row.has_planet),
|
||||||
|
planet: row.planet_json ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cooldown ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cooldownActive() {
|
||||||
|
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
|
||||||
|
return Date.now() < teamCooldownEndMs[currentTeam];
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingSecs() {
|
||||||
|
return Math.max(0, (teamCooldownEndMs[currentTeam] - Date.now()) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickCooldown() {
|
||||||
|
if (GAME_CONFIG.clickCooldownSeconds <= 0) { countdownWrap.classList.add("hidden"); return; }
|
||||||
|
const left = remainingSecs();
|
||||||
|
if (left <= 0) {
|
||||||
|
countdownWrap.classList.add("hidden");
|
||||||
|
countdownEl.textContent = "0";
|
||||||
|
teamCooldownEndMs[currentTeam] = 0;
|
||||||
|
refreshCursorFromLast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
countdownWrap.classList.remove("hidden");
|
||||||
|
countdownEl.textContent = String(Math.ceil(left));
|
||||||
|
refreshCursorFromLast();
|
||||||
|
rafId = requestAnimationFrame(tickCooldown);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCooldown() {
|
||||||
|
const secs = GAME_CONFIG.clickCooldownSeconds;
|
||||||
|
if (secs <= 0) {
|
||||||
|
teamCooldownEndMs[currentTeam] = 0;
|
||||||
|
countdownWrap.classList.add("hidden");
|
||||||
|
refreshCursorFromLast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
|
||||||
|
countdownWrap.classList.remove("hidden");
|
||||||
|
countdownEl.textContent = String(secs);
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(tickCooldown);
|
||||||
|
refreshCursorFromLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCooldown() {
|
||||||
|
teamCooldownEndMs.blue = 0;
|
||||||
|
teamCooldownEndMs.red = 0;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
countdownWrap.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function draw() {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
ctx.fillStyle = COLOR_OUTSIDE;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cw = w / GRID_W;
|
||||||
|
const ch = h / GRID_H;
|
||||||
|
|
||||||
|
for (let y = 0; y < GRID_H; y++) {
|
||||||
|
for (let x = 0; x < GRID_W; x++) {
|
||||||
|
if (!isExploitable(x, y)) { ctx.fillStyle = COLOR_OUTSIDE; ctx.fillRect(x * cw, y * ch, cw, ch); continue; }
|
||||||
|
const k = cellKey(x, y);
|
||||||
|
const meta = cellMeta(k);
|
||||||
|
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
|
||||||
|
else if (meta.discoveredBy !== currentTeam) ctx.fillStyle = COLOR_OPPONENT_GREY;
|
||||||
|
else ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
|
||||||
|
ctx.fillRect(x * cw, y * ch, cw, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, meta] of cells) {
|
||||||
|
if (meta.discoveredBy !== currentTeam) continue;
|
||||||
|
const [xs, ys] = key.split(",").map(Number);
|
||||||
|
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
|
||||||
|
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
|
||||||
|
const rng = mulberry32(h32);
|
||||||
|
ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`;
|
||||||
|
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(xs * cw + cw / 2, ys * ch + ch / 2, r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let x = 1; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) {
|
||||||
|
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
|
||||||
|
ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch);
|
||||||
|
}
|
||||||
|
for (let y = 1; y < GRID_H; y++) for (let x = 0; x < GRID_W; x++) {
|
||||||
|
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
|
||||||
|
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cursor ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pickCell(ev) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W);
|
||||||
|
const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H);
|
||||||
|
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCursor(ev) {
|
||||||
|
const cell = pickCell(ev);
|
||||||
|
if (!cell || !isExploitable(cell.x, cell.y) || isOpponentTile(cellKey(cell.x, cell.y))) {
|
||||||
|
canvas.style.cursor = "default";
|
||||||
|
} else {
|
||||||
|
canvas.style.cursor = "pointer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCursorFromLast() {
|
||||||
|
if (lastPointerEvent) refreshCursor(lastPointerEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection display ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyRevealPayload(cell) {
|
||||||
|
cells.set(cellKey(cell.x, cell.y), {
|
||||||
|
discoveredBy: cell.discoveredBy ?? currentTeam,
|
||||||
|
hasPlanet: Boolean(cell.hasPlanet),
|
||||||
|
planet: cell.planet ?? null,
|
||||||
|
});
|
||||||
|
details.classList.remove("details--hidden");
|
||||||
|
if (!cell.exploitable) {
|
||||||
|
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
|
||||||
|
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cell.hasPlanet) {
|
||||||
|
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
|
||||||
|
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
|
||||||
|
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLocalSelection(x, y) {
|
||||||
|
const k = cellKey(x, y);
|
||||||
|
details.classList.remove("details--hidden");
|
||||||
|
if (!isOwnTile(k)) return;
|
||||||
|
if (!isExploitable(x, y)) {
|
||||||
|
hint.textContent = `(${x},${y}) not exploitable.`;
|
||||||
|
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = cellMeta(k);
|
||||||
|
if (!meta?.hasPlanet) {
|
||||||
|
hint.textContent = `(${x},${y}) exploitable — empty.`;
|
||||||
|
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let planet = meta.planet;
|
||||||
|
if (!planet) {
|
||||||
|
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
|
||||||
|
planet = generatePlanet(mulberry32(h));
|
||||||
|
}
|
||||||
|
hint.textContent = `(${x},${y}) exploitable — planet.`;
|
||||||
|
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas click handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function onCanvasClick(ev) {
|
||||||
|
const cell = pickCell(ev);
|
||||||
|
if (!cell || !isExploitable(cell.x, cell.y)) return;
|
||||||
|
const key = cellKey(cell.x, cell.y);
|
||||||
|
|
||||||
|
if (isOpponentTile(key)) return;
|
||||||
|
|
||||||
|
if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
|
||||||
|
|
||||||
|
if (cooldownActive()) {
|
||||||
|
hint.textContent = "Cooldown active — reveal a tile your team already discovered to view stats.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam);
|
||||||
|
if (res.status === 409) {
|
||||||
|
hint.textContent = "This tile was already discovered by the other team.";
|
||||||
|
await fetchGridForSeed(seedStr);
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.status === 410) {
|
||||||
|
hint.textContent = "World period changed — syncing.";
|
||||||
|
await refreshFromServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error("reveal");
|
||||||
|
applyRevealPayload(await res.json());
|
||||||
|
startCooldown();
|
||||||
|
draw();
|
||||||
|
fetchAndApplyScores();
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; }
|
||||||
|
hint.textContent = "Could not save reveal — check server / database.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team segmented control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function updateTeamSegmented() {
|
||||||
|
teamTrack.dataset.active = currentTeam;
|
||||||
|
teamBlueBtn.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
|
||||||
|
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full server refresh ───────────────────────────────────────────────────────
|
||||||
|
// Exported so auth.js / main.js can call it after login or on timer.
|
||||||
|
|
||||||
|
export async function refreshFromServer() {
|
||||||
|
try {
|
||||||
|
const seedChanged = await fetchConfig();
|
||||||
|
if (seedChanged) {
|
||||||
|
clearCooldown();
|
||||||
|
details.textContent = "Stats are hidden until you click a tile.";
|
||||||
|
details.classList.add("details--hidden");
|
||||||
|
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
|
||||||
|
}
|
||||||
|
await fetchGridForSeed(seedStr);
|
||||||
|
await fetchAndApplyScores();
|
||||||
|
draw();
|
||||||
|
refreshCursorFromLast();
|
||||||
|
} catch {
|
||||||
|
hint.textContent = "Could not sync with server. Is the API running?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
||||||
|
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
||||||
|
canvas.addEventListener("click", onCanvasClick);
|
||||||
|
|
||||||
|
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||||
|
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||||
@@ -1,697 +1,51 @@
|
|||||||
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
import {
|
||||||
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
GAME_CONFIG,
|
||||||
|
seedStr,
|
||||||
|
updateTeamSegmented,
|
||||||
|
updateResetCountdown,
|
||||||
|
fetchConfig,
|
||||||
|
fetchGridForSeed,
|
||||||
|
fetchAndApplyScores,
|
||||||
|
refreshFromServer,
|
||||||
|
draw,
|
||||||
|
} from "./game.js";
|
||||||
|
|
||||||
const GRID_W = 100;
|
import {
|
||||||
const GRID_H = 100;
|
tryRestoreSession,
|
||||||
const OUTER_RADIUS = 50;
|
showAuthOverlay,
|
||||||
const INNER_RADIUS = 30;
|
hideAuthOverlay,
|
||||||
const PLANET_CHANCE = 0.1;
|
} from "./auth.js";
|
||||||
|
|
||||||
/** Outside donut: solid black, no grid. */
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
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 refreshBtn = document.getElementById("refreshBtn");
|
||||||
const teamCorner = document.getElementById("teamCorner");
|
const hint = document.getElementById("hint");
|
||||||
const teamSegmentedTrack = document.getElementById("teamSegmentedTrack");
|
const cooldownEl = document.getElementById("cooldownConfig");
|
||||||
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
|
// ── Polling ───────────────────────────────────────────────────────────────────
|
||||||
const authOverlay = document.getElementById("authOverlay");
|
|
||||||
const tabLogin = document.getElementById("tabLogin");
|
|
||||||
const tabRegister = document.getElementById("tabRegister");
|
|
||||||
const loginForm = document.getElementById("loginForm");
|
|
||||||
const registerForm = document.getElementById("registerForm");
|
|
||||||
const loginUsernameEl = document.getElementById("loginUsername");
|
|
||||||
const loginPasswordEl = document.getElementById("loginPassword");
|
|
||||||
const loginErrorEl = document.getElementById("loginError");
|
|
||||||
const regUsernameEl = document.getElementById("regUsername");
|
|
||||||
const regEmailEl = document.getElementById("regEmail");
|
|
||||||
const regPasswordEl = document.getElementById("regPassword");
|
|
||||||
const registerErrorEl = document.getElementById("registerError");
|
|
||||||
|
|
||||||
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, worldSeed: string, seedPeriodEndsAtUtc: string }} */
|
|
||||||
const GAME_CONFIG = {
|
|
||||||
clickCooldownSeconds: 5,
|
|
||||||
databaseWipeoutIntervalSeconds: 21600,
|
|
||||||
debugModeForTeams: true,
|
|
||||||
configReloadIntervalSeconds: 30,
|
|
||||||
worldSeed: "",
|
|
||||||
seedPeriodEndsAtUtc: "",
|
|
||||||
};
|
|
||||||
window.GAME_CONFIG = GAME_CONFIG;
|
|
||||||
|
|
||||||
let seedStr = "";
|
|
||||||
let seedU32 = 0;
|
|
||||||
|
|
||||||
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object | null }>} */
|
|
||||||
const state = { cells: new Map() };
|
|
||||||
|
|
||||||
/** @type {"blue" | "red"} */
|
|
||||||
let currentTeam = "blue";
|
|
||||||
|
|
||||||
// Auth state
|
|
||||||
let authToken = localStorage.getItem("authToken") ?? null;
|
|
||||||
let currentUser = null; // { id, username, team, role }
|
|
||||||
|
|
||||||
let rafId = 0;
|
|
||||||
/** @type {MouseEvent | null} */
|
|
||||||
let lastPointerEvent = null;
|
|
||||||
|
|
||||||
let configPollTimer = 0;
|
let configPollTimer = 0;
|
||||||
let scorePollTimer = 0;
|
let scorePollTimer = 0;
|
||||||
/** @type {ReturnType<typeof setInterval> | null} */
|
let resetTimer = null;
|
||||||
let refreshTimer = null;
|
|
||||||
|
|
||||||
const teamCooldownEndMs = { blue: 0, red: 0 };
|
function scheduleConfigPoll() {
|
||||||
|
clearTimeout(configPollTimer);
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
|
||||||
|
configPollTimer = window.setTimeout(async () => {
|
||||||
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 {
|
try {
|
||||||
const res = await fetch("/api/auth/me", {
|
const changed = await fetchConfig();
|
||||||
headers: { Authorization: `Bearer ${authToken}` },
|
if (changed) await refreshFromServer();
|
||||||
});
|
|
||||||
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 */ }
|
} catch { /* ignore */ }
|
||||||
|
scheduleConfigPoll();
|
||||||
|
}, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleScorePoll() {
|
function scheduleScorePoll() {
|
||||||
clearTimeout(scorePollTimer);
|
clearTimeout(scorePollTimer);
|
||||||
scorePollTimer = window.setTimeout(async () => {
|
scorePollTimer = window.setTimeout(async () => {
|
||||||
await fetchScores();
|
await fetchAndApplyScores();
|
||||||
scheduleScorePoll();
|
scheduleScorePoll();
|
||||||
}, 5000);
|
}, 5_000);
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 ──────────────────────────────────────────────────────────────────────
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -699,50 +53,36 @@ function scheduleClientConfigPoll() {
|
|||||||
async function boot() {
|
async function boot() {
|
||||||
updateTeamSegmented();
|
updateTeamSegmented();
|
||||||
|
|
||||||
// Try to restore session first
|
|
||||||
const restored = await tryRestoreSession();
|
const restored = await tryRestoreSession();
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
showAuthOverlay();
|
showAuthOverlay();
|
||||||
// Still load config/grid in the background for display
|
} else {
|
||||||
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();
|
hideAuthOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
applyWorldSeedFromConfig();
|
|
||||||
await fetchGridForSeed(seedStr);
|
await fetchGridForSeed(seedStr);
|
||||||
await fetchScores();
|
await fetchAndApplyScores();
|
||||||
} catch {
|
} catch {
|
||||||
hint.textContent = "API unavailable — start the Node server (docker-compose).";
|
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||||
cooldownConfigEl.textContent = "?";
|
cooldownEl.textContent = "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
refreshCursorFromLast();
|
|
||||||
|
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
if (resetTimer) clearInterval(resetTimer);
|
||||||
refreshTimer = setInterval(updateRefreshCountdown, 1000);
|
resetTimer = setInterval(updateResetCountdown, 1_000);
|
||||||
updateRefreshCountdown();
|
updateResetCountdown();
|
||||||
scheduleClientConfigPoll();
|
|
||||||
|
scheduleConfigPoll();
|
||||||
scheduleScorePoll();
|
scheduleScorePoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global event listeners ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", () => refreshFromServer());
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
25
server/app.js
Normal file
25
server/app.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import authRouter from "./routes/auth.js";
|
||||||
|
import gameRouter from "./routes/game.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = path.join(__dirname, "..", "public");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api", gameRouter);
|
||||||
|
|
||||||
|
// Catch-all: serve index.html for non-API routes (SPA fallback)
|
||||||
|
app.get("*", (req, res) => {
|
||||||
|
if (req.path.startsWith("/api")) {
|
||||||
|
return res.status(404).json({ error: "not_found" });
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
128
server/db/gameDb.js
Normal file
128
server/db/gameDb.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { pool } from "./pools.js";
|
||||||
|
import { loadConfigFile, getConfig } from "../configLoader.js";
|
||||||
|
import { computeWorldSeedState } from "../worldSeed.js";
|
||||||
|
|
||||||
|
let lastSeedSlot = null;
|
||||||
|
|
||||||
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function initGameSchema() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS grid_cells (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
x SMALLINT NOT NULL CHECK (x >= 0 AND x < 100),
|
||||||
|
y SMALLINT NOT NULL CHECK (y >= 0 AND y < 100),
|
||||||
|
exploitable BOOLEAN NOT NULL,
|
||||||
|
has_planet BOOLEAN NOT NULL,
|
||||||
|
planet_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (world_seed, x, y)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grid_cells_seed ON grid_cells (world_seed);
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS team_cooldowns (
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
|
last_reveal TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (world_seed, team)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
|
||||||
|
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
|
||||||
|
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET DEFAULT 'blue';
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check
|
||||||
|
CHECK (discovered_by IN ('blue', 'red'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── World-seed epoch ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ensureSeedEpoch() {
|
||||||
|
loadConfigFile();
|
||||||
|
const rot = getConfig().databaseWipeoutIntervalSeconds;
|
||||||
|
const { seedSlot, worldSeed } = computeWorldSeedState(rot);
|
||||||
|
if (lastSeedSlot === null) {
|
||||||
|
lastSeedSlot = seedSlot;
|
||||||
|
return worldSeed;
|
||||||
|
}
|
||||||
|
if (seedSlot !== lastSeedSlot) {
|
||||||
|
await pool.query("TRUNCATE grid_cells RESTART IDENTITY");
|
||||||
|
await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]);
|
||||||
|
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||||
|
lastSeedSlot = seedSlot;
|
||||||
|
}
|
||||||
|
return worldSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid cells ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getGridCells(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
||||||
|
FROM grid_cells WHERE world_seed = $1`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (world_seed, x, y) DO NOTHING
|
||||||
|
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
|
||||||
|
[seed, x, y, exploitable, hasPlanet, planetJson, team]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExistingCell(seed, x, y) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
||||||
|
FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`,
|
||||||
|
[seed, x, y]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team cooldowns ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTeamCooldown(worldSeed, team) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT last_reveal FROM team_cooldowns WHERE world_seed = $1 AND team = $2`,
|
||||||
|
[worldSeed, team]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertTeamCooldown(worldSeed, team) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO team_cooldowns (world_seed, team, last_reveal)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (world_seed, team) DO UPDATE SET last_reveal = NOW()`,
|
||||||
|
[worldSeed, team]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getScores(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT discovered_by, COUNT(*) AS cnt
|
||||||
|
FROM grid_cells WHERE world_seed = $1
|
||||||
|
GROUP BY discovered_by`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
10
server/db/pools.js
Normal file
10
server/db/pools.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid";
|
||||||
|
|
||||||
|
const USERS_DATABASE_URL =
|
||||||
|
process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users";
|
||||||
|
|
||||||
|
export const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||||
|
export const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL });
|
||||||
45
server/db/usersDb.js
Normal file
45
server/db/usersDb.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { usersPool } from "./pools.js";
|
||||||
|
|
||||||
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function initUsersSchema() {
|
||||||
|
await usersPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createUser(username, email, passwordHash, team) {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`INSERT INTO users (username, email, password_hash, team)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, username, email, team, role`,
|
||||||
|
[username, email, passwordHash, team]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username) {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id) {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
55
server/helpers/cell.js
Normal file
55
server/helpers/cell.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { fnv1a32, hash2u32, mulberry32 } from "../../public/src/rng.js";
|
||||||
|
import { generatePlanet, formatPlanet } from "../../public/src/planetGeneration.js";
|
||||||
|
|
||||||
|
const GRID_W = 100;
|
||||||
|
const GRID_H = 100;
|
||||||
|
const OUTER_RADIUS = 50;
|
||||||
|
const INNER_RADIUS = 30;
|
||||||
|
const PLANET_CHANCE = 0.1;
|
||||||
|
|
||||||
|
export function cellCenter(x, y) {
|
||||||
|
const cx = (GRID_W - 1) / 2;
|
||||||
|
const cy = (GRID_H - 1) / 2;
|
||||||
|
return { dx: x - cx, dy: y - cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExploitable(x, y) {
|
||||||
|
const { dx, dy } = cellCenter(x, y);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPlanetAt(x, y, seedU32) {
|
||||||
|
const h = hash2u32(x, y, seedU32);
|
||||||
|
return h / 4294967296 < PLANET_CHANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCell(seedStr, x, y) {
|
||||||
|
const seedU32 = fnv1a32(seedStr);
|
||||||
|
const exploitable = isExploitable(x, y);
|
||||||
|
if (!exploitable) {
|
||||||
|
return { x, y, exploitable: false, hasPlanet: false, planet: null, formatted: null };
|
||||||
|
}
|
||||||
|
const hp = hasPlanetAt(x, y, seedU32);
|
||||||
|
if (!hp) {
|
||||||
|
return { x, y, exploitable: true, hasPlanet: false, planet: null, formatted: null };
|
||||||
|
}
|
||||||
|
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
|
||||||
|
const rng = mulberry32(h);
|
||||||
|
const planet = generatePlanet(rng);
|
||||||
|
return {
|
||||||
|
x, y, exploitable: true, hasPlanet: true,
|
||||||
|
planet, formatted: formatPlanet(planet),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToCellPayload(row) {
|
||||||
|
return {
|
||||||
|
x: row.x,
|
||||||
|
y: row.y,
|
||||||
|
exploitable: row.exploitable,
|
||||||
|
hasPlanet: row.has_planet,
|
||||||
|
planet: row.planet_json ?? null,
|
||||||
|
discoveredBy: row.discovered_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
473
server/index.js
473
server/index.js
@@ -1,463 +1,16 @@
|
|||||||
import express from "express";
|
|
||||||
import pg from "pg";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import { fnv1a32, hash2u32, mulberry32 } from "../public/src/rng.js";
|
|
||||||
import { formatPlanet, generatePlanet } from "../public/src/planetGeneration.js";
|
|
||||||
import { loadConfigFile, getConfig } from "./configLoader.js";
|
import { 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;
|
// ── Config-file poll ──────────────────────────────────────────────────────────
|
||||||
const GRID_H = 100;
|
// Periodically re-reads game.settings.json and checks for a seed-epoch change.
|
||||||
const OUTER_RADIUS = 50;
|
|
||||||
const INNER_RADIUS = 30;
|
|
||||||
const PLANET_CHANCE = 0.1;
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me";
|
|
||||||
|
|
||||||
function cellCenter(x, y) {
|
|
||||||
const cx = (GRID_W - 1) / 2;
|
|
||||||
const cy = (GRID_H - 1) / 2;
|
|
||||||
return { dx: x - cx, dy: y - cy };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExploitable(x, y) {
|
|
||||||
const { dx, dy } = cellCenter(x, y);
|
|
||||||
const r = Math.hypot(dx, dy);
|
|
||||||
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPlanetAt(x, y, seedU32) {
|
|
||||||
const h = hash2u32(x, y, seedU32);
|
|
||||||
return h / 4294967296 < PLANET_CHANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeCell(seedStr, x, y) {
|
|
||||||
const seedU32 = fnv1a32(seedStr);
|
|
||||||
const exploitable = isExploitable(x, y);
|
|
||||||
if (!exploitable) {
|
|
||||||
return { x, y, exploitable: false, hasPlanet: false, planet: null, formatted: null };
|
|
||||||
}
|
|
||||||
const hp = hasPlanetAt(x, y, seedU32);
|
|
||||||
if (!hp) {
|
|
||||||
return { x, y, exploitable: true, hasPlanet: false, planet: null, formatted: null };
|
|
||||||
}
|
|
||||||
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
|
|
||||||
const rng = mulberry32(h);
|
|
||||||
const planet = generatePlanet(rng);
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
exploitable: true,
|
|
||||||
hasPlanet: true,
|
|
||||||
planet,
|
|
||||||
formatted: formatPlanet(planet),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATABASE_URL =
|
|
||||||
process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid";
|
|
||||||
|
|
||||||
const USERS_DATABASE_URL =
|
|
||||||
process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users";
|
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
||||||
const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL });
|
|
||||||
|
|
||||||
let lastSeedSlot = null;
|
|
||||||
|
|
||||||
async function initSchema() {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS grid_cells (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
world_seed TEXT NOT NULL,
|
|
||||||
x SMALLINT NOT NULL CHECK (x >= 0 AND x < 100),
|
|
||||||
y SMALLINT NOT NULL CHECK (y >= 0 AND y < 100),
|
|
||||||
exploitable BOOLEAN NOT NULL,
|
|
||||||
has_planet BOOLEAN NOT NULL,
|
|
||||||
planet_json JSONB,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (world_seed, x, y)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_grid_cells_seed ON grid_cells (world_seed);
|
|
||||||
`);
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS team_cooldowns (
|
|
||||||
world_seed TEXT NOT NULL,
|
|
||||||
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
|
||||||
last_reveal TIMESTAMPTZ,
|
|
||||||
PRIMARY KEY (world_seed, team)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
|
|
||||||
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
|
|
||||||
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET DEFAULT 'blue';
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check
|
|
||||||
CHECK (discovered_by IN ('blue', 'red'));
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initUsersSchema() {
|
|
||||||
await usersPool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToCellPayload(row) {
|
|
||||||
return {
|
|
||||||
x: row.x,
|
|
||||||
y: row.y,
|
|
||||||
exploitable: row.exploitable,
|
|
||||||
hasPlanet: row.has_planet,
|
|
||||||
planet: row.planet_json ?? null,
|
|
||||||
discoveredBy: row.discovered_by,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSeedEpoch() {
|
|
||||||
loadConfigFile();
|
|
||||||
const rot = getConfig().databaseWipeoutIntervalSeconds;
|
|
||||||
const { seedSlot, worldSeed } = computeWorldSeedState(rot);
|
|
||||||
if (lastSeedSlot === null) {
|
|
||||||
lastSeedSlot = seedSlot;
|
|
||||||
return worldSeed;
|
|
||||||
}
|
|
||||||
if (seedSlot !== lastSeedSlot) {
|
|
||||||
await pool.query("TRUNCATE grid_cells RESTART IDENTITY");
|
|
||||||
await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]);
|
|
||||||
console.log(`[world] Period slot ${lastSeedSlot} -> ${seedSlot}; grid wiped and cooldowns cleared for old seeds.`);
|
|
||||||
lastSeedSlot = seedSlot;
|
|
||||||
}
|
|
||||||
return worldSeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
||||||
return res.status(401).json({ error: "unauthorized" });
|
|
||||||
}
|
|
||||||
const token = authHeader.slice(7);
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, JWT_SECRET);
|
|
||||||
req.user = payload;
|
|
||||||
next();
|
|
||||||
} catch {
|
|
||||||
return res.status(401).json({ error: "invalid_token" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const publicDir = path.join(__dirname, "..", "public");
|
|
||||||
app.use(express.static(publicDir));
|
|
||||||
|
|
||||||
// ── Auth endpoints ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.post("/api/auth/register", async (req, res) => {
|
|
||||||
const { username, email, password, team } = req.body ?? {};
|
|
||||||
if (!username || !email || !password || !team) {
|
|
||||||
return res.status(400).json({ error: "missing_fields" });
|
|
||||||
}
|
|
||||||
if (team !== "blue" && team !== "red") {
|
|
||||||
return res.status(400).json({ error: "invalid_team" });
|
|
||||||
}
|
|
||||||
if (typeof username !== "string" || username.length < 2 || username.length > 32) {
|
|
||||||
return res.status(400).json({ error: "invalid_username" });
|
|
||||||
}
|
|
||||||
if (typeof password !== "string" || password.length < 6) {
|
|
||||||
return res.status(400).json({ error: "password_too_short" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
|
||||||
const result = await usersPool.query(
|
|
||||||
`INSERT INTO users (username, email, password_hash, team)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, username, email, team, role`,
|
|
||||||
[username.trim(), email.trim().toLowerCase(), passwordHash, team]
|
|
||||||
);
|
|
||||||
const user = result.rows[0];
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ userId: user.id, username: user.username, team: user.team, role: user.role },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: "7d" }
|
|
||||||
);
|
|
||||||
return res.status(201).json({ token, user: { id: user.id, username: user.username, team: user.team, role: user.role } });
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === "23505") {
|
|
||||||
if (e.constraint?.includes("email")) {
|
|
||||||
return res.status(409).json({ error: "email_taken" });
|
|
||||||
}
|
|
||||||
return res.status(409).json({ error: "username_taken" });
|
|
||||||
}
|
|
||||||
console.error(e);
|
|
||||||
return res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/auth/login", async (req, res) => {
|
|
||||||
const { username, password } = req.body ?? {};
|
|
||||||
if (!username || !password) {
|
|
||||||
return res.status(400).json({ error: "missing_fields" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await usersPool.query(
|
|
||||||
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
|
|
||||||
[username.trim()]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(401).json({ error: "invalid_credentials" });
|
|
||||||
}
|
|
||||||
const user = result.rows[0];
|
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
|
||||||
if (!valid) {
|
|
||||||
return res.status(401).json({ error: "invalid_credentials" });
|
|
||||||
}
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ userId: user.id, username: user.username, team: user.team, role: user.role },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: "7d" }
|
|
||||||
);
|
|
||||||
return res.json({ token, user: { id: user.id, username: user.username, team: user.team, role: user.role } });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/auth/me", authMiddleware, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = await usersPool.query(
|
|
||||||
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
|
|
||||||
[req.user.userId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: "user_not_found" });
|
|
||||||
}
|
|
||||||
const user = result.rows[0];
|
|
||||||
// Re-issue token with fresh team/role in case admin changed it
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ userId: user.id, username: user.username, team: user.team, role: user.role },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: "7d" }
|
|
||||||
);
|
|
||||||
return res.json({ token, user });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Scores endpoint ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get("/api/scores", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const worldSeed = await ensureSeedEpoch();
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT discovered_by, COUNT(*) AS cnt
|
|
||||||
FROM grid_cells
|
|
||||||
WHERE world_seed = $1
|
|
||||||
GROUP BY discovered_by`,
|
|
||||||
[worldSeed]
|
|
||||||
);
|
|
||||||
const scores = { blue: 0, red: 0 };
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.discovered_by === "blue") scores.blue = Number(row.cnt);
|
|
||||||
if (row.discovered_by === "red") scores.red = Number(row.cnt);
|
|
||||||
}
|
|
||||||
res.json(scores);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Existing game endpoints ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get("/api/config", async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const worldSeed = await ensureSeedEpoch();
|
|
||||||
const cfg = getConfig();
|
|
||||||
const rot = cfg.databaseWipeoutIntervalSeconds;
|
|
||||||
const ws = computeWorldSeedState(rot);
|
|
||||||
|
|
||||||
const team = typeof _req.query.team === 'string' ? _req.query.team : undefined;
|
|
||||||
let teamCooldownRemaining = 0;
|
|
||||||
if (team && (team === "blue" || team === "red")) {
|
|
||||||
const cooldownResult = await pool.query(
|
|
||||||
`SELECT last_reveal FROM team_cooldowns
|
|
||||||
WHERE world_seed = $1 AND team = $2`,
|
|
||||||
[worldSeed, team]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cooldownResult.rows.length > 0) {
|
|
||||||
const lastReveal = new Date(cooldownResult.rows[0].last_reveal);
|
|
||||||
const secondsSinceLastReveal = (Date.now() - lastReveal.getTime()) / 1000;
|
|
||||||
if (secondsSinceLastReveal < cfg.clickCooldownSeconds) {
|
|
||||||
teamCooldownRemaining = Math.ceil(cfg.clickCooldownSeconds - secondsSinceLastReveal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
clickCooldownSeconds: cfg.clickCooldownSeconds,
|
|
||||||
databaseWipeoutIntervalSeconds: rot,
|
|
||||||
debugModeForTeams: cfg.debugModeForTeams,
|
|
||||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
|
||||||
worldSeed: ws.worldSeed,
|
|
||||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
|
||||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
|
||||||
teamCooldownRemaining: teamCooldownRemaining
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: "config_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/grid/:seed", async (req, res) => {
|
|
||||||
const seed = decodeURIComponent(req.params.seed || "");
|
|
||||||
try {
|
|
||||||
const worldSeed = await ensureSeedEpoch();
|
|
||||||
if (seed !== worldSeed) {
|
|
||||||
return res.status(410).json({
|
|
||||||
error: "seed_expired",
|
|
||||||
worldSeed,
|
|
||||||
seedPeriodEndsAtUtc: computeWorldSeedState(getConfig().databaseWipeoutIntervalSeconds).seedPeriodEndsAtUtc,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
|
||||||
FROM grid_cells WHERE world_seed = $1`,
|
|
||||||
[seed]
|
|
||||||
);
|
|
||||||
res.json({ seed, cells: rows });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/cell/reveal", async (req, res) => {
|
|
||||||
const seed = String(req.body?.seed ?? "");
|
|
||||||
const team = String(req.body?.team ?? "");
|
|
||||||
const x = Number(req.body?.x);
|
|
||||||
const y = Number(req.body?.y);
|
|
||||||
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
|
||||||
return res.status(400).json({ error: "invalid_body" });
|
|
||||||
}
|
|
||||||
if (team !== "blue" && team !== "red") {
|
|
||||||
return res.status(400).json({ error: "invalid_team" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const worldSeed = await ensureSeedEpoch();
|
|
||||||
if (seed !== worldSeed) {
|
|
||||||
return res.status(410).json({
|
|
||||||
error: "seed_expired",
|
|
||||||
worldSeed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = getConfig();
|
|
||||||
const cooldownSeconds = cfg.clickCooldownSeconds;
|
|
||||||
if (cooldownSeconds > 0) {
|
|
||||||
const cooldownResult = await pool.query(
|
|
||||||
`SELECT last_reveal FROM team_cooldowns
|
|
||||||
WHERE world_seed = $1 AND team = $2`,
|
|
||||||
[worldSeed, team]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cooldownResult.rows.length > 0) {
|
|
||||||
const lastReveal = new Date(cooldownResult.rows[0].last_reveal);
|
|
||||||
const secondsSinceLastReveal = (Date.now() - lastReveal.getTime()) / 1000;
|
|
||||||
if (secondsSinceLastReveal < cooldownSeconds) {
|
|
||||||
const remaining = Math.ceil(cooldownSeconds - secondsSinceLastReveal);
|
|
||||||
return res.status(429).json({
|
|
||||||
error: "cooldown_active",
|
|
||||||
team,
|
|
||||||
remainingSeconds: remaining,
|
|
||||||
cooldownSeconds: cooldownSeconds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cell = computeCell(seed, x, y);
|
|
||||||
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
|
|
||||||
const ins = await pool.query(
|
|
||||||
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (world_seed, x, y)
|
|
||||||
DO NOTHING
|
|
||||||
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
|
|
||||||
[seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team]
|
|
||||||
);
|
|
||||||
if (ins.rows.length > 0) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO team_cooldowns (world_seed, team, last_reveal)
|
|
||||||
VALUES ($1, $2, NOW())
|
|
||||||
ON CONFLICT (world_seed, team)
|
|
||||||
DO UPDATE SET last_reveal = NOW()`,
|
|
||||||
[worldSeed, team]
|
|
||||||
);
|
|
||||||
return res.json(rowToCellPayload(ins.rows[0]));
|
|
||||||
}
|
|
||||||
const existing = await pool.query(
|
|
||||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
|
||||||
FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`,
|
|
||||||
[seed, x, y]
|
|
||||||
);
|
|
||||||
const row = existing.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
return res.status(500).json({ error: "insert_race" });
|
|
||||||
}
|
|
||||||
if (row.discovered_by !== team) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "taken_by_other_team",
|
|
||||||
discoveredBy: row.discovered_by,
|
|
||||||
cell: rowToCellPayload(row),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return res.json(rowToCellPayload(row));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: "database_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("*", (req, res) => {
|
|
||||||
if (req.path.startsWith("/api")) {
|
|
||||||
return res.status(404).json({ error: "not_found" });
|
|
||||||
}
|
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 8080);
|
|
||||||
|
|
||||||
function scheduleConfigPoll() {
|
function scheduleConfigPoll() {
|
||||||
loadConfigFile();
|
loadConfigFile();
|
||||||
const ms = Math.max(5000, getConfig().configReloadIntervalSeconds * 1000);
|
const ms = Math.max(5_000, getConfig().configReloadIntervalSeconds * 1_000);
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
loadConfigFile();
|
loadConfigFile();
|
||||||
@@ -469,15 +22,21 @@ function scheduleConfigPoll() {
|
|||||||
}, ms);
|
}, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
loadConfigFile();
|
loadConfigFile();
|
||||||
await initSchema();
|
await initGameSchema();
|
||||||
await initUsersSchema();
|
await initUsersSchema();
|
||||||
await ensureSeedEpoch();
|
await ensureSeedEpoch();
|
||||||
app.listen(port, () => {
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
const cfg = getConfig();
|
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();
|
scheduleConfigPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
server/middleware/auth.js
Normal file
18
server/middleware/auth.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me";
|
||||||
|
|
||||||
|
export function authMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
req.user = payload;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: "invalid_token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
83
server/routes/auth.js
Normal file
83
server/routes/auth.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import express from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { createUser, getUserByUsername, getUserById } from "../db/usersDb.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function issueToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId: user.id, username: user.username, team: user.team, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: "7d" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/register
|
||||||
|
router.post("/register", async (req, res) => {
|
||||||
|
const { username, email, password, team } = req.body ?? {};
|
||||||
|
if (!username || !email || !password || !team) {
|
||||||
|
return res.status(400).json({ error: "missing_fields" });
|
||||||
|
}
|
||||||
|
if (team !== "blue" && team !== "red") {
|
||||||
|
return res.status(400).json({ error: "invalid_team" });
|
||||||
|
}
|
||||||
|
if (typeof username !== "string" || username.length < 2 || username.length > 32) {
|
||||||
|
return res.status(400).json({ error: "invalid_username" });
|
||||||
|
}
|
||||||
|
if (typeof password !== "string" || password.length < 6) {
|
||||||
|
return res.status(400).json({ error: "password_too_short" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const user = await createUser(username.trim(), email.trim().toLowerCase(), passwordHash, team);
|
||||||
|
const token = issueToken(user);
|
||||||
|
return res.status(201).json({
|
||||||
|
token,
|
||||||
|
user: { id: user.id, username: user.username, team: user.team, role: user.role },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === "23505") {
|
||||||
|
if (e.constraint?.includes("email")) return res.status(409).json({ error: "email_taken" });
|
||||||
|
return res.status(409).json({ error: "username_taken" });
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
return res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "missing_fields" });
|
||||||
|
try {
|
||||||
|
const user = await getUserByUsername(username.trim());
|
||||||
|
if (!user) return res.status(401).json({ error: "invalid_credentials" });
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) return res.status(401).json({ error: "invalid_credentials" });
|
||||||
|
const token = issueToken(user);
|
||||||
|
return res.json({
|
||||||
|
token,
|
||||||
|
user: { id: user.id, username: user.username, team: user.team, role: user.role },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get("/me", authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await getUserById(req.user.userId);
|
||||||
|
if (!user) return res.status(404).json({ error: "user_not_found" });
|
||||||
|
const token = issueToken(user);
|
||||||
|
return res.json({ token, user });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
154
server/routes/game.js
Normal file
154
server/routes/game.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getConfig } from "../configLoader.js";
|
||||||
|
import { computeWorldSeedState } from "../worldSeed.js";
|
||||||
|
import {
|
||||||
|
ensureSeedEpoch,
|
||||||
|
getGridCells,
|
||||||
|
insertCell,
|
||||||
|
getExistingCell,
|
||||||
|
getTeamCooldown,
|
||||||
|
upsertTeamCooldown,
|
||||||
|
getScores,
|
||||||
|
} from "../db/gameDb.js";
|
||||||
|
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/config
|
||||||
|
router.get("/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const cfg = getConfig();
|
||||||
|
const rot = cfg.databaseWipeoutIntervalSeconds;
|
||||||
|
const ws = computeWorldSeedState(rot);
|
||||||
|
|
||||||
|
let teamCooldownRemaining = 0;
|
||||||
|
const team = typeof req.query.team === "string" ? req.query.team : undefined;
|
||||||
|
if (team === "blue" || team === "red") {
|
||||||
|
const row = await getTeamCooldown(worldSeed, team);
|
||||||
|
if (row) {
|
||||||
|
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
|
||||||
|
if (secondsSince < cfg.clickCooldownSeconds) {
|
||||||
|
teamCooldownRemaining = Math.ceil(cfg.clickCooldownSeconds - secondsSince);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
clickCooldownSeconds: cfg.clickCooldownSeconds,
|
||||||
|
databaseWipeoutIntervalSeconds: rot,
|
||||||
|
debugModeForTeams: cfg.debugModeForTeams,
|
||||||
|
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||||
|
worldSeed: ws.worldSeed,
|
||||||
|
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||||
|
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||||
|
teamCooldownRemaining,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "config_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/grid/:seed
|
||||||
|
router.get("/grid/:seed", async (req, res) => {
|
||||||
|
const seed = decodeURIComponent(req.params.seed || "");
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (seed !== worldSeed) {
|
||||||
|
return res.status(410).json({
|
||||||
|
error: "seed_expired",
|
||||||
|
worldSeed,
|
||||||
|
seedPeriodEndsAtUtc: computeWorldSeedState(
|
||||||
|
getConfig().databaseWipeoutIntervalSeconds
|
||||||
|
).seedPeriodEndsAtUtc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = await getGridCells(seed);
|
||||||
|
res.json({ seed, cells: rows });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cell/reveal
|
||||||
|
router.post("/cell/reveal", async (req, res) => {
|
||||||
|
const seed = String(req.body?.seed ?? "");
|
||||||
|
const team = String(req.body?.team ?? "");
|
||||||
|
const x = Number(req.body?.x);
|
||||||
|
const y = Number(req.body?.y);
|
||||||
|
|
||||||
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
||||||
|
return res.status(400).json({ error: "invalid_body" });
|
||||||
|
}
|
||||||
|
if (team !== "blue" && team !== "red") {
|
||||||
|
return res.status(400).json({ error: "invalid_team" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (seed !== worldSeed) {
|
||||||
|
return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = getConfig();
|
||||||
|
if (cfg.clickCooldownSeconds > 0) {
|
||||||
|
const cooldownRow = await getTeamCooldown(worldSeed, team);
|
||||||
|
if (cooldownRow) {
|
||||||
|
const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000;
|
||||||
|
if (secondsSince < cfg.clickCooldownSeconds) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "cooldown_active",
|
||||||
|
team,
|
||||||
|
remainingSeconds: Math.ceil(cfg.clickCooldownSeconds - secondsSince),
|
||||||
|
cooldownSeconds: cfg.clickCooldownSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = computeCell(seed, x, y);
|
||||||
|
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
|
||||||
|
const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team);
|
||||||
|
|
||||||
|
if (inserted) {
|
||||||
|
await upsertTeamCooldown(worldSeed, team);
|
||||||
|
return res.json(rowToCellPayload(inserted));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getExistingCell(seed, x, y);
|
||||||
|
if (!existing) return res.status(500).json({ error: "insert_race" });
|
||||||
|
|
||||||
|
if (existing.discovered_by !== team) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "taken_by_other_team",
|
||||||
|
discoveredBy: existing.discovered_by,
|
||||||
|
cell: rowToCellPayload(existing),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json(rowToCellPayload(existing));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scores
|
||||||
|
router.get("/scores", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const rows = await getScores(worldSeed);
|
||||||
|
const scores = { blue: 0, red: 0 };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.discovered_by === "blue") scores.blue = Number(row.cnt);
|
||||||
|
if (row.discovered_by === "red") scores.red = Number(row.cnt);
|
||||||
|
}
|
||||||
|
res.json(scores);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user