2.1.0 : connexion multi-appareils (mot de passe + login) et tuning admin du bestiaire, de l'équipement et des trésors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 15:37:21 +02:00
co-authored by Claude Fable 5
parent 1a5d2c7317
commit 646a147774
8 changed files with 509 additions and 25 deletions
+37 -5
View File
@@ -117,8 +117,12 @@ commun à tous les joueurs, autoritaire côté serveur (`mp.js`) :
sortilège de race, potions, parchemins, équipement, entraînement en PI. sortilège de race, potions, parchemins, équipement, entraînement en PI.
- **À MountyHall on ne meurt jamais vraiment** : un troll terrassé réapparaît - **À MountyHall on ne meurt jamais vraiment** : un troll terrassé réapparaît
après un délai paramétrable, PV pleins, en conservant tout son avoir. après un délai paramétrable, PV pleins, en conservant tout son avoir.
- L'identité du troll (id + clé secrète) est gardée par le navigateur - **Connexion multi-appareils** : le troll vit côté serveur. À la création on
(localStorage) : on retrouve son personnage d'une session à l'autre. choisit un mot de passe (conseillé) ; « 🔑 Retrouver mon troll » permet ensuite
de le récupérer depuis n'importe quel appareil avec nom + mot de passe (les
noms sont uniques, le mot de passe est stocké hashé+salé). Sans mot de passe,
l'identité (id + clé secrète) reste gardée par le navigateur d'origine
(localStorage), comme avant.
- Le client se rafraîchit par **polling** (intervalle conseillé par le serveur, - Le client se rafraîchit par **polling** (intervalle conseillé par le serveur,
paramétrable) ; les autres trolls visibles apparaissent en bleu avec leur nom, paramétrable) ; les autres trolls visibles apparaissent en bleu avec leur nom,
les camouflés sont invisibles. les camouflés sont invisibles.
@@ -137,15 +141,31 @@ de trolls. Plus : vue d'ensemble (trolls avec dernière activité, monstres avec
leur DLA individuelle et leur prochaine action), derniers échos du monde, et leur DLA individuelle et leur prochaine action), derniers échos du monde, et
bouton « Régénérer le monde » (trolls conservés et replacés). bouton « Régénérer le monde » (trolls conservés et replacés).
L'admin peut aussi **retoucher les règles du monde partagé**, à chaud (les
nouveaux spawns/drops utilisent les valeurs retouchées ; le solo reste vanilla) :
- **Tuning du bestiaire** : tableau éditable par type de monstre (niveau, ATT,
ESQ, DEG, PV, armure physique/magique, VUE) — valeurs de base, avant gabarit
d'âge (Jeune/Vieux/Ancien/Mythique).
- **Tuning de l'équipement** : les 55 objets avec tous leurs bonus modifiables
(ATT/ESQ/DEG/REG/armure/VUE/PV fixes, RM/MM en %).
- **Tuning des potions & parchemins** : fourchette de puissance « niveau X »
(min/max) tirée à chaque drop, par trésor.
Les cases modifiées sont surlignées ; seuls les écarts au vanilla sont stockés
(`world.tuning`), et chaque catégorie se remet d'origine d'un clic.
### API multijoueur ### API multijoueur
- `POST /api/mp/join` `{name, race}``{id, secret, state}` - `POST /api/mp/join` `{name, race, password}``{id, secret, state}`
- `POST /api/mp/login` `{name, password}``{id, secret, state}` (retrouver
son troll depuis un autre appareil)
- `GET /api/mp/state?id=&secret=` → état visible (vue du troll) - `GET /api/mp/state?id=&secret=` → état visible (vue du troll)
- `POST /api/mp/action` `{id, secret, action}``move`, `attack`, `comp`, - `POST /api/mp/action` `{id, secret, action}``move`, `attack`, `comp`,
`sort`, `pickup`, `use`, `train` `sort`, `pickup`, `use`, `train`
- `GET /api/mp/info` → compteurs publics (trolls en ligne…) - `GET /api/mp/info` → compteurs publics (trolls en ligne…)
- `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `POST /api/mp/admin/reset` - `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `PUT /api/mp/admin/tuning`
(header `X-Admin-Token`) · `POST /api/mp/admin/reset` (header `X-Admin-Token`)
## Éditeur de niveaux & partage ## Éditeur de niveaux & partage
@@ -201,6 +221,18 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## Versions
- **2.1.0** (2026-06-12) — **Connexion multi-appareils** : à la création du troll
on choisit un mot de passe (stocké hashé+salé, jamais en clair) ; « 🔑 Retrouver
mon troll » (`POST /api/mp/login`) permet de récupérer son troll depuis
n'importe quel appareil avec nom + mot de passe. Les noms de troll deviennent
uniques (insensible à la casse). Structure prête pour une vraie inscription
plus tard. **Admin enrichie** (`PUT /api/mp/admin/tuning`, appliqué à chaud
aux nouveaux spawns/drops du monde partagé, solo vanilla) : bestiaire éditable
par type (niveau, ATT, ESQ, DEG, PV, armures phy/mag, VUE), les 55 objets
d'équipement avec tous leurs bonus modifiables, fourchette de puissance
« niveau X » min/max par potion et parchemin. Seuls les écarts au vanilla sont
stockés (`world.tuning`, persisté), cases modifiées surlignées, remise
d'origine par catégorie.
- **2.0.0** (2026-06-12) — **Multijoueur** : monde souterrain partagé et persistant, - **2.0.0** (2026-06-12) — **Multijoueur** : monde souterrain partagé et persistant,
autoritaire côté serveur (`mp.js`, node pur). Le monde vit en continu : chaque autoritaire côté serveur (`mp.js`, node pur). Le monde vit en continu : chaque
monstre agit à sa propre DLA paramétrable (fourchette min/max), erre ou attaque monstre agit à sa propre DLA paramétrable (fourchette min/max), erre ou attaque
+133 -1
View File
@@ -16,6 +16,9 @@
.admin-field input { width: 80px; padding: 3px; } .admin-field input { width: 80px; padding: 3px; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.82em; } .admin-table { width: 100%; border-collapse: collapse; font-size: 0.82em; }
.admin-table th, .admin-table td { border: 1px solid #999; padding: 2px 6px; text-align: left; } .admin-table th, .admin-table td { border: 1px solid #999; padding: 2px 6px; text-align: left; }
.admin-table input[type=number] { width: 52px; padding: 1px 2px; border: 1px solid #aaa; background: #fff; }
.admin-table input.tuned { background: #fff3b0; border-color: #c90; font-weight: bold; }
.tune-slot { margin: 10px 0 4px; font-weight: bold; }
.admin-log { max-height: 220px; overflow-y: auto; font-size: 0.82em; } .admin-log { max-height: 220px; overflow-y: auto; font-size: 0.82em; }
.admin-msg { font-weight: bold; margin: 8px 0; } .admin-msg { font-weight: bold; margin: 8px 0; }
#admin-login { max-width: 480px; margin: 60px auto; } #admin-login { max-width: 480px; margin: 60px auto; }
@@ -61,6 +64,37 @@
<h2>👺 Monstres</h2> <h2>👺 Monstres</h2>
<div id="admin-monsters"></div> <div id="admin-monsters"></div>
</div> </div>
<div class="admin-card" style="margin-top:16px">
<h2>🐗 Tuning du bestiaire</h2>
<p><small>Valeurs de base par type (avant gabarit Jeune/Vieux/Ancien/Mythique).
Les cases <b>modifiées</b> (fond jaune) s'appliquent aux <b>prochains spawns</b> du monde
partagé — le solo reste vanilla. Vider/remettre la valeur d'origine pour annuler.</small></p>
<div id="tune-monsters"></div>
<button id="tune-monsters-save" class="big-btn">💾 Appliquer le bestiaire</button>
<button id="tune-monsters-reset" class="menu-btn">↩️ Tout remettre d'origine</button>
</div>
<div class="admin-card" style="margin-top:16px">
<h2>🧪 Tuning des potions &amp; parchemins</h2>
<p><small>Fourchette de puissance « niveau X » de chaque trésor (tirée au drop).
S'applique aux prochains drops du monde partagé.</small></p>
<div class="admin-grid">
<div><h3>Potions</h3><div id="tune-potions"></div></div>
<div><h3>Parchemins</h3><div id="tune-scrolls"></div></div>
</div>
<button id="tune-treasures-save" class="big-btn">💾 Appliquer les trésors</button>
<button id="tune-treasures-reset" class="menu-btn">↩️ Tout remettre d'origine</button>
</div>
<div class="admin-card" style="margin-top:16px">
<h2>⚔️ Tuning de l'équipement</h2>
<p><small>Bonus/malus de chaque objet (physiques, fixes sur les jets ; RM/MM en %).
S'applique aux prochains drops du monde partagé.</small></p>
<div id="tune-gear"></div>
<button id="tune-gear-save" class="big-btn">💾 Appliquer l'équipement</button>
<button id="tune-gear-reset" class="menu-btn">↩️ Tout remettre d'origine</button>
</div>
</div> </div>
</div> </div>
@@ -140,9 +174,98 @@ function renderOverview(ov) {
</tr>`).join("")}</table>`; </tr>`).join("")}</table>`;
} }
/* ---------- Tuning : bestiaire, trésors, équipement ---------- */
const MONSTER_LABELS = { level: "Niv.", att: "ATT", esq: "ESQ", deg: "DEG", pv: "PV", armor: "Arm. phy", armorMag: "Arm. mag", vue: "VUE" };
const GEAR_LABELS = { att: "ATT", esq: "ESQ", deg: "DEG", reg: "REG", arm: "Arm. phy", vue: "VUE", pv: "PV", rmPct: "RM %", mmPct: "MM %" };
function tuneInput(cat, id, key, def, cur) {
const tuned = cur !== undefined && Number(cur) !== Number(def);
return `<input type="number" class="${tuned ? "tuned" : ""}" data-cat="${cat}" data-id="${id}" data-key="${key}" data-def="${def}" value="${cur !== undefined ? cur : def}">`;
}
function markTuned(e) {
if (e.target.dataset.def === undefined) return;
e.target.classList.toggle("tuned", Number(e.target.value) !== Number(e.target.dataset.def));
}
function renderTuning(defaults, tuning) {
const mKeys = defaults.monsterKeys;
document.getElementById("tune-monsters").innerHTML = `<table class="admin-table">
<tr><th>Type</th>${mKeys.map(k => `<th>${MONSTER_LABELS[k]}</th>`).join("")}</tr>
${defaults.monsters.map(m => `<tr><td>${m.emoji} ${m.name}${m.boss ? " (boss)" : ""}</td>` +
mKeys.map(k => `<td>${tuneInput("monsters", m.name, k, m[k], (tuning.monsters[m.name] || {})[k])}</td>`).join("") +
`</tr>`).join("")}</table>`;
const treasureTable = (cat, list) => `<table class="admin-table">
<tr><th>Trésor</th><th>X min</th><th>X max</th></tr>
${list.map(t => {
const cur = tuning[cat][t.id] || [];
return `<tr><td>${t.emoji} ${t.name}</td>
<td>${tuneInput(cat, t.id, "min", t.min, cur[0])}</td>
<td>${tuneInput(cat, t.id, "max", t.max, cur[1])}</td></tr>`;
}).join("")}</table>`;
document.getElementById("tune-potions").innerHTML = treasureTable("potions", defaults.potions);
document.getElementById("tune-scrolls").innerHTML = treasureTable("scrolls", defaults.scrolls);
const slots = {};
for (const it of defaults.gear) (slots[it.slot] = slots[it.slot] || []).push(it);
document.getElementById("tune-gear").innerHTML = Object.entries(slots).map(([slot, list]) => `
<div class="tune-slot">${slot.charAt(0).toUpperCase() + slot.slice(1)}s</div>
<table class="admin-table">
<tr><th>Objet</th>${defaults.gearKeys.map(k => `<th>${GEAR_LABELS[k]}</th>`).join("")}</tr>
${list.map(it => {
const id = `${it.slot}/${it.name}`;
const cur = tuning.gear[id] || {};
return `<tr><td>${it.emoji} ${it.name}${it.twoHanded ? " (2 mains)" : ""}</td>` +
defaults.gearKeys.map(k => `<td>${tuneInput("gear", id, k, it.mods[k], cur[k])}</td>`).join("") + `</tr>`;
}).join("")}</table>`).join("");
for (const input of document.querySelectorAll("#tune-monsters input, #tune-potions input, #tune-scrolls input, #tune-gear input")) {
input.addEventListener("input", markTuned);
}
}
/* Collecte les écarts à la valeur d'origine d'une catégorie. */
function collectTuning(cat) {
const out = {};
for (const input of document.querySelectorAll(`input[data-cat="${cat}"]`)) {
const v = Number(input.value), def = Number(input.dataset.def);
if (!Number.isFinite(v) || v === def) continue;
const id = input.dataset.id, key = input.dataset.key;
(out[id] = out[id] || {})[key] = v;
}
return out;
}
/* Potions/parchemins : chaque entrée doit devenir [min, max] complet. */
function collectTreasureTuning(cat, defaults) {
const deltas = collectTuning(cat);
const out = {};
for (const [id, vals] of Object.entries(deltas)) {
const def = defaults.find(t => t.id === id);
out[id] = [vals.min !== undefined ? vals.min : def.min, vals.max !== undefined ? vals.max : def.max];
}
return out;
}
async function saveTuning(patch, label) {
try {
await api("PUT", "api/mp/admin/tuning", patch);
$("admin-msg").textContent = `${label} appliqué à chaud.`;
await refresh(true);
} catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; }
}
let LAST_OV = null;
async function refresh(withForm = false) { async function refresh(withForm = false) {
const ov = await api("GET", "api/mp/admin"); const ov = await api("GET", "api/mp/admin");
if (withForm) renderForm(ov.config, ov.bounds); LAST_OV = ov;
if (withForm) {
renderForm(ov.config, ov.bounds);
renderTuning(ov.defaults, ov.tuning);
}
renderOverview(ov); renderOverview(ov);
} }
@@ -172,6 +295,15 @@ document.addEventListener("DOMContentLoaded", () => {
await refresh(true); await refresh(true);
} catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; } } catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; }
}; };
$("tune-monsters-save").onclick = () => saveTuning({ monsters: collectTuning("monsters") }, "Bestiaire");
$("tune-monsters-reset").onclick = () => saveTuning({ monsters: {} }, "Bestiaire d'origine");
$("tune-treasures-save").onclick = () => saveTuning({
potions: collectTreasureTuning("potions", LAST_OV.defaults.potions),
scrolls: collectTreasureTuning("scrolls", LAST_OV.defaults.scrolls),
}, "Trésors");
$("tune-treasures-reset").onclick = () => saveTuning({ potions: {}, scrolls: {} }, "Trésors d'origine");
$("tune-gear-save").onclick = () => saveTuning({ gear: collectTuning("gear") }, "Équipement");
$("tune-gear-reset").onclick = () => saveTuning({ gear: {} }, "Équipement d'origine");
$("admin-reset").onclick = async () => { $("admin-reset").onclick = async () => {
if (!confirm("Régénérer la carte, les monstres et les trésors ? (les trolls sont conservés et replacés)")) return; if (!confirm("Régénérer la carte, les monstres et les trésors ? (les trolls sont conservés et replacés)")) return;
try { try {
+11
View File
@@ -145,6 +145,17 @@
<button id="mp-leave" class="menu-btn">← Quitter</button> <button id="mp-leave" class="menu-btn">← Quitter</button>
</div> </div>
</header> </header>
<div id="mp-join" class="create-box hidden">
<h2>Rejoindre le Monde Souterrain</h2>
<p class="lore">Ton troll vit sur le serveur : avec un mot de passe, tu peux
le retrouver depuis n'importe quel appareil.</p>
<input type="text" id="mp-join-name" placeholder="Nom de ton Trõll" maxlength="20">
<input type="password" id="mp-join-pass" placeholder="Mot de passe (conseillé)" maxlength="50">
<p class="lore">Race choisie à l'accueil : <b id="mp-join-race"></b></p>
<button id="mp-join-create" class="big-btn">⚔️ Créer ce troll</button>
<button id="mp-join-login" class="big-btn">🔑 Retrouver mon troll</button>
<div id="mp-join-msg"></div>
</div>
<div id="layout"> <div id="layout">
<aside id="panel-left"> <aside id="panel-left">
<h2 id="mp-troll-title"></h2> <h2 id="mp-troll-title"></h2>
+1 -1
View File
@@ -4,7 +4,7 @@
"use strict"; "use strict";
const APP_VERSION = "2.0.0"; const APP_VERSION = "2.1.0";
/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; const START_COMP_PCT = 90;
+51 -8
View File
@@ -29,11 +29,22 @@ function mpSaveIdentity(id, secret) {
/* ---------- Réseau ---------- */ /* ---------- Réseau ---------- */
async function mpJoin() { async function mpJoin(name, password) {
const name = document.getElementById("troll-name").value.trim() || "Trõllinet";
const res = await fetch("api/mp/join", { const res = await fetch("api/mp/join", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, race: selectedRace }), body: JSON.stringify({ name, race: selectedRace, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "connexion refusée");
MP.id = data.id; MP.secret = data.secret;
mpSaveIdentity(data.id, data.secret);
return data.state;
}
async function mpLogin(name, password) {
const res = await fetch("api/mp/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "connexion refusée"); if (!res.ok) throw new Error(data.error || "connexion refusée");
@@ -74,6 +85,18 @@ function mpToast(msg) {
/* ---------- Entrée / sortie de l'écran ---------- */ /* ---------- Entrée / sortie de l'écran ---------- */
function mpShowJoin(show) {
document.getElementById("mp-join").classList.toggle("hidden", !show);
// #layout et #bottom-row existent aussi dans l'écran solo : on scope au multi
document.querySelector("#screen-mp #layout").classList.toggle("hidden", show);
document.querySelector("#screen-mp #bottom-row").classList.toggle("hidden", show);
if (show) {
document.getElementById("mp-join-name").value = document.getElementById("troll-name").value.trim() || "Trõllinet";
document.getElementById("mp-join-race").textContent = `${RACES[selectedRace].emoji} ${selectedRace}`;
document.getElementById("mp-join-msg").textContent = "";
}
}
async function mpEnter() { async function mpEnter() {
document.getElementById("screen-create").classList.add("hidden"); document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-mp").classList.remove("hidden"); document.getElementById("screen-mp").classList.remove("hidden");
@@ -84,7 +107,8 @@ async function mpEnter() {
MP.id = saved.id; MP.secret = saved.secret; MP.id = saved.id; MP.secret = saved.secret;
MP.state = await mpFetchState(); MP.state = await mpFetchState();
} }
if (!MP.state) MP.state = await mpJoin(); if (!MP.state) { mpShowJoin(true); return; } // pas d'identité valable : créer ou retrouver
mpShowJoin(false);
mpRender(); mpRender();
mpSchedule(); mpSchedule();
} catch (e) { } catch (e) {
@@ -93,6 +117,22 @@ async function mpEnter() {
} }
} }
/* Création ou reconnexion depuis le panneau « Rejoindre ». */
async function mpJoinSubmit(isLogin) {
const name = document.getElementById("mp-join-name").value.trim() || "Trõllinet";
const password = document.getElementById("mp-join-pass").value;
const msg = document.getElementById("mp-join-msg");
try {
MP.state = isLogin ? await mpLogin(name, password) : await mpJoin(name, password);
MP.seen = new Set();
mpShowJoin(false);
mpRender();
mpSchedule();
} catch (e) {
msg.textContent = `⚠️ ${e.message}`;
}
}
function mpLeave() { function mpLeave() {
if (MP.timer) { clearTimeout(MP.timer); MP.timer = null; } if (MP.timer) { clearTimeout(MP.timer); MP.timer = null; }
document.getElementById("screen-mp").classList.add("hidden"); document.getElementById("screen-mp").classList.add("hidden");
@@ -106,12 +146,13 @@ function mpSchedule() {
if (document.getElementById("screen-mp").classList.contains("hidden")) return; if (document.getElementById("screen-mp").classList.contains("hidden")) return;
try { try {
const st = await mpFetchState(); const st = await mpFetchState();
if (!st) { // identité refusée : on retente une création if (!st) { // identité refusée (reset serveur ?) : repasser par le panneau
localStorage.removeItem(MP_KEY); localStorage.removeItem(MP_KEY);
MP.state = await mpJoin(); MP.state = null;
} else { mpShowJoin(true);
MP.state = st; return;
} }
MP.state = st;
mpRender(); mpRender();
} catch { /* on retentera au prochain tour */ } } catch { /* on retentera au prochain tour */ }
mpSchedule(); mpSchedule();
@@ -316,6 +357,8 @@ if (typeof document !== "undefined") {
const btn = document.getElementById("btn-mp"); const btn = document.getElementById("btn-mp");
if (btn) btn.onclick = mpEnter; if (btn) btn.onclick = mpEnter;
document.getElementById("mp-leave").onclick = mpLeave; document.getElementById("mp-leave").onclick = mpLeave;
document.getElementById("mp-join-create").onclick = () => mpJoinSubmit(false);
document.getElementById("mp-join-login").onclick = () => mpJoinSubmit(true);
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (document.getElementById("screen-mp").classList.contains("hidden")) return; if (document.getElementById("screen-mp").classList.contains("hidden")) return;
+182 -9
View File
@@ -51,6 +51,63 @@ const CONFIG_BOUNDS = {
maxTrolls: [1, 200], logSize: [20, 500], maxTrolls: [1, 200], logSize: [20, 500],
}; };
/* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ----------
* world.tuning ne stocke que les écarts à la valeur de base (vanilla). Il
* s'applique aux nouveaux spawns/drops du monde partagé — le solo reste vanilla. */
const MONSTER_TUNE_KEYS = ["level", "att", "esq", "deg", "pv", "armor", "armorMag", "vue"];
const MONSTER_TUNE_BOUNDS = { level: [1, 99], att: [1, 99], esq: [1, 99], deg: [1, 99], pv: [1, 999], armor: [0, 99], armorMag: [0, 99], vue: [1, 30] };
const GEAR_TUNE_KEYS = ["att", "esq", "deg", "reg", "arm", "vue", "pv", "rmPct", "mmPct"];
const GEAR_TUNE_BOUNDS = [-100, 100];
const POWER_BOUNDS = [0, 200];
/* Fourchettes de puissance « niveau X » par défaut (Mountypedia). La Longue-Vue
* vanilla tire dans {1,2,3,5,8} : sans override on garde le tirage officiel. */
const POTION_POWER_DEFAULTS = {
biskot: [0, 0], doverPowa: [11, 100], bonneBouffe: [3, 7], corruption: [3, 7],
fertilite: [3, 7], feu: [3, 7], longueVue: [1, 8], kouleMann: [1, 5],
djhinTonik: [1, 5], glacier: [3, 7], calvok: [2, 6], rhume: [1, 2],
grippe: [3, 4], pneumonie: [5, 5], cervelle: [2, 6], chronometre: [1, 5],
metomol: [1, 5], guerison: [1, 5], painture: [1, 5], pufPuff: [0, 2],
sangToh: [1, 5], sinneKhole: [11, 100], toxine: [1, 5], voiputrin: [1, 5],
zetCrak: [1, 5],
};
const SCROLL_POWER_DEFAULTS = Object.fromEntries(sc.SCROLL_IDS.map(id => [id, [1, 5]]));
function emptyTuning() {
return { monsters: {}, gear: {}, potions: {}, scrolls: {} };
}
function randRange([min, max]) {
const lo = Math.min(min, max), hi = Math.max(min, max);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}
/* Types de monstres avec le tuning admin appliqué (avant gabarit d'âge). */
function tunedMonsterTypes(world) {
const tune = (world.tuning && world.tuning.monsters) || {};
return [...g.MONSTER_TYPES, g.BOSS].map(t => tune[t.name] ? { ...t, ...tune[t.name] } : t);
}
function tunedRandomPotion(world) {
const item = p.makeRandomPotion();
const range = (world.tuning && world.tuning.potions || {})[item.potionId];
return range ? p.makePotionItem(item.potionId, randRange(range)) : item;
}
function tunedRandomScroll(world) {
const item = sc.makeRandomScroll();
const range = (world.tuning && world.tuning.scrolls || {})[item.scrollId];
return range ? sc.makeScrollItem(item.scrollId, randRange(range)) : item;
}
function applyGearTuning(world, item) {
if (!item || item.kind !== "gear") return item;
const tune = (world.tuning && world.tuning.gear || {})[`${item.slot}/${item.name}`];
if (tune) item.mods = { ...item.mods, ...tune };
return item;
}
/* ---------- Création du monde ---------- */ /* ---------- Création du monde ---------- */
function randomFloor(world, taken = null) { function randomFloor(world, taken = null) {
@@ -81,7 +138,16 @@ function monsterDlaMs(config) {
function spawnMonster(world, now = Date.now()) { function spawnMonster(world, now = Date.now()) {
const pos = randomFloor(world); const pos = randomFloor(world);
if (!pos) return null; if (!pos) return null;
const m = g.makeMonster(world.config.worldDepth, pos.x, pos.y); // même logique que makeMonster (pool par profondeur + gabarit d'âge),
// mais sur les types tunés par l'admin
const depth = world.config.worldDepth;
const types = tunedMonsterTypes(world).filter(t => !t.boss);
const pool = types.filter(t => t.level <= depth + 1 && t.level >= Math.max(1, depth - 2));
const list = pool.length ? pool : types;
const type = list[Math.floor(Math.random() * list.length)];
const tplMax = Math.min(g.TEMPLATES.length - 1, depth - 1);
const tpl = g.TEMPLATES[Math.floor(Math.random() * (tplMax + 1))];
const m = g.applyTemplate(type, tpl, pos.x, pos.y);
m.id = world.nextId++; m.id = world.nextId++;
m.dlaMs = Math.round(monsterDlaMs(world.config)); m.dlaMs = Math.round(monsterDlaMs(world.config));
m.nextDla = now + m.dlaMs; m.nextDla = now + m.dlaMs;
@@ -94,9 +160,9 @@ function spawnItem(world) {
if (!pos) return null; if (!pos) return null;
const r = Math.random(); const r = Math.random();
const depth = world.config.worldDepth; const depth = world.config.worldDepth;
const item = r < 0.34 ? p.makeRandomPotion() const item = r < 0.34 ? tunedRandomPotion(world)
: r < 0.48 ? sc.makeRandomScroll() : r < 0.48 ? tunedRandomScroll(world)
: r < 0.78 ? gearLib.makeRandomGear(depth) : r < 0.78 ? applyGearTuning(world, gearLib.makeRandomGear(depth))
: { kind: "gold", gold: g.rollDice(depth, 6).total * 10, emoji: "💰" }; : { kind: "gold", gold: g.rollDice(depth, 6).total * 10, emoji: "💰" };
if (item.kind === "gold") item.name = `${item.gold} Mountyzédons`; if (item.kind === "gold") item.name = `${item.gold} Mountyzédons`;
world.items.push({ ...item, x: pos.x, y: pos.y }); world.items.push({ ...item, x: pos.x, y: pos.y });
@@ -107,6 +173,7 @@ function createWorld(config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config }; const cfg = { ...DEFAULT_CONFIG, ...config };
const world = { const world = {
config: cfg, config: cfg,
tuning: emptyTuning(),
grid: g.generateCavern(cfg.mapW, cfg.mapH), grid: g.generateCavern(cfg.mapW, cfg.mapH),
trolls: {}, trolls: {},
monsters: [], monsters: [],
@@ -137,17 +204,28 @@ function privLog(troll, msg, cls = "info") {
/* ---------- Trolls ---------- */ /* ---------- Trolls ---------- */
function newTroll(world, name, race, now = Date.now()) { function hashPass(salt, password) {
return crypto.createHash("sha256").update(`${salt}:${password}`).digest("hex");
}
/* Le troll vit côté serveur : le mot de passe (optionnel mais conseillé)
* permet de le retrouver depuis n'importe quel appareil via login(). */
function newTroll(world, name, race, password = "", now = Date.now()) {
if (!g.RACES[race]) return { error: "race inconnue" }; if (!g.RACES[race]) return { error: "race inconnue" };
name = String(name || "").trim().slice(0, 20) || "Trõllinet"; name = String(name || "").trim().slice(0, 20) || "Trõllinet";
password = String(password || "");
const taken = Object.values(world.trolls).some(t => t.name.toLowerCase() === name.toLowerCase());
if (taken) return { error: "ce nom de troll est déjà pris — choisis-en un autre ou retrouve ton troll avec ton mot de passe" };
const living = Object.keys(world.trolls).length; const living = Object.keys(world.trolls).length;
if (living >= world.config.maxTrolls) return { error: "le Hall est plein (max atteint)" }; if (living >= world.config.maxTrolls) return { error: "le Hall est plein (max atteint)" };
const pos = randomFloor(world); const pos = randomFloor(world);
if (!pos) return { error: "pas de place dans la caverne" }; if (!pos) return { error: "pas de place dans la caverne" };
const salt = crypto.randomBytes(8).toString("hex");
const s = g.RACES[race].stats; const s = g.RACES[race].stats;
const troll = { const troll = {
id: crypto.randomBytes(6).toString("hex"), id: crypto.randomBytes(6).toString("hex"),
secret: crypto.randomBytes(16).toString("hex"), secret: crypto.randomBytes(16).toString("hex"),
salt, passHash: password ? hashPass(salt, password) : null,
name, race, x: pos.x, y: pos.y, name, race, x: pos.x, y: pos.y,
att: s.att, esq: s.esq, deg: s.deg, reg: s.reg, att: s.att, esq: s.esq, deg: s.deg, reg: s.reg,
pv: s.pvMax, pvMax: s.pvMax, vue: s.vue, pv: s.pvMax, pvMax: s.pvMax, vue: s.vue,
@@ -177,6 +255,18 @@ function authTroll(world, id, secret) {
return t; return t;
} }
/* Retrouver son troll par nom + mot de passe (multi-appareils). */
function login(world, name, password) {
name = String(name || "").trim().toLowerCase();
password = String(password || "");
const t = Object.values(world.trolls).find(t => t.name.toLowerCase() === name);
if (!t) return { error: "aucun troll de ce nom" };
if (!t.passHash) return { error: "ce troll n'a pas de mot de passe — il n'est accessible que depuis son navigateur d'origine" };
if (hashPass(t.salt, password) !== t.passHash) return { error: "mot de passe incorrect" };
t.lastSeen = Date.now();
return { troll: t };
}
const chebyshev = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); const chebyshev = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
function effTrollMP(t) { function effTrollMP(t) {
@@ -342,8 +432,8 @@ function killMonsterMP(world, t, m) {
worldLog(world, `⚔️ ${t.name} a terrassé ${m.emoji} ${m.name} !`, "combat"); worldLog(world, `⚔️ ${t.name} a terrassé ${m.emoji} ${m.name} !`, "combat");
if (Math.random() < 0.4 && !world.items.some(i => i.x === m.x && i.y === m.y)) { if (Math.random() < 0.4 && !world.items.some(i => i.x === m.x && i.y === m.y)) {
const lr = Math.random(); const lr = Math.random();
const drop = lr < 0.4 ? { ...p.makeRandomPotion(), x: m.x, y: m.y } const drop = lr < 0.4 ? { ...tunedRandomPotion(world), x: m.x, y: m.y }
: lr < 0.65 ? { ...sc.makeRandomScroll(), x: m.x, y: m.y } : lr < 0.65 ? { ...tunedRandomScroll(world), x: m.x, y: m.y }
: { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y }; : { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y };
world.items.push(drop); world.items.push(drop);
privLog(t, `${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info"); privLog(t, `${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info");
@@ -725,6 +815,8 @@ function adminOverview(world, now = Date.now()) {
return { return {
config: world.config, config: world.config,
bounds: CONFIG_BOUNDS, bounds: CONFIG_BOUNDS,
tuning: world.tuning || emptyTuning(),
defaults: adminDefaults(),
uptime: now - world.createdAt, uptime: now - world.createdAt,
trolls: Object.values(world.trolls).map(t => ({ trolls: Object.values(world.trolls).map(t => ({
id: t.id, name: t.name, race: t.race, level: g.levelFromTotalPI(t.totalPI), id: t.id, name: t.name, race: t.race, level: g.levelFromTotalPI(t.totalPI),
@@ -766,6 +858,85 @@ function adminSetConfig(world, patch, now = Date.now()) {
return cfg; return cfg;
} }
/* Valeurs de base (vanilla) pour construire les formulaires de tuning admin. */
function adminDefaults() {
return {
monsters: [...g.MONSTER_TYPES, g.BOSS].map(t => ({
name: t.name, emoji: t.emoji, boss: !!t.boss,
...Object.fromEntries(MONSTER_TUNE_KEYS.map(k => [k, t[k] || 0])),
})),
monsterKeys: MONSTER_TUNE_KEYS,
gear: Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(def => ({
slot, name: def.name, emoji: def.emoji, twoHanded: !!def.twoHanded,
mods: Object.fromEntries(GEAR_TUNE_KEYS.map(k => [k, def.mods[k] || 0])),
}))),
gearKeys: GEAR_TUNE_KEYS,
potions: p.POTION_IDS.map(id => ({
id, name: p.POTION_DEFS[id].name, emoji: p.POTION_DEFS[id].emoji,
min: POTION_POWER_DEFAULTS[id][0], max: POTION_POWER_DEFAULTS[id][1],
})),
scrolls: sc.SCROLL_IDS.map(id => ({
id, name: sc.SCROLL_DEFS[id].name, emoji: sc.SCROLL_DEFS[id].emoji,
min: SCROLL_POWER_DEFAULTS[id][0], max: SCROLL_POWER_DEFAULTS[id][1],
})),
};
}
const clampInt = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v)));
/* Applique un patch de tuning : chaque catégorie fournie remplace l'existante
* (l'admin envoie l'ensemble de ses écarts). Tout est validé et borné. */
function adminSetTuning(world, patch) {
world.tuning = world.tuning || emptyTuning();
const known = {
monsters: new Set([...g.MONSTER_TYPES, g.BOSS].map(t => t.name)),
gear: new Set(Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(d => `${slot}/${d.name}`))),
potions: new Set(p.POTION_IDS),
scrolls: new Set(sc.SCROLL_IDS),
};
if (patch.monsters && typeof patch.monsters === "object") {
const out = {};
for (const [name, vals] of Object.entries(patch.monsters)) {
if (!known.monsters.has(name) || typeof vals !== "object") continue;
const entry = {};
for (const k of MONSTER_TUNE_KEYS) {
const v = Number(vals[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, MONSTER_TUNE_BOUNDS[k][0], MONSTER_TUNE_BOUNDS[k][1]);
}
if (Object.keys(entry).length) out[name] = entry;
}
world.tuning.monsters = out;
}
if (patch.gear && typeof patch.gear === "object") {
const out = {};
for (const [key, mods] of Object.entries(patch.gear)) {
if (!known.gear.has(key) || typeof mods !== "object") continue;
const entry = {};
for (const k of GEAR_TUNE_KEYS) {
const v = Number(mods[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, GEAR_TUNE_BOUNDS[0], GEAR_TUNE_BOUNDS[1]);
}
if (Object.keys(entry).length) out[key] = entry;
}
world.tuning.gear = out;
}
for (const cat of ["potions", "scrolls"]) {
if (!patch[cat] || typeof patch[cat] !== "object") continue;
const out = {};
for (const [id, range] of Object.entries(patch[cat])) {
if (!known[cat].has(id) || !Array.isArray(range)) continue;
const lo = Number(range[0]), hi = Number(range[1]);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
out[id] = [
clampInt(Math.min(lo, hi), POWER_BOUNDS[0], POWER_BOUNDS[1]),
clampInt(Math.max(lo, hi), POWER_BOUNDS[0], POWER_BOUNDS[1]),
];
}
world.tuning[cat] = out;
}
return world.tuning;
}
/* Régénère le monde (carte, monstres, trésors) en conservant trolls et config ; /* Régénère le monde (carte, monstres, trésors) en conservant trolls et config ;
* les trolls sont replacés. */ * les trolls sont replacés. */
function adminResetWorld(world) { function adminResetWorld(world) {
@@ -799,14 +970,16 @@ function loadWorld(file) {
const world = JSON.parse(fs.readFileSync(file, "utf8")); const world = JSON.parse(fs.readFileSync(file, "utf8"));
if (!world || !world.grid || !world.trolls) return null; if (!world || !world.grid || !world.trolls) return null;
world.config = { ...DEFAULT_CONFIG, ...world.config }; world.config = { ...DEFAULT_CONFIG, ...world.config };
world.tuning = { ...emptyTuning(), ...world.tuning };
return world; return world;
} catch { return null; } } catch { return null; }
} }
module.exports = { module.exports = {
DEFAULT_CONFIG, CONFIG_BOUNDS, DEFAULT_CONFIG, CONFIG_BOUNDS,
createWorld, tick, newTroll, authTroll, action, stateFor, createWorld, tick, newTroll, authTroll, login, action, stateFor,
adminOverview, adminSetConfig, adminResetWorld, adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld,
saveWorld, loadWorld, saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog, spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
tunedRandomPotion, tunedRandomScroll, applyGearTuning,
}; };
+20 -1
View File
@@ -161,7 +161,7 @@ function handleMP(req, res, url) {
if (req.method === "POST" && url.pathname === "/api/mp/join") { if (req.method === "POST" && url.pathname === "/api/mp/join") {
return readBody(req, res, body => { return readBody(req, res, body => {
const r = mp.newTroll(WORLD, body.name, body.race); const r = mp.newTroll(WORLD, body.name, body.race, body.password);
if (r.error) return sendJSON(res, 400, { error: r.error }); if (r.error) return sendJSON(res, 400, { error: r.error });
worldDirty = true; worldDirty = true;
return sendJSON(res, 201, { return sendJSON(res, 201, {
@@ -171,6 +171,17 @@ function handleMP(req, res, url) {
}); });
} }
if (req.method === "POST" && url.pathname === "/api/mp/login") {
return readBody(req, res, body => {
const r = mp.login(WORLD, body.name, body.password);
if (r.error) return sendJSON(res, 403, { error: r.error });
return sendJSON(res, 200, {
id: r.troll.id, secret: r.troll.secret,
state: mp.stateFor(WORLD, r.troll),
});
});
}
if (req.method === "GET" && url.pathname === "/api/mp/state") { if (req.method === "GET" && url.pathname === "/api/mp/state") {
const t = mp.authTroll(WORLD, url.searchParams.get("id"), url.searchParams.get("secret")); 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" }); if (!t) return sendJSON(res, 403, { error: "troll inconnu ou clé invalide" });
@@ -211,6 +222,14 @@ function handleMP(req, res, url) {
return sendJSON(res, 200, cfg); return sendJSON(res, 200, cfg);
}); });
} }
if (req.method === "PUT" && url.pathname === "/api/mp/admin/tuning") {
return readBody(req, res, body => {
const tuning = mp.adminSetTuning(WORLD, body);
worldDirty = true;
mp.worldLog(WORLD, "⚗️ Les Dieux Trõlls ont retouché bêtes et trésors.");
return sendJSON(res, 200, tuning);
});
}
if (req.method === "POST" && url.pathname === "/api/mp/admin/reset") { if (req.method === "POST" && url.pathname === "/api/mp/admin/reset") {
mp.adminResetWorld(WORLD); mp.adminResetWorld(WORLD);
worldDirty = true; worldDirty = true;
+74
View File
@@ -217,6 +217,80 @@ function makeWorld(over = {}) {
assert(w.monsters.length === w.config.monsterTarget, "monstres régénérés"); assert(w.monsters.length === w.config.monsterTarget, "monstres régénérés");
} }
// Identité côté serveur : mot de passe, login multi-appareils, unicité du nom
{
const w = makeWorld();
const r = mp.newTroll(w, "Perco", "Durakuir", "tr0ll!");
assert(r.troll, "troll avec mot de passe créé");
assert(!("password" in r.troll), "le mot de passe en clair n'est pas stocké");
assert(r.troll.passHash && r.troll.passHash !== "tr0ll!", "hash stocké");
assert(mp.newTroll(w, "perco", "Skrim").error, "nom déjà pris (insensible à la casse)");
// login depuis « un autre appareil »
const l1 = mp.login(w, "Perco", "tr0ll!");
assert(l1.troll === r.troll, "login OK → même troll");
assert(mp.login(w, "Perco", "mauvais").error, "mauvais mot de passe refusé");
assert(mp.login(w, "Inconnu", "x").error, "troll inconnu refusé");
// troll sans mot de passe : pas de login possible
mp.newTroll(w, "SansPass", "Skrim");
assert(mp.login(w, "SansPass", "").error, "pas de login sans mot de passe");
// l'état ne fuit ni hash ni sel
const st = JSON.stringify(mp.stateFor(w, r.troll));
assert(!st.includes(r.troll.passHash) && !st.includes(r.troll.salt), "pas de fuite du hash/sel");
}
// Tuning admin : bestiaire appliqué aux nouveaux spawns
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9, pv: 77, armorMag: 3 } } });
// spawn forcé jusqu'à obtenir un Gobelin
let gob = null;
for (let i = 0; i < 300 && !gob; i++) {
const m = mp.spawnMonster(w);
if (m && m.name.includes("Gobelin")) gob = m;
}
assert(gob, "un Gobelin a fini par apparaître");
// gabarit « Jeune/normal » à depth 1 : mult 0.7 ou 1.0 → att 6 ou 9, pv 54 ou 77
assert([6, 9].includes(gob.att), "ATT tunée (×gabarit) : " + gob.att);
assert([54, 77].includes(gob.pv), "PV tunés (×gabarit) : " + gob.pv);
assert.strictEqual(gob.armorMag, 3, "armure magique tunée (gabarit ne la multiplie pas)");
// bornage et noms inconnus ignorés
mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } });
assert(!w.tuning.monsters.Dragon, "type inconnu ignoré");
assert.strictEqual(w.tuning.monsters.Gobelin.att, 99, "ATT bornée à 99");
// retour au vanilla
mp.adminSetTuning(w, { monsters: {} });
assert.strictEqual(Object.keys(w.tuning.monsters).length, 0, "bestiaire d'origine restauré");
}
// Tuning admin : puissance des potions/parchemins et bonus d'équipement
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0 });
mp.adminSetTuning(w, {
potions: { guerison: [9, 9], nimporte: [1, 2] },
scrolls: { runeExplosive: [7, 3] }, // inversé : doit devenir [3, 7]
gear: { "arme/Gourdin": { deg: 12 }, "arme/Excalibur": { deg: 99 } },
});
assert(!w.tuning.potions.nimporte, "potion inconnue ignorée");
assert.deepStrictEqual(w.tuning.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre");
assert(!w.tuning.gear["arme/Excalibur"], "objet inconnu ignoré");
// la puissance tirée respecte l'override
for (let i = 0; i < 100; i++) {
const it = mp.tunedRandomPotion(w);
if (it.potionId === "guerison") assert.strictEqual(it.power, 9, "Guérison forcée à X=9");
}
// l'équipement tuné garde ses autres mods
const gearLib = require("../js/gear.js");
const club = mp.applyGearTuning(w, gearLib.gearItemByName("arme", "Gourdin"));
assert.strictEqual(club.mods.deg, 12, "DEG du Gourdin tuné");
assert.strictEqual(club.mods.att, 2, "ATT du Gourdin inchangée");
// les défauts pour l'admin sont complets
const defs = mp.adminDefaults();
assert.strictEqual(defs.potions.length, 25, "25 potions");
assert.strictEqual(defs.scrolls.length, 7, "7 parchemins");
assert(defs.gear.length >= 50, "tout l'équipement listé");
assert.strictEqual(defs.monsters.length, 8, "7 types + boss");
}
// Persistance : save + load // Persistance : save + load
{ {
const os = require("os"); const os = require("os");