diff --git a/README.md b/README.md index 143fab7..9f86c06 100644 --- a/README.md +++ b/README.md @@ -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=`. ### API | Route | Description | |---|---| -| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb parties) | -| `GET /api/levels/` | 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, 1–60 monstres…) | +| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb portes, nb parties) | +| `GET /api/levels/` | 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/` | 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=`, backend node sans dépendance (`server.js`) avec validation et stockage JSON. diff --git a/css/style.css b/css/style.css index 4aacd65..50e9b3d 100644 --- a/css/style.css +++ b/css/style.css @@ -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; } diff --git a/index.html b/index.html index df2e228..9ab616c 100644 --- a/index.html +++ b/index.html @@ -51,8 +51,13 @@

Objets

+

Portes (vers un niveau publié)

+ +

Outils

+

Mes niveaux publiés

+
@@ -66,8 +71,9 @@
-

Clic / glisser : peindre la sélection · Un niveau publiable a un point de départ 🧌 et au moins un monstre.

-

La sortie ▼ est optionnelle : sans elle, il faut terrasser tous les monstres pour gagner.

+

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.

+

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.

+

Tes niveaux publiés restent modifiables depuis « Mes niveaux publiés » (la clé d'auteur est gardée par ton navigateur).

diff --git a/js/editor.js b/js/editor.js index 6ff4f12..20d3f91 100644 --- a/js/editor.js +++ b/js/editor.js @@ -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 = '

Aucun pour l\'instant — publie un niveau et il apparaîtra ici, modifiable à volonté.

'; + 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 = ""; + 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 = () => { diff --git a/js/game.js b/js/game.js index 819a1be..87baa8c 100644 --- a/js/game.js +++ b/js/game.js @@ -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 = diff --git a/server.js b/server.js index 45032a7..a321071 100644 --- a/server.js +++ b/server.js @@ -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" }); diff --git a/test/smoke.js b/test/smoke.js index f1e9747..0be277a 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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é");