2.0.0 : multijoueur — monde partagé persistant, DLA paramétrables par monstre, admin à chaud

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 10:53:16 +02:00
co-authored by Claude Fable 5
parent e5a4148ee8
commit 1a5d2c7317
10 changed files with 1839 additions and 11 deletions
+190
View File
@@ -0,0 +1,190 @@
<!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-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>
</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></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>
</tr>`).join("")}</table>` : "<p>Aucun troll pour l'instant.</p>";
$("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>`;
}
async function refresh(withForm = false) {
const ov = await api("GET", "api/mp/admin");
if (withForm) renderForm(ov.config, ov.bounds);
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; }
};
$("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>