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