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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user