Files
mountycrawl/admin.html
T

337 lines
16 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; }
</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 &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>
</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 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) {
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({ 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 () => {
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>