Private
Public Access
1
0

Adding repo after 2 days of vibecoding

This commit is contained in:
gauvainboiche
2026-03-29 11:16:46 +02:00
commit 4eac0f4415
948 changed files with 99537 additions and 0 deletions

748
public/src/main.js Normal file
View File

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

View File

@@ -0,0 +1,64 @@
export const elements = {
common: "Matières premières",
petrol: "Hydrocarbures",
food: "Nourriture",
medic: "Médicaments",
science: "Science",
industry: "Industrie",
money: "Finance",
goods: "Biens",
};
export const resources = {
common: {
rock: "Roches communes",
wood: "Bois communs",
mineral: "Minérais communs",
stones: "Gemmes communes",
liquid: "Eau salée",
oil: "Fioul brut",
gas: "Gaz communs",
grain: "Céréales",
livestock: "Bétail commun",
fish: "Poissons commun",
plant: "Plantes communes",
goods: "Biens de consommation",
animals: "Animaux domestiques",
science: "Sites archéologiques",
factory: "Usines standards",
acid: "Acides pauvres",
},
rare: {
rock: "Roches rares",
wood: "Bois renforcés",
mineral: "Minérais rares",
stones: "Gemmes rares",
liquid: "Eau douce",
oil: "Fioul raffiné",
gas: "Gaz nobles",
grain: "Fruits",
livestock: "Bétail raffiné",
fish: "Poissons raffinés",
plant: "Plantes rares",
goods: "Biens de luxe",
animals: "Animaux exotiques",
science: "Artéfacts anciens",
factory: "Usines planétaires",
acid: "Acides riches",
},
};
export const population = {
humanoids: {
humans: "Humains",
near: "Presque'humains",
aliens: "Aliens",
},
creatures: {
casual: "Faune sauvage",
danger: "Faune hostile",
apex: "Superprédateurs",
robots: "Androïdes",
},
};

View File

@@ -0,0 +1,76 @@
import * as stat from "./planetStat.js";
const getRandomInt = (rng, min, max) => Math.floor(rng() * (max - min + 1)) + min;
const pick = (rng, array) => array[Math.floor(rng() * array.length)];
const randomPlanetTypeKey = (rng, planetType) => {
const keys = Object.keys(planetType);
return keys[Math.floor(rng() * keys.length)];
};
const distributePercentages = (rng, items) => {
if (items.length === 0) return {};
if (items.length === 1) return { [items[0]]: 100 };
const cuts = Array.from({ length: items.length - 1 }, () => rng()).sort((a, b) => a - b);
const segs = cuts.map((p, i) => (i === 0 ? p * 100 : (cuts[i] - cuts[i - 1]) * 100));
segs.push((1 - cuts[items.length - 2]) * 100);
return Object.fromEntries(items.map((item, idx) => [item, segs[idx]]));
};
export function buildPlanetTypeDistributions(rng) {
for (const key in stat.planetType) {
const type = stat.planetType[key];
if (Array.isArray(type.elements)) type.distributedElements = distributePercentages(rng, type.elements);
if (Array.isArray(type.resources)) type.distributedResources = distributePercentages(rng, type.resources);
}
}
export function generatePlanet(rng) {
// Distributions are normally created once at startup, but since we run per-cell with per-cell rng,
// we generate per planet for stable, deterministic results from the same seed.
buildPlanetTypeDistributions(rng);
const planetTypeGeneration = randomPlanetTypeKey(rng, stat.planetType);
const planetName = pick(rng, stat.planetNamePrefix) + pick(rng, stat.planetNameSuffix); // don't touch it
const planetPopulationPeople = pick(rng, stat.planetType[planetTypeGeneration].populationType);
const planetPopulationNumber =
(stat.planetType[planetTypeGeneration].population * getRandomInt(rng, 50, 150)) / 99.9;
return {
name: planetName,
type: planetTypeGeneration,
population: {
billions: Number(planetPopulationNumber.toFixed(3)),
majority: planetPopulationPeople,
},
production: stat.planetType[planetTypeGeneration].distributedElements,
naturalResources: stat.planetType[planetTypeGeneration].distributedResources,
};
}
export function formatPlanet(planet) {
const elementsDescription = Object.entries(planet.production)
.map(([k, v]) => ` ${k}: ${v.toFixed(1)}%`)
.join("\n");
const resourcesDescription = Object.entries(planet.naturalResources)
.map(([k, v]) => ` ${k}: ${v.toFixed(3)}%`)
.join("\n");
return `Planète : ${planet.name}
Type : ${planet.type}
Production :
${elementsDescription}
Ressources naturelles :
${resourcesDescription}
Population :
${planet.population.billions.toFixed(3)} milliards
Majoritairement ${planet.population.majority}`;
}

249
public/src/planetStat.js Normal file
View File

@@ -0,0 +1,249 @@
import { elements, population, resources } from "./planetEconomy.js";
export const planetType = {
Tempérée: {
population: 100,
populationType: [population.humanoids.humans, population.humanoids.near, population.humanoids.aliens],
elements: [elements.common, elements.food, elements.medic],
resources: [
resources.common.rock,
resources.common.wood,
resources.common.liquid,
resources.common.grain,
resources.common.livestock,
resources.common.fish,
resources.common.animals,
resources.rare.liquid,
resources.rare.grain,
resources.rare.livestock,
resources.rare.fish,
resources.rare.animals,
],
},
Glacée: {
population: 1,
populationType: [
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.humanoids.aliens,
],
elements: [elements.petrol, elements.science],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.science,
resources.rare.liquid,
resources.rare.science,
],
},
Volcanique: {
population: 2,
populationType: [
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.creatures.robots,
population.humanoids.aliens,
],
elements: [elements.common, elements.petrol],
resources: [
resources.common.rock,
resources.common.gas,
resources.common.acid,
resources.rare.rock,
resources.rare.mineral,
],
},
Marécageuse: {
population: 10,
populationType: [population.creatures.casual, population.creatures.danger, population.creatures.apex],
elements: [elements.common, elements.petrol],
resources: [
resources.common.wood,
resources.common.liquid,
resources.common.fish,
resources.common.plant,
resources.common.stones,
resources.rare.wood,
resources.rare.liquid,
resources.rare.fish,
resources.rare.animals,
],
},
Forestière: {
population: 20,
populationType: [population.creatures.casual, population.creatures.danger, population.humanoids.aliens],
elements: [elements.common],
resources: [
resources.common.wood,
resources.common.plant,
resources.common.animals,
resources.common.grain,
resources.rare.wood,
resources.rare.plant,
resources.rare.animals,
],
},
Océanique: {
population: 25,
populationType: [population.creatures.casual, population.creatures.apex, population.humanoids.aliens],
elements: [elements.common, elements.petrol],
resources: [
resources.common.liquid,
resources.common.fish,
resources.common.stones,
resources.rare.liquid,
resources.rare.fish,
resources.rare.stones,
],
},
Oecuménopole: {
population: 2000,
populationType: [population.humanoids.humans, population.humanoids.near],
elements: [elements.science, elements.industry, elements.money, elements.goods],
resources: [
resources.common.mineral,
resources.common.grain,
resources.common.livestock,
resources.common.goods,
resources.common.factory,
resources.rare.liquid,
resources.rare.grain,
resources.rare.livestock,
resources.rare.goods,
],
},
Désert: {
population: 50,
populationType: [
population.humanoids.near,
population.humanoids.aliens,
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.creatures.robots,
],
elements: [elements.common, elements.goods],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.livestock,
resources.common.science,
resources.rare.stones,
resources.rare.animals,
],
},
Minéralogique: {
population: 3,
populationType: [population.humanoids.aliens, population.creatures.robots, population.creatures.danger],
elements: [elements.common],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.stones,
resources.rare.rock,
resources.rare.mineral,
resources.rare.stones,
],
},
Gazeuse: {
population: 1,
populationType: [
population.humanoids.humans,
population.humanoids.near,
population.humanoids.aliens,
population.creatures.robots,
],
elements: [elements.petrol],
resources: [resources.common.oil, resources.common.gas, resources.rare.oil, resources.rare.gas],
},
Acide: {
population: 1,
populationType: [population.humanoids.aliens, population.creatures.casual, population.creatures.robots],
elements: [elements.petrol, elements.industry],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.factory,
resources.common.acid,
resources.rare.acid,
],
},
"Monde usine": {
population: 500,
populationType: [
population.humanoids.humans,
population.humanoids.near,
population.humanoids.aliens,
population.creatures.robots,
],
elements: [elements.petrol, elements.industry],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.oil,
resources.common.goods,
resources.common.science,
resources.common.factory,
resources.common.acid,
resources.rare.factory,
],
},
};
export const planetNamePrefix = [
"Acod",
"Acht",
"Bex",
"Carob",
"Daris",
"Ecop",
"Fron",
"Glac",
"Glad",
"Hac",
"Hor",
"Is",
"Jud",
"Kor",
"Ler",
"Marel",
"Naur",
"Olep",
"Prid",
"Plex",
"Qef",
"Rem",
"Sarit",
"Thec",
"Thex",
"Thet",
"Uv",
"Volc",
"Vold",
"Wann",
"Xal",
"Yr",
"Zot",
];
export const planetNameSuffix = [
"a Minor",
"a Major",
"ant",
"alor",
"eri",
"inia",
"irid",
"is",
"o Zion",
"or",
"oria",
"us",
"us Prime",
"us Secondus",
"land",
"yard",
];

30
public/src/rng.js Normal file
View File

@@ -0,0 +1,30 @@
export function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
export function hash2u32(a, b, seed = 0) {
// 2D integer hash -> uint32 (cheap, stable)
let x = (a | 0) + 0x9e3779b9 + (seed | 0);
let y = (b | 0) + 0x85ebca6b;
x ^= y + 0x27d4eb2d + (x << 6) + (x >>> 2);
x = Math.imul(x ^ (x >>> 16), 0x7feb352d);
x = Math.imul(x ^ (x >>> 15), 0x846ca68b);
x ^= x >>> 16;
return x >>> 0;
}
export function mulberry32(seedU32) {
let t = seedU32 >>> 0;
return function rand() {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}