adminSetTuning ne fait plus resetCategory + reapplique tout (ce qui ecrasait les editions sqlite-web) : mises a jour ciblees des seuls objets fournis (valeurs absolues), sans toucher au reste. Le bouton reset passe par patch.reset (tableau de categories). admin.html : collectTuning renvoie les objets MODIFIES dans la session (data-init) avec leurs valeurs completes. Tests : non-regression (edition externe preservee) + mise a jour du test de reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
16 KiB
HTML
347 lines
16 KiB
HTML
<!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; }
|
||
</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 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 (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>
|
||
</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"],
|
||
["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 MONSTER_LABELS = { level: "Niv.", att: "ATT phy", attMag: "ATT mag", esq: "ESQ", deg: "DEG phy", degMag: "DEG mag", pv: "PV", armor: "Arm. phy", armorMag: "Arm. mag", vue: "VUE" };
|
||
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 %", mmPct: "MM %" };
|
||
|
||
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 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 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);
|
||
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;
|
||
}
|
||
}
|
||
|
||
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-monsters-save").onclick = () => saveTuning({ monsters: collectTuning("monsters") }, "Bestiaire");
|
||
$("tune-monsters-reset").onclick = () => saveTuning({ reset: ["monsters"] }, "Bestiaire d'origine");
|
||
$("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");
|
||
$("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>
|