2.0.0 : multijoueur — monde partagé persistant, DLA paramétrables par monstre, admin à chaud

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 10:53:16 +02:00
co-authored by Claude Fable 5
parent e5a4148ee8
commit 1a5d2c7317
10 changed files with 1839 additions and 11 deletions
+116 -4
View File
@@ -1,5 +1,6 @@
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires.
* Node pur, aucune dépendance. Stockage : un fichier JSON (LEVELS_FILE). */
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires
* + monde multijoueur persistant (mp.js).
* Node pur, aucune dépendance. Stockage : fichiers JSON (LEVELS_FILE, WORLD_FILE). */
"use strict";
@@ -7,10 +8,12 @@ const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const mp = require("./mp.js");
const PORT = process.env.PORT || 80;
const ROOT = __dirname;
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
const WORLD_FILE = process.env.WORLD_FILE || "/data/world.json";
const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large
const MAX_LEVELS = 500;
@@ -111,7 +114,115 @@ function readBody(req, res, cb) {
});
}
/* ---------- Multijoueur : monde partagé (mp.js) ---------- */
let WORLD = null; // initialisé au démarrage du serveur seulement
let worldDirty = false;
let ADMIN_TOKEN = null;
function initMP() {
WORLD = mp.loadWorld(WORLD_FILE) || mp.createWorld();
// token admin : variable d'environnement, sinon généré et persisté à côté du monde
ADMIN_TOKEN = process.env.MP_ADMIN_TOKEN || null;
if (!ADMIN_TOKEN) {
const tokenFile = path.join(path.dirname(WORLD_FILE), "admin-token.txt");
try { ADMIN_TOKEN = fs.readFileSync(tokenFile, "utf8").trim(); } catch {}
if (!ADMIN_TOKEN) {
ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
try {
fs.mkdirSync(path.dirname(tokenFile), { recursive: true });
fs.writeFileSync(tokenFile, ADMIN_TOKEN);
} catch {}
}
}
console.log(`Monde multijoueur prêt (${Object.keys(WORLD.trolls).length} troll(s), ${WORLD.monsters.length} monstre(s)).`);
console.log(`Token admin : ${ADMIN_TOKEN}`);
setInterval(() => {
if (mp.tick(WORLD)) worldDirty = true;
}, 1000);
setInterval(() => {
if (!worldDirty) return;
worldDirty = false;
try { mp.saveWorld(WORLD, WORLD_FILE); } catch (e) { console.error("sauvegarde du monde :", e.message); }
}, 15000);
process.on("SIGTERM", () => {
try { mp.saveWorld(WORLD, WORLD_FILE); } catch {}
process.exit(0);
});
}
function isAdmin(req, url) {
const provided = req.headers["x-admin-token"] || url.searchParams.get("token");
return ADMIN_TOKEN && provided === ADMIN_TOKEN;
}
function handleMP(req, res, url) {
if (!WORLD) return sendJSON(res, 503, { error: "monde non initialisé" });
if (req.method === "POST" && url.pathname === "/api/mp/join") {
return readBody(req, res, body => {
const r = mp.newTroll(WORLD, body.name, body.race);
if (r.error) return sendJSON(res, 400, { error: r.error });
worldDirty = true;
return sendJSON(res, 201, {
id: r.troll.id, secret: r.troll.secret,
state: mp.stateFor(WORLD, r.troll),
});
});
}
if (req.method === "GET" && url.pathname === "/api/mp/state") {
const t = mp.authTroll(WORLD, url.searchParams.get("id"), url.searchParams.get("secret"));
if (!t) return sendJSON(res, 403, { error: "troll inconnu ou clé invalide" });
return sendJSON(res, 200, mp.stateFor(WORLD, t));
}
if (req.method === "POST" && url.pathname === "/api/mp/action") {
return readBody(req, res, body => {
const t = mp.authTroll(WORLD, body.id, body.secret);
if (!t) return sendJSON(res, 403, { error: "troll inconnu ou clé invalide" });
const r = mp.action(WORLD, t, body.action || {});
worldDirty = true;
return sendJSON(res, r.error ? 400 : 200, { ...r, state: mp.stateFor(WORLD, t) });
});
}
if (req.method === "GET" && url.pathname === "/api/mp/info") {
const now = Date.now();
const online = Object.values(WORLD.trolls).filter(t => now - (t.lastSeen || 0) < 5 * 60 * 1000).length;
return sendJSON(res, 200, {
trolls: Object.keys(WORLD.trolls).length, online,
monsters: WORLD.monsters.length, pollSec: WORLD.config.pollSec,
});
}
/* --- Admin (X-Admin-Token ou ?token=) --- */
if (url.pathname.startsWith("/api/mp/admin")) {
if (!isAdmin(req, url)) return sendJSON(res, 403, { error: "token admin invalide" });
if (req.method === "GET" && url.pathname === "/api/mp/admin") {
return sendJSON(res, 200, mp.adminOverview(WORLD));
}
if (req.method === "PUT" && url.pathname === "/api/mp/admin/config") {
return readBody(req, res, body => {
const cfg = mp.adminSetConfig(WORLD, body);
worldDirty = true;
mp.worldLog(WORLD, "⚙️ Les Dieux Trõlls ont ajusté les lois du monde.");
return sendJSON(res, 200, cfg);
});
}
if (req.method === "POST" && url.pathname === "/api/mp/admin/reset") {
mp.adminResetWorld(WORLD);
worldDirty = true;
return sendJSON(res, 200, { ok: true });
}
}
sendJSON(res, 404, { error: "route multijoueur inconnue" });
}
function handleAPI(req, res, url) {
if (url.pathname.startsWith("/api/mp/")) return handleMP(req, res, url);
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
if (req.method === "GET" && url.pathname === "/api/levels") {
@@ -175,7 +286,7 @@ function handleStatic(req, res, url) {
let p = decodeURIComponent(url.pathname);
if (p === "/") p = "/index.html";
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js")) {
if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js") || file === path.join(ROOT, "mp.js")) {
res.writeHead(403); return res.end("interdit");
}
fs.readFile(file, (err, data) => {
@@ -193,7 +304,8 @@ const server = http.createServer((req, res) => {
});
if (require.main === module) {
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}`));
initMP();
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}, monde dans ${WORLD_FILE}`));
}
module.exports = { validateLevel, MAP_W, MAP_H };