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