1.1.0 : éditeur de niveaux et partage communautaire

Éditeur visuel (terrain, monstres avec gabarits, objets, départ, sortie),
test en un clic, publication via une API node sans dépendance (server.js,
validation serveur, stockage JSON sur volume), écran « Niveaux de la
communauté » et liens partageables ?level=<id>. Le conteneur passe de
nginx à node:22-alpine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 13:26:56 +02:00
co-authored by Claude Fable 5
parent a106b0d021
commit 103be8e9c7
8 changed files with 831 additions and 37 deletions
+153
View File
@@ -0,0 +1,153 @@
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires.
* Node pur, aucune dépendance. Stockage : un fichier JSON (LEVELS_FILE). */
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const PORT = process.env.PORT || 80;
const ROOT = __dirname;
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large
const MAX_LEVELS = 500;
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
};
/* ---------- Stockage ---------- */
function loadLevels() {
try { return JSON.parse(fs.readFileSync(LEVELS_FILE, "utf8")); }
catch { return []; }
}
function saveLevels(levels) {
fs.mkdirSync(path.dirname(LEVELS_FILE), { recursive: true });
const tmp = LEVELS_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(levels));
fs.renameSync(tmp, LEVELS_FILE);
}
/* ---------- Validation d'un niveau ---------- */
const MAP_W = 28, MAP_H = 20;
function validateLevel(l) {
if (!l || typeof l !== "object") return "niveau invalide";
if (typeof l.name !== "string" || !l.name.trim() || l.name.length > 40) return "nom invalide (140 caractères)";
if (typeof l.author !== "string" || !l.author.trim() || l.author.length > 30) return "auteur invalide (130 caractères)";
if (!Array.isArray(l.grid) || l.grid.length !== MAP_H) return "grille invalide";
for (const row of l.grid) {
if (typeof row !== "string" || row.length !== MAP_W || /[^#.>]/.test(row)) return "ligne de grille invalide";
}
if (!l.start || !inBounds(l.start) || tile(l, l.start) === "#") return "point de départ manquant ou dans un mur";
if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)";
for (const m of l.monsters) {
if (!inBounds(m) || tile(l, m) === "#") return "monstre hors-sol";
if (m.boss !== true && !(Number.isInteger(m.type) && m.type >= 0 && Number.isInteger(m.tpl) && m.tpl >= 0)) return "type de monstre invalide";
}
if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
for (const i of l.items) {
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
if (!["potion", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide";
}
if (l.monsters.length === 0) return "place au moins un monstre, sinon pas de défi !";
return null;
}
const inBounds = p => p && Number.isInteger(p.x) && Number.isInteger(p.y) && p.x >= 0 && p.x < MAP_W && p.y >= 0 && p.y < MAP_H;
const tile = (l, p) => l.grid[p.y][p.x];
/* ---------- API ---------- */
function sendJSON(res, code, data) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
function handleAPI(req, res, url) {
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
if (req.method === "GET" && url.pathname === "/api/levels") {
const list = loadLevels().map(l => ({
id: l.id, name: l.name, author: l.author, date: l.date,
monsters: l.monsters.length, plays: l.plays || 0,
}));
return sendJSON(res, 200, list);
}
if (req.method === "GET" && idMatch) {
const levels = loadLevels();
const level = levels.find(l => l.id === idMatch[1]);
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
level.plays = (level.plays || 0) + 1;
saveLevels(levels);
return sendJSON(res, 200, level);
}
if (req.method === "POST" && url.pathname === "/api/levels") {
let body = "";
req.on("data", chunk => {
body += chunk;
if (body.length > MAX_BODY) { sendJSON(res, 413, { error: "niveau trop gros" }); req.destroy(); }
});
req.on("end", () => {
let level;
try { level = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
const err = validateLevel(level);
if (err) return sendJSON(res, 400, { error: err });
const levels = loadLevels();
if (levels.length >= MAX_LEVELS) return sendJSON(res, 507, { error: "trop de niveaux stockés" });
const clean = {
id: crypto.randomBytes(6).toString("hex"),
name: level.name.trim(), author: level.author.trim(),
date: new Date().toISOString().slice(0, 10),
grid: level.grid, start: { x: level.start.x, y: level.start.y },
monsters: level.monsters, items: level.items, plays: 0,
};
levels.push(clean);
saveLevels(levels);
return sendJSON(res, 201, { id: clean.id });
});
return;
}
sendJSON(res, 404, { error: "route inconnue" });
}
/* ---------- Statique ---------- */
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")) {
res.writeHead(403); return res.end("interdit");
}
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end("introuvable"); }
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
res.end(data);
});
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname.startsWith("/api/")) return handleAPI(req, res, url);
if (req.method !== "GET") { res.writeHead(405); return res.end(); }
handleStatic(req, res, url);
});
if (require.main === module) {
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}`));
}
module.exports = { validateLevel, MAP_W, MAP_H };