1.2.0 : portes entre niveaux et édition post-publication

Portes 🚪 (max 10/niveau) téléportant vers un autre niveau publié avec
troll conservé (PV, PI, équipement) ; clé d'auteur renvoyée au POST et
stockée en localStorage, PUT /api/levels/<id> protégé par X-Level-Secret,
section « Mes niveaux publiés » dans l'éditeur, objectifs de victoire
affinés (sortie > nettoyage, portes = continuation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 13:42:31 +02:00
co-authored by Claude Fable 5
parent 103be8e9c7
commit fe13a4d518
7 changed files with 280 additions and 38 deletions
+19 -5
View File
@@ -76,19 +76,30 @@ Depuis l'écran d'accueil : **🛠️ Éditeur de niveaux**.
- Peins le terrain à la souris (mur, sol, sortie ▼), place le départ 🧌, les monstres
(avec gabarit Jeune → Mythique) et les objets. Outils : tout sol / tout mur /
caverne aléatoire.
caverne aléatoire / nouveau niveau.
- **🚪 Portes** : une porte téléporte le troll vers un autre niveau publié, en
conservant PV, PI et équipement — de quoi chaîner des niveaux en campagne.
- **▶️ Tester** joue ton niveau immédiatement (retour à l'éditeur après la partie).
- **🌍 Publier** l'envoie au serveur ; il apparaît dans « Niveaux de la communauté ».
- Victoire d'un niveau custom : terrasser tous les monstres, ou atteindre la sortie ▼.
La publication renvoie une clé d'auteur gardée en localStorage : tes niveaux
restent modifiables via la section « Mes niveaux publiés » (bouton devient
« Mettre à jour »).
- Victoire d'un niveau custom : atteindre la sortie ▼ si elle existe, sinon
terrasser tous les monstres. Un niveau avec porte(s) sans sortie ne se termine
pas sur place : la suite est derrière la porte.
- Lien partageable : `https://mountycrawl.nas.percolouco.com/?level=<id>`.
### API
| Route | Description |
|---|---|
| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb parties) |
| `GET /api/levels/<id>` | Niveau complet (incrémente le compteur de parties) |
| `POST /api/levels` | Publie un niveau (validation côté serveur : grille 28×20, départ hors mur, 160 monstres…) |
| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb portes, nb parties) |
| `GET /api/levels/<id>` | Niveau complet (incrémente le compteur de parties ; la clé d'auteur n'est jamais renvoyée) |
| `POST /api/levels` | Publie un niveau ; renvoie `{id, secret}` — le `secret` est la clé d'auteur |
| `PUT /api/levels/<id>` | Met à jour un niveau ; exige l'en-tête `X-Level-Secret` (403 sinon) |
Validation serveur : grille 28×20, départ hors mur, max 60 monstres/objets et
10 portes (cible = id de niveau), au moins un objectif (monstre, porte ou sortie).
Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
@@ -110,6 +121,9 @@ non affilié au jeu original de Mountyhall SARL.
## Versions
- **1.2.0** (2026-06-11) — Portes 🚪 entre niveaux (campagnes multi-niveaux, troll
conservé), édition post-publication avec clé d'auteur (`PUT` + localStorage),
section « Mes niveaux publiés », objectifs de victoire affinés.
- **1.1.0** (2026-06-11) — Mode création : éditeur de niveaux visuel, publication en
ligne, écran « Niveaux de la communauté », liens partageables `?level=<id>`,
backend node sans dépendance (`server.js`) avec validation et stockage JSON.
+1
View File
@@ -253,6 +253,7 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
#ed-status { margin: 10px 0; min-height: 1.2em; font-size: .95em; }
.ed-warn { color: #e06060; }
.ed-ok { color: #8fbf5a; }
.ed-note { font-size: .8em; color: #a89568; }
#ed-help { max-width: 600px; margin: 0 auto; }
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
+8 -2
View File
@@ -51,8 +51,13 @@
<div id="ed-monsters"></div>
<h3>Objets</h3>
<div id="ed-items"></div>
<h3>Portes (vers un niveau publié)</h3>
<select id="ed-door-target"></select>
<div id="ed-doors"></div>
<h3>Outils</h3>
<div id="ed-tools"></div>
<h3>Mes niveaux publiés</h3>
<div id="ed-mine"></div>
</aside>
<main id="ed-main">
<canvas id="ed-map" width="672" height="480"></canvas>
@@ -66,8 +71,9 @@
</div>
<div id="ed-status"></div>
<div class="help" id="ed-help">
<p>Clic / glisser : peindre la sélection · Un niveau publiable a un point de départ 🧌 et au moins un monstre.</p>
<p>La sortie ▼ est optionnelle : sans elle, il faut terrasser tous les monstres pour gagner.</p>
<p>Clic / glisser : peindre la sélection · Un niveau publiable a un point de départ 🧌 et au moins un monstre, une porte ou une sortie.</p>
<p>Victoire : atteindre la sortie ▼ si elle existe, sinon terrasser tous les monstres. Les portes 🚪 téléportent vers un autre niveau publié (PV, PI et équipement conservés) — idéal pour chaîner une campagne.</p>
<p>Tes niveaux publiés restent modifiables depuis « Mes niveaux publiés » (la clé d'auteur est gardée par ton navigateur).</p>
</div>
</main>
</div>
+133 -8
View File
@@ -7,6 +7,8 @@ const ED = {
start: null, // {x, y}
monsters: [], // specs {x, y, type, tpl} ou {x, y, boss: true}
items: [], // specs {x, y, kind, idx?, gold?}
doors: [], // {x, y, target: id de niveau publié}
editing: null, // {id, secret} quand on modifie un niveau déjà publié
brush: { mode: "tile", char: "#" },
painting: false,
};
@@ -23,6 +25,67 @@ function edReset() {
ED.start = null;
ED.monsters = [];
ED.items = [];
ED.doors = [];
ED.editing = null;
const name = document.getElementById("ed-name");
if (name) { name.value = ""; document.getElementById("ed-author").value = ""; }
edSyncPublishLabel();
}
/* ---------- Mes niveaux (clés d'auteur en localStorage) ---------- */
function myLevels() {
try { return JSON.parse(localStorage.getItem("mc_myLevels")) || []; }
catch { return []; }
}
function saveMyLevel(entry) {
const mine = myLevels().filter(l => l.id !== entry.id);
mine.push(entry);
localStorage.setItem("mc_myLevels", JSON.stringify(mine));
}
function edRenderMine() {
const div = document.getElementById("ed-mine");
div.innerHTML = "";
const mine = myLevels();
if (mine.length === 0) {
div.innerHTML = '<p class="ed-note">Aucun pour l\'instant — publie un niveau et il apparaîtra ici, modifiable à volonté.</p>';
return;
}
for (const entry of mine) {
const b = document.createElement("button");
b.textContent = `✏️ ${entry.name}`;
b.title = "Recharger ce niveau dans l'éditeur pour le modifier";
b.onclick = () => edLoadMine(entry);
div.appendChild(b);
}
}
async function edLoadMine(entry) {
try {
const res = await fetch("api/levels/" + entry.id);
if (!res.ok) { edFlash("Niveau introuvable sur le serveur.", true); return; }
const level = await res.json();
ED.grid = level.grid.map(row => row.split(""));
ED.start = level.start;
ED.monsters = level.monsters;
ED.items = level.items;
ED.doors = level.doors || [];
ED.editing = { id: entry.id, secret: entry.secret };
document.getElementById("ed-name").value = level.name;
document.getElementById("ed-author").value = level.author;
edSyncPublishLabel();
edRender();
edFlash(`« ${level.name} » chargé — modifie puis « Mettre à jour ».`, false);
} catch {
edFlash("Impossible de joindre le serveur.", true);
}
}
function edSyncPublishLabel() {
const btn = document.getElementById("ed-publish");
if (btn) btn.textContent = ED.editing ? "💾 Mettre à jour le niveau" : "🌍 Publier en ligne";
}
/* ---------- Palette ---------- */
@@ -63,8 +126,16 @@ function edBuildPalette() {
WEAPONS.forEach((w, i) => edAddBrushBtn(itemsDiv, `${w.emoji} ${w.name} (+${w.bonus})`, { mode: "item", kind: "weapon", idx: i }));
ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i }));
const doorsDiv = document.getElementById("ed-doors");
doorsDiv.innerHTML = "";
edAddBrushBtn(doorsDiv, "🚪 Placer une porte", { mode: "door" });
const toolsDiv = document.getElementById("ed-tools");
toolsDiv.innerHTML = "";
const newBtn = document.createElement("button");
newBtn.textContent = "📄 Nouveau niveau (vider)";
newBtn.onclick = () => { edReset(); edRender(); };
toolsDiv.appendChild(newBtn);
const fill = (char, label) => {
const b = document.createElement("button");
b.textContent = label;
@@ -108,9 +179,26 @@ function edPruneEntities() {
const onFloor = e => ED.grid[e.y][e.x] !== "#";
ED.monsters = ED.monsters.filter(onFloor);
ED.items = ED.items.filter(onFloor);
ED.doors = ED.doors.filter(onFloor);
if (ED.start && !onFloor(ED.start)) ED.start = null;
}
/* Remplit le sélecteur de cible des portes avec les niveaux publiés. */
async function edRefreshDoorTargets() {
const sel = document.getElementById("ed-door-target");
sel.innerHTML = "<option value=''>— choisis le niveau cible —</option>";
try {
const levels = await (await fetch("api/levels")).json();
for (const l of levels) {
if (ED.editing && l.id === ED.editing.id) continue; // pas de porte vers soi-même
const opt = document.createElement("option");
opt.value = l.id;
opt.textContent = `${l.name} (par ${l.author})`;
sel.appendChild(opt);
}
} catch { /* hors-ligne : le sélecteur reste vide */ }
}
/* ---------- Application du pinceau ---------- */
function edApply(x, y) {
@@ -124,6 +212,7 @@ function edApply(x, y) {
if (b.char === "#") {
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
ED.items = ED.items.filter(i => i.x !== x || i.y !== y);
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== y);
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
}
} else if (b.mode === "start") {
@@ -132,7 +221,14 @@ function edApply(x, y) {
} else if (b.mode === "erase") {
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
ED.items = ED.items.filter(i => i.x !== x || i.y !== y);
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== y);
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
} else if (b.mode === "door") {
if (ED.grid[y][x] === "#") return;
const target = document.getElementById("ed-door-target").value;
if (!target) { edFlash("Choisis d'abord le niveau cible de la porte dans la liste.", true); return; }
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== y);
ED.doors.push({ x, y, target });
} else if (b.mode === "monster") {
if (ED.grid[y][x] === "#") return;
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
@@ -184,6 +280,11 @@ function edRender() {
: i.kind === "weapon" ? WEAPONS[i.idx || 0].emoji : ARMORS[i.idx || 0].emoji;
ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
}
for (const d of ED.doors) {
disc(d.x, d.y, "#a06a28");
ctx.fillStyle = "#1a140e";
ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1);
}
for (const m of ED.monsters) {
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
ctx.fillStyle = "#1a140e";
@@ -199,10 +300,15 @@ function edRender() {
const status = document.getElementById("ed-status");
const missing = [];
if (!ED.start) missing.push("un point de départ 🧌");
if (ED.monsters.length === 0) missing.push("au moins un monstre");
if (ED.monsters.length === 0 && ED.doors.length === 0 && !ED.grid.some(r => r.includes(">")))
missing.push("au moins un monstre, une porte ou une sortie");
const extras = [
ED.doors.length ? `${ED.doors.length} porte(s)` : "",
ED.grid.some(r => r.includes(">")) ? "une sortie" : "",
].filter(Boolean).join(", ");
status.textContent = missing.length
? "Il manque : " + missing.join(" et ") + "."
: `Prêt : ${ED.monsters.length} monstre(s), ${ED.items.length} objet(s)${ED.grid.some(r => r.includes(">")) ? ", une sortie" : ""}.`;
: `Prêt : ${ED.monsters.length} monstre(s), ${ED.items.length} objet(s)${extras ? ", " + extras : ""}.`;
status.className = missing.length ? "ed-warn" : "ed-ok";
}
@@ -216,12 +322,14 @@ function edToLevel() {
start: ED.start,
monsters: ED.monsters,
items: ED.items,
doors: ED.doors,
};
}
function edValidate(level) {
if (!level.start) return "place un point de départ 🧌";
if (level.monsters.length === 0) return "place au moins un monstre";
if (level.monsters.length === 0 && level.doors.length === 0 && !level.grid.some(r => r.includes(">")))
return "place au moins un monstre, une porte ou une sortie";
return null;
}
@@ -243,14 +351,28 @@ async function edPublish() {
(!level.author ? "indique ton pseudo d'auteur" : null);
if (err) { edFlash(err, true); return; }
try {
const res = await fetch("api/levels", {
method: "POST",
headers: { "Content-Type": "application/json" },
const updating = !!ED.editing;
const res = await fetch(updating ? "api/levels/" + ED.editing.id : "api/levels", {
method: updating ? "PUT" : "POST",
headers: {
"Content-Type": "application/json",
...(updating ? { "X-Level-Secret": ED.editing.secret } : {}),
},
body: JSON.stringify(level),
});
const data = await res.json();
if (!res.ok) { edFlash("Refusé par le serveur : " + data.error, true); return; }
edFlash(`🌍 Publié ! « ${level.name} » est maintenant jouable par tous.`, false);
if (updating) {
saveMyLevel({ id: ED.editing.id, secret: ED.editing.secret, name: level.name });
edFlash(`💾 « ${level.name} » mis à jour pour tous les joueurs.`, false);
} else {
ED.editing = { id: data.id, secret: data.secret };
saveMyLevel({ id: data.id, secret: data.secret, name: level.name });
edSyncPublishLabel();
edFlash(`🌍 Publié ! « ${level.name} » est jouable par tous (id ${data.id}).`, false);
}
edRenderMine();
edRefreshDoorTargets();
} catch {
edFlash("Impossible de joindre le serveur.", true);
}
@@ -280,7 +402,8 @@ async function communityShow() {
for (const l of levels.slice().reverse()) {
const row = document.createElement("button");
row.className = "level-row";
row.textContent = `⚔️ ${l.name} — par ${l.author} · ${l.monsters} monstre(s) · joué ${l.plays} fois · ${l.date}`;
row.textContent = `⚔️ ${l.name} — par ${l.author} · ${l.monsters} monstre(s)`
+ (l.doors ? ` · ${l.doors} porte(s)` : "") + ` · joué ${l.plays} fois · ${l.date}`;
row.onclick = async () => {
const r = await fetch("api/levels/" + l.id);
if (!r.ok) return;
@@ -306,6 +429,8 @@ if (typeof document !== "undefined") {
document.getElementById("btn-editor").onclick = () => {
document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-editor").classList.remove("hidden");
edRefreshDoorTargets();
edRenderMine();
edRender();
};
document.getElementById("ed-back").onclick = () => {
+47 -4
View File
@@ -274,14 +274,18 @@ function newGame(name, race, customLevel = null) {
pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
bag: [], camo: false, stoneSkin: 0, kills: 0, dla: 1,
},
depth: 1, grid: null, monsters: [], items: [], stairs: null,
depth: 1, grid: null, monsters: [], items: [], doors: [], stairs: null,
seen: new Set(), over: false,
};
if (customLevel) buildCustomLevel(customLevel);
else buildLevel();
if (customLevel) {
log(`${name} le ${race} entre dans « ${customLevel.name} », un niveau de ${customLevel.author}.`, "good");
log("Objectif : terrasser tous les monstres, ou atteindre la sortie ▼ s'il y en a une.", "info");
const hasExitTile = G.grid.some(row => row.includes(T_STAIRS));
const hasDoors = G.doors.length > 0;
if (hasExitTile) log("Objectif : atteindre la sortie ▼.", "info");
else if (hasDoors) log("Objectif : des portes 🚪 mènent vers d'autres niveaux — explore !", "info");
else log("Objectif : terrasser tous les monstres.", "info");
} else {
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
}
@@ -302,10 +306,38 @@ function buildCustomLevel(level) {
G.seen = new Set();
G.monsters = level.monsters.map(monsterFromSpec);
G.items = level.items.map(itemFromSpec);
G.doors = (level.doors || []).map(d => ({ ...d }));
G.stairs = null;
updateFov();
}
function doorAt(x, y) {
return (G.doors || []).find(d => d.x === x && d.y === y);
}
/* Le niveau custom courant a-t-il encore un objectif au-delà du nettoyage ? */
function customHasExit() {
return G.grid.some(row => row.includes(T_STAIRS)) || (G.doors || []).length > 0;
}
/* Franchir une porte : charge le niveau cible en conservant le troll (PV, PI, sac…). */
async function enterDoor() {
const door = doorAt(G.troll.x, G.troll.y);
if (!door || G.over) return;
log("🚪 Tu pousses la lourde porte…", "info");
try {
const res = await fetch("api/levels/" + door.target);
if (!res.ok) throw new Error();
const level = await res.json();
G.custom = level;
buildCustomLevel(level);
log(`Tu débouches dans « ${level.name} », un niveau de ${level.author}.`, "good");
afterAction();
} catch {
log("La porte est condamnée : le niveau cible n'existe plus.", "bad");
}
}
function buildLevel() {
const grid = generateCavern(MAP_W, MAP_H);
const taken = new Set();
@@ -386,6 +418,7 @@ function tryMove(dx, dy) {
if (G.custom) log("La sortie ! (bouton « Sortir »)", "info");
else log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "info");
}
if (doorAt(nx, ny)) log("Une porte massive se dresse là. (bouton « Franchir la porte »)", "info");
const item = G.items.find(i => i.x === nx && i.y === ny);
if (item) log(`Tu vois : ${item.emoji} ${item.name}. Ramasse-le pour ${COSTS.pickup} PA.`, "info");
afterAction();
@@ -423,7 +456,10 @@ function killMonster(m) {
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
G.monsters = G.monsters.filter(x => x !== m);
if (G.custom) {
if (G.monsters.length === 0) win();
if (G.monsters.length === 0) {
if (customHasExit()) log("Le niveau est nettoyé… mais l'aventure continue derrière une porte ou une sortie.", "info");
else win();
}
} else if (m.boss) {
win();
}
@@ -685,6 +721,12 @@ function render() {
ctx.fillStyle = "#1a140e";
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
}
for (const d of G.doors || []) {
if (!G.seen.has(d.y * MAP_W + d.x)) continue;
disc(d.x, d.y, "#a06a28");
ctx.fillStyle = "#1a140e";
ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1);
}
for (const m of G.monsters) {
if (!inSight(m)) continue;
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
@@ -758,7 +800,8 @@ function renderPanels() {
const onItem = G.items.some(i => i.x === t.x && i.y === t.y);
addBtn(`🖐️ Ramasser (${COSTS.pickup} PA)`, pickup, onItem && t.pa >= COSTS.pickup);
const onStairs = G.grid[t.y][t.x] === T_STAIRS;
addBtn(G.custom ? "🚪 Sortir" : "⬇️ Descendre", descend, onStairs);
addBtn(G.custom ? "🏁 Sortir" : "⬇️ Descendre", descend, onStairs);
if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true);
addBtn("⏳ Passer la DLA", passDLA, true);
$("equipment").innerHTML =
+62 -18
View File
@@ -59,7 +59,16 @@ function validateLevel(l) {
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 !";
if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides";
const doors = l.doors || [];
if (doors.length > 10) return "portes invalides (max 10)";
for (const d of doors) {
if (!inBounds(d) || tile(l, d) === "#") return "porte hors-sol";
if (typeof d.target !== "string" || !/^[a-f0-9]{12}$/.test(d.target)) return "cible de porte invalide";
}
const hasExit = l.grid.some(row => row.includes(">"));
if (l.monsters.length === 0 && doors.length === 0 && !hasExit)
return "place au moins un monstre, une porte ou une sortie, sinon pas de défi !";
return null;
}
@@ -73,13 +82,40 @@ function sendJSON(res, code, data) {
res.end(JSON.stringify(data));
}
/* Le secret d'auteur (renvoyé à la publication, gardé côté client) ne sort jamais en GET. */
function publicLevel(l) {
const { secret, ...pub } = l;
return pub;
}
function cleanLevel(level) {
return {
name: level.name.trim(), author: level.author.trim(),
grid: level.grid, start: { x: level.start.x, y: level.start.y },
monsters: level.monsters, items: level.items, doors: level.doors || [],
};
}
function readBody(req, res, cb) {
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 data;
try { data = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
cb(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,
monsters: l.monsters.length, doors: (l.doors || []).length, plays: l.plays || 0,
}));
return sendJSON(res, 200, list);
}
@@ -90,34 +126,42 @@ function handleAPI(req, res, url) {
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
level.plays = (level.plays || 0) + 1;
saveLevels(levels);
return sendJSON(res, 200, level);
return sendJSON(res, 200, publicLevel(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" }); }
return readBody(req, res, level => {
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 = {
const record = {
id: crypto.randomBytes(6).toString("hex"),
name: level.name.trim(), author: level.author.trim(),
secret: crypto.randomBytes(16).toString("hex"),
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,
plays: 0,
...cleanLevel(level),
};
levels.push(clean);
levels.push(record);
saveLevels(levels);
return sendJSON(res, 201, { id: clean.id });
return sendJSON(res, 201, { id: record.id, secret: record.secret });
});
}
if (req.method === "PUT" && idMatch) {
return readBody(req, res, level => {
const levels = loadLevels();
const existing = levels.find(l => l.id === idMatch[1]);
if (!existing) return sendJSON(res, 404, { error: "niveau introuvable" });
const provided = req.headers["x-level-secret"];
if (!existing.secret || !provided || provided !== existing.secret)
return sendJSON(res, 403, { error: "clé d'auteur invalide : tu ne peux modifier que tes propres niveaux" });
const err = validateLevel(level);
if (err) return sendJSON(res, 400, { error: err });
Object.assign(existing, cleanLevel(level), { updated: new Date().toISOString().slice(0, 10) });
saveLevels(levels);
return sendJSON(res, 200, { id: existing.id });
});
return;
}
sendJSON(res, 404, { error: "route inconnue" });
+10 -1
View File
@@ -98,7 +98,16 @@ const goodLevel = {
};
assert.strictEqual(srv.validateLevel(goodLevel), null);
assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé");
assert(srv.validateLevel({ ...goodLevel, monsters: [] }), "niveau sans monstre refusé");
assert(srv.validateLevel({ ...goodLevel, monsters: [] }), "niveau sans monstre ni porte ni sortie refusé");
// Portes : valides, et suffisantes comme objectif sans monstre
const door = { x: 4, y: 4, target: "abcdef012345" };
assert.strictEqual(srv.validateLevel({ ...goodLevel, doors: [door] }), null);
assert.strictEqual(srv.validateLevel({ ...goodLevel, monsters: [], doors: [door] }), null, "porte = objectif valable");
assert(srv.validateLevel({ ...goodLevel, doors: [{ x: 4, y: 4, target: "pas-un-id" }] }), "cible de porte invalide refusée");
assert(srv.validateLevel({ ...goodLevel, doors: [{ x: 0, y: 0, target: "abcdef012345" }] }), "porte dans un mur refusée");
const gridWithExit = goodGrid.map((r, y) => y === 5 ? r.slice(0, 5) + ">" + r.slice(6) : r);
assert.strictEqual(srv.validateLevel({ ...goodLevel, monsters: [], grid: gridWithExit }), null, "sortie = objectif valable");
assert(srv.validateLevel({ ...goodLevel, start: { x: 0, y: 0 } }), "départ dans un mur refusé");
assert(srv.validateLevel({ ...goodLevel, grid: goodGrid.slice(1) }), "grille tronquée refusée");
assert(srv.validateLevel({ ...goodLevel, monsters: [{ x: 0, y: 0, type: 0, tpl: 0 }] }), "monstre dans un mur refusé");