Files
mountycrawl/admin.html
T
percoandClaude Opus 4.8 f31ac75e1d v2.13.0 : RM%/MM% d'equipement en plages (min/max) tirees au drop
db.js : GEAR_KEYS += rmPctMin, mmPctMin (les rmPct/mmPct existants = max) ;
vanillaGear : min defaut = la valeur (pas de plage) ; migration ALTER gear
ADD COLUMN rmPctMin/mmPctMin + UPDATE min=valeur. mp.js applyGearTuning :
mods construits hors cles Min, RM%/MM% tires au hasard dans [min,max] a la
creation (comme les potions). treasures.js : encyclopedie affiche la plage
'min a max %' (rmPctMin/mmPctMin portes sur le def). admin GEAR_LABELS :
RM%/MM% min + max. Solo : valeurs fixes (la plage vit dans la base / monde
partage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:35:43 +02:00

425 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MountyCrawl — Administration du Monde Souterrain</title>
<link rel="stylesheet" href="css/style.css">
<style>
.admin-wrap { max-width: 1100px; margin: 0 auto; padding: 16px; }
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.admin-card { background: #FFFFEE; border: 1px solid #000; padding: 12px; }
.admin-card h2 { margin-top: 0; font-size: 1.05em; }
.admin-field { display: grid; grid-template-columns: 1fr 90px; gap: 6px; align-items: center; margin-bottom: 6px; }
.admin-field label { font-size: 0.85em; }
.admin-field small { display: block; color: #666; }
.admin-field input { width: 80px; padding: 3px; }
.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 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-msg { font-weight: bold; margin: 8px 0; }
#admin-login { max-width: 480px; margin: 60px auto; }
.bestiary-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.bestiary-filters select, .bestiary-filters input { padding: 4px 6px; border: 1px solid #aaa; background: #fff; }
.bestiary-filters #best-pick { min-width: 220px; }
.age-mult { display: inline-block; margin: 0 8px 4px 0; font-size: .85em; }
.age-mult input { width: 64px; padding: 1px 3px; border: 1px solid #aaa; }
.best-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; margin: 8px 0; }
.best-grid label { display: flex; flex-direction: column; font-size: .78em; color: #333; }
.best-grid input { padding: 2px 4px; border: 1px solid #aaa; background: #fff; }
</style>
</head>
<body>
<div class="admin-wrap">
<h1>⚙️ Administration du Monde Souterrain</h1>
<div id="admin-login" class="admin-card">
<h2>Token admin</h2>
<p><small>Le token est affiché dans les logs du serveur au démarrage
(ou dans <code>/data/admin-token.txt</code>), ou défini par la variable
d'environnement <code>MP_ADMIN_TOKEN</code>.</small></p>
<input type="password" id="admin-token" placeholder="token admin" style="width:100%">
<button id="admin-connect" class="big-btn">Entrer</button>
<div id="admin-login-msg" class="admin-msg"></div>
</div>
<div id="admin-body" class="hidden">
<div class="admin-msg" id="admin-msg"></div>
<div class="admin-grid">
<div class="admin-card">
<h2>⏱️ Réglages (appliqués à chaud)</h2>
<form id="admin-form"></form>
<button id="admin-save" class="big-btn">💾 Appliquer les réglages</button>
<hr>
<button id="admin-reset" class="menu-btn">🌋 Régénérer le monde (trolls conservés)</button>
</div>
<div class="admin-card">
<h2>📊 Vue d'ensemble</h2>
<div id="admin-overview"></div>
<h2>🌍 Derniers échos</h2>
<div id="admin-log" class="admin-log"></div>
</div>
</div>
<div class="admin-card" style="margin-top:16px">
<h2>🧌 Trolls</h2>
<div id="admin-trolls"></div>
</div>
<div class="admin-card" style="margin-top:16px">
<h2>👺 Monstres</h2>
<div id="admin-monsters"></div>
</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 (fixes sur les jets ; RM/MM en %).
S'applique aux prochains drops du monde partagé. Ces valeurs vivent dans la
base SQLite <code>data/mountycrawl.db</code> (tables <code>gear</code>,
<code>monsters</code>, <code>potions</code>, <code>scrolls</code>) — tu peux
aussi les éditer avec n'importe quel outil SQLite, c'est relu à chaque drop.</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 class="admin-card" style="margin-top:16px">
<h2>🐲 Bestiaire</h2>
<p><small>1 ligne par monstre (stats de base = âge le plus jeune, en plages
min/max) et une table d'âges (multiplicateurs appliqués à la génération /
croissance). Tirage au hasard dans les plages à chaque apparition.</small></p>
<h3>Multiplicateurs d'âge</h3>
<div id="tune-ages"></div>
<button id="tune-ages-save" class="big-btn">💾 Appliquer les âges</button>
<button id="tune-ages-reset" class="menu-btn">↩️ Âges d'origine</button>
<h3 style="margin-top:14px">Monstres</h3>
<div class="bestiary-filters">
<select id="best-family"><option value="">Toutes les familles</option></select>
<input type="text" id="best-search" placeholder="Rechercher un monstre…">
<select id="best-pick"></select>
</div>
<div id="best-form"></div>
<button id="best-save" class="big-btn">💾 Appliquer ce monstre</button>
<button id="best-reset" class="menu-btn">↩️ Tout le bestiaire d'origine</button>
<div id="best-msg"></div>
</div>
</div>
</div>
<script>
"use strict";
/* Libellés des réglages : [clé, libellé, description] */
const FIELDS = [
["monsterDlaMinSec", "DLA monstres — min (s)", "période minimale entre deux actions d'un monstre"],
["monsterDlaMaxSec", "DLA monstres — max (s)", "période maximale (chaque monstre tire sa DLA dans [min, max])"],
["trollDlaSec", "DLA des trolls (s)", "rechargement des PA des joueurs"],
["pollSec", "Rafraîchissement client (s)", "fréquence de polling conseillée aux navigateurs"],
["monsterTarget", "Population de monstres", "le repeuplement vise ce nombre"],
["repopSec", "Période de repeuplement (s)", "fréquence de vérification de la population"],
["itemTarget", "Trésors au sol", "nombre de trésors visé"],
["deathRespawnSec", "Réapparition des trolls (s)", "délai après une mort"],
["worldDepth", "Profondeur du monde (1-5)", "puissance des monstres et des trésors"],
["maxTrolls", "Trolls max", "limite de personnages dans le monde"],
["templateChance", "Templates — proba / emplacement (%)", "chance qu'un emplacement de template soit rempli sur un drop (3 max en 1 main, 6 en 2 mains)"],
["mapW", "Largeur de carte", "appliqué à la prochaine régénération du monde"],
["mapH", "Hauteur de carte", "appliqué à la prochaine régénération du monde"],
];
let TOKEN = localStorage.getItem("mc_admin_token") || "";
const $ = id => document.getElementById(id);
function fmtAgo(ms) {
const s = Math.round(ms / 1000);
if (s < 60) return s + " s";
if (s < 3600) return Math.round(s / 60) + " min";
return Math.round(s / 3600) + " h";
}
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: { "Content-Type": "application/json", "X-Admin-Token": TOKEN },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "erreur serveur");
return data;
}
function renderForm(config, bounds) {
const form = $("admin-form");
form.innerHTML = FIELDS.map(([key, label, desc]) => `
<div class="admin-field">
<label>${label}<small>${desc} (${bounds[key][0]}${bounds[key][1]})</small></label>
<input type="number" name="${key}" value="${config[key]}" min="${bounds[key][0]}" max="${bounds[key][1]}">
</div>`).join("");
}
function renderOverview(ov) {
$("admin-overview").innerHTML = `
<p>Uptime du monde : <b>${fmtAgo(ov.uptime)}</b> ·
Trolls : <b>${ov.trolls.length}</b> ·
Monstres : <b>${ov.monsters.length}</b> ·
Trésors au sol : <b>${ov.items}</b></p>`;
$("admin-log").innerHTML = ov.logTail.map(l =>
`<div>${new Date(l.t).toLocaleTimeString("fr-FR")}${l.msg}</div>`).reverse().join("");
$("admin-trolls").innerHTML = ov.trolls.length ? `<table class="admin-table">
<tr><th>Nom</th><th>Race</th><th>Niv.</th><th>PV</th><th>PA</th><th>Kills</th><th>Or</th><th>Position</th><th>État</th><th>Vu il y a</th><th></th></tr>
${ov.trolls.map(t => `<tr>
<td>${t.name}</td><td>${t.race}</td><td>${t.level}</td>
<td>${t.pv}/${t.pvMax}</td><td>${t.pa}</td><td>${t.kills}</td><td>${t.gold}</td>
<td>${t.x},${t.y}</td><td>${t.dead ? "☠️ mort" : "vivant"}</td><td>${fmtAgo(t.lastSeenAgo)}</td>
<td><button class="kick-troll" data-id="${t.id}" data-name="${t.name}" title="Supprimer ce troll">🗑️</button></td>
</tr>`).join("")}</table>` : "<p>Aucun troll pour l'instant.</p>";
for (const btn of document.querySelectorAll(".kick-troll")) {
btn.onclick = async () => {
if (!confirm(`Supprimer définitivement le troll « ${btn.dataset.name} » ?`)) return;
try {
await api("POST", "api/mp/admin/kick", { id: btn.dataset.id });
$("admin-msg").textContent = `✅ ${btn.dataset.name} supprimé.`;
await refresh();
} catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; }
};
}
$("admin-monsters").innerHTML = `<table class="admin-table">
<tr><th></th><th>Nom</th><th>Niv.</th><th>PV</th><th>Position</th><th>DLA (s)</th><th>Prochaine action</th></tr>
${ov.monsters.map(m => `<tr>
<td>${m.emoji}</td><td>${m.name}</td><td>${m.level}</td>
<td>${m.pv}/${m.pvMax}</td><td>${m.x},${m.y}</td>
<td>${m.dlaSec}</td><td>dans ${Math.round(m.nextDlaIn / 1000)} s</td>
</tr>`).join("")}</table>`;
}
/* ---------- Tuning : bestiaire, trésors, équipement ---------- */
const GEAR_LABELS = { att: "ATT phy", attMag: "ATT mag", esq: "ESQ", deg: "DEG phy", degMag: "DEG mag", reg: "REG", arm: "Arm. phy", armMag: "Arm. mag", vue: "VUE", pv: "PV", rmPct: "RM% max", rmPctMin: "RM% min", mmPct: "MM% max", mmPctMin: "MM% min" };
function tuneInput(cat, id, key, def, cur) {
const val = cur !== undefined ? cur : def;
const tuned = cur !== undefined && Number(cur) !== Number(def);
// data-def = valeur d'origine (vanilla) ; data-init = valeur chargée (pour
// détecter ce que l'utilisateur change réellement dans cette session).
return `<input type="number" class="${tuned ? "tuned" : ""}" data-cat="${cat}" data-id="${id}" data-key="${key}" data-def="${def}" data-init="${val}" value="${val}">`;
}
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 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-potions input, #tune-scrolls input, #tune-gear input")) {
input.addEventListener("input", markTuned);
}
}
/* Collecte les objets MODIFIÉS dans cette session (au moins un champ ≠ valeur
* chargée) avec TOUTES leurs valeurs courantes (absolues). La sauvegarde ne
* touchera donc que ces objets-là, sans réinitialiser le reste : on peut éditer
* en parallèle via la base (sqlite-web) sans que l'admin écrase ces éditions. */
function collectTuning(cat) {
const byId = {};
for (const input of document.querySelectorAll(`input[data-cat="${cat}"]`)) {
(byId[input.dataset.id] = byId[input.dataset.id] || []).push(input);
}
const out = {};
for (const [id, inputs] of Object.entries(byId)) {
if (!inputs.some(i => Number(i.value) !== Number(i.dataset.init))) continue;
out[id] = {};
for (const i of inputs) {
const v = Number(i.value);
if (Number.isFinite(v)) out[id][i.dataset.key] = v;
}
}
return out;
}
/* Potions/parchemins : chaque objet modifié devient [min, max] complet. */
function collectTreasureTuning(cat) {
const out = {};
for (const [id, vals] of Object.entries(collectTuning(cat))) {
out[id] = [vals.min, vals.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) {
const ov = await api("GET", "api/mp/admin");
LAST_OV = ov;
if (withForm) {
renderForm(ov.config, ov.bounds);
renderTuning(ov.defaults, ov.tuning);
}
renderOverview(ov);
}
async function connect() {
try {
await refresh(true);
await loadBestiary();
localStorage.setItem("mc_admin_token", TOKEN);
$("admin-login").classList.add("hidden");
$("admin-body").classList.remove("hidden");
setInterval(() => refresh(false).catch(() => {}), 10000);
} catch (e) {
$("admin-login-msg").textContent = "⚠️ " + e.message;
}
}
/* ---------- Bestiaire ---------- */
let BEST = [], AGES = [];
const BEST_FIELDS = [
["family", "Famille", "text"], ["gender", "Genre (m/f)", "text"], ["minAge", "Âge min", "int"], ["maxAge", "Âge max", "int"],
["levelMin", "Niveau min", "int"], ["levelMax", "Niveau max", "int"],
["pvMin", "PV min", "int"], ["pvMax", "PV max", "int"],
["attMin", "ATT min (D6)", "int"], ["attMax", "ATT max", "int"],
["esqMin", "ESQ min (D6)", "int"], ["esqMax", "ESQ max", "int"],
["degMin", "DEG min (D3)", "int"], ["degMax", "DEG max", "int"],
["regMin", "REG min (D3)", "int"], ["regMax", "REG max", "int"],
["armPhysMin", "Arm.phy min", "int"], ["armPhysMax", "Arm.phy max", "int"],
["armMagMin", "Arm.mag min", "int"], ["armMagMax", "Arm.mag max", "int"],
["vueMin", "Vue min", "int"], ["vueMax", "Vue max", "int"],
["mmMin", "MM min", "int"], ["mmMax", "MM max", "int"],
["rmMin", "RM min", "int"], ["rmMax", "RM max", "int"], ["nbAtt", "Attaques/tour", "int"],
["fly", "Vole (0/1)", "int"], ["ranged", "Att. distance (0/1)", "int"],
["magic", "Att. magique (0/1)", "int"], ["seesHidden", "Voit caché (0/1)", "int"],
["speed", "Vitesse", "text"], ["capacities", "Capacités", "text"], ["blason", "Blason (URL)", "text"],
];
async function loadBestiary() {
try {
[BEST, AGES] = await Promise.all([
fetch("api/reference/bestiary").then(r => r.json()),
fetch("api/reference/ages").then(r => r.json()),
]);
} catch { BEST = []; AGES = []; }
$("tune-ages").innerHTML = AGES.map(a =>
`<label class="age-mult">âge ${a.age} <input type="number" step="0.05" data-age="${a.age}" value="${a.mult}"></label>`).join("");
const fams = [...new Set(BEST.map(b => b.family))].sort();
$("best-family").innerHTML = '<option value="">Toutes les familles</option>' + fams.map(f => `<option>${f}</option>`).join("");
renderMonsterPicker();
}
function renderMonsterPicker() {
const fam = $("best-family").value, q = $("best-search").value.trim().toLowerCase();
const list = BEST.filter(b => (!fam || b.family === fam) && (!q || b.name.toLowerCase().includes(q)));
const pick = $("best-pick"), cur = pick.value;
pick.innerHTML = list.map(b => `<option>${b.name}</option>`).join("");
if (list.some(b => b.name === cur)) pick.value = cur;
renderMonsterForm();
}
function renderMonsterForm() {
const b = BEST.find(x => x.name === $("best-pick").value);
if (!b) { $("best-form").innerHTML = "<p><small>Aucun monstre pour ce filtre.</small></p>"; return; }
$("best-form").innerHTML = `<div class="best-grid">` + BEST_FIELDS.map(([k, label, type]) =>
`<label>${label}<input data-key="${k}" type="${type === "int" ? "number" : "text"}" value="${String(b[k] ?? "").replace(/"/g, "&quot;")}"></label>`).join("") + "</div>";
}
document.addEventListener("DOMContentLoaded", () => {
$("admin-connect").onclick = () => {
TOKEN = $("admin-token").value.trim();
connect();
};
$("admin-save").onclick = async () => {
const patch = {};
for (const input of $("admin-form").querySelectorAll("input")) patch[input.name] = input.value;
try {
await api("PUT", "api/mp/admin/config", patch);
$("admin-msg").textContent = "✅ Réglages appliqués à chaud.";
await refresh(true);
} catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; }
};
$("tune-treasures-save").onclick = () => saveTuning({
potions: collectTreasureTuning("potions"),
scrolls: collectTreasureTuning("scrolls"),
}, "Trésors");
$("tune-treasures-reset").onclick = () => saveTuning({ reset: ["potions", "scrolls"] }, "Trésors d'origine");
// Bestiaire
$("best-family").onchange = renderMonsterPicker;
$("best-search").oninput = renderMonsterPicker;
$("best-pick").onchange = renderMonsterForm;
$("tune-ages-save").onclick = async () => {
const ages = {};
for (const i of document.querySelectorAll("#tune-ages input")) ages[i.dataset.age] = Number(i.value);
await saveTuning({ ages }, "Âges"); await loadBestiary();
};
$("tune-ages-reset").onclick = async () => { await saveTuning({ reset: ["ages"] }, "Âges d'origine"); await loadBestiary(); };
$("best-save").onclick = async () => {
const name = $("best-pick").value; if (!name) return;
const vals = {};
for (const i of $("best-form").querySelectorAll("input")) vals[i.dataset.key] = i.value;
await saveTuning({ bestiary: { [name]: vals } }, "Monstre « " + name + " »"); await loadBestiary();
};
$("best-reset").onclick = async () => { await saveTuning({ reset: ["bestiary"] }, "Bestiaire d'origine"); await loadBestiary(); };
$("tune-gear-save").onclick = () => saveTuning({ gear: collectTuning("gear") }, "Équipement");
$("tune-gear-reset").onclick = () => saveTuning({ reset: ["gear"] }, "Équipement d'origine");
$("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;
try {
await api("POST", "api/mp/admin/reset");
$("admin-msg").textContent = "🌋 Monde régénéré.";
await refresh(true);
} catch (e) { $("admin-msg").textContent = "⚠️ " + e.message; }
};
// ?token=… dans l'URL : connexion directe (le token est ensuite mémorisé)
const urlToken = new URLSearchParams(location.search).get("token");
if (urlToken) TOKEN = urlToken;
if (TOKEN) { $("admin-token").value = TOKEN; connect(); }
});
</script>
</body>
</html>