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
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY index.html server.js ./ COPY index.html admin.html server.js mp.js ./
COPY css ./css COPY css ./css
COPY js ./js COPY js ./js
COPY img ./img COPY img ./img
+60 -3
View File
@@ -103,6 +103,50 @@ Gobelin, Champignon Vénéneux, Araignée Géante, Gargouille, Momie, Sorcière,
Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur, Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur,
et le **Béhémoth** comme boss final à la profondeur 5. et le **Béhémoth** comme boss final à la profondeur 5.
## Monde partagé (multijoueur)
Depuis l'accueil : **🧌🧌 Monde partagé**. Un monde souterrain persistant et
commun à tous les joueurs, autoritaire côté serveur (`mp.js`) :
- **Le monde vit en continu**, joueurs connectés ou non : chaque monstre a sa
propre **DLA** (période tirée dans une fourchette paramétrable) et agit à son
échéance — il erre, ou attaque le troll à portée de Vue. Les monstres ne
s'attaquent jamais entre eux. La population se repeuple automatiquement.
- **Chaque troll a 6 PA**, rechargés à sa DLA personnelle (paramétrable). Toutes
les actions du solo sont disponibles : déplacement, attaque, compétence et
sortilège de race, potions, parchemins, équipement, entraînement en PI.
- **À 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.
- L'identité du troll (id + clé secrète) est gardée par le navigateur
(localStorage) : on retrouve son personnage d'une session à l'autre.
- Le client se rafraîchit par **polling** (intervalle conseillé par le serveur,
paramétrable) ; les autres trolls visibles apparaissent en bleu avec leur nom,
les camouflés sont invisibles.
- Persistance dans `WORLD_FILE` (défaut `/data/world.json`), sauvegarde
périodique et au SIGTERM.
### Administration (`admin.html`)
Page protégée par token (`MP_ADMIN_TOKEN`, sinon généré au premier démarrage
dans `/data/admin-token.txt` et affiché dans les logs). Tous les réglages sont
**appliqués à chaud** : DLA des monstres (min/max — les échéances déjà planifiées
sont re-tirées si besoin), DLA des trolls, intervalle de rafraîchissement client,
population de monstres et de trésors, délai de réapparition, profondeur du monde
(puissance des monstres), taille de carte (à la prochaine régénération), limite
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
bouton « Régénérer le monde » (trolls conservés et replacés).
### API multijoueur
- `POST /api/mp/join` `{name, race}``{id, secret, state}`
- `GET /api/mp/state?id=&secret=` → état visible (vue du troll)
- `POST /api/mp/action` `{id, secret, action}``move`, `attack`, `comp`,
`sort`, `pickup`, `use`, `train`
- `GET /api/mp/info` → compteurs publics (trolls en ligne…)
- `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `POST /api/mp/admin/reset`
(header `X-Admin-Token`)
## Éditeur de niveaux & partage ## Éditeur de niveaux & partage
Depuis l'écran d'accueil : **🛠️ Éditeur de niveaux**. Depuis l'écran d'accueil : **🛠️ Éditeur de niveaux**.
@@ -139,11 +183,14 @@ Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
## Tests ## Tests
```bash ```bash
node test/smoke.js node test/smoke.js # cœur des règles : dés, combat, armures, talents, cavernes…
node test/mp.js # moteur multijoueur : DLA, tick, actions, admin, persistance
``` ```
Vérifie les dés, la résolution de combat, le Seuil de Résistance, les coûts Vérifie les dés, la résolution de combat (armures physique/magique), le Seuil de
d'amélioration, les niveaux, la connexité des cavernes générées et le bestiaire. Résistance, les coûts d'amélioration, les niveaux, la connexité des cavernes
générées, le bestiaire, et le monde partagé (DLA des monstres, mort/réapparition,
visibilité, réglages admin bornés, sauvegarde/rechargement).
## Sources ## Sources
@@ -154,6 +201,16 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## Versions
- **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
monstre agit à sa propre DLA paramétrable (fourchette min/max), erre ou attaque
les trolls à portée, repeuplement automatique ; jamais de combat monstre contre
monstre. Trolls : 6 PA rechargés à leur DLA personnelle, toutes les actions du
solo (talents de race compris), réapparition après la mort, identité conservée
en localStorage. Client par polling (intervalle paramétrable), autres trolls
visibles (sauf camouflés). Page `admin.html` protégée par token : tous les
réglages à chaud + tableau de bord (trolls, monstres et leurs DLA, échos du
monde, régénération du monde). Tests `test/mp.js`.
- **1.9.0** (2026-06-12) — Bonus/malus **physiques** et **magiques** distincts, comme - **1.9.0** (2026-06-12) — Bonus/malus **physiques** et **magiques** distincts, comme
à MountyHall : l'équipement donne des bonus physiques (exceptions à venir), les à MountyHall : l'équipement donne des bonus physiques (exceptions à venir), les
potions et parchemins des bonus magiques. L'armure est scindée en **armure potions et parchemins des bonus magiques. L'armure est scindée en **armure
+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>
+31
View File
@@ -513,6 +513,37 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; }
#log .l-good { color: #008040; } #log .l-good { color: #008040; }
#log .l-info { color: #666666; } #log .l-info { color: #666666; }
/* ---------- Multijoueur : journaux et statut ---------- */
#mp-log, #mp-world-log {
flex: 1 1 50%;
background: #FFFFEE url("../img/mh/tableau1.jpg");
border: 1px solid #000000;
padding: 8px 12px;
height: 190px;
overflow-y: auto;
font-size: .85em;
font-family: monospace;
line-height: 1.45;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.3);
}
#mp-world-log .cd-header {
color: #000000;
font-weight: bold;
border-bottom: 1px solid #000000;
margin-bottom: 6px;
position: sticky;
top: 0;
background: #EBEDAB url("../img/mh/tableau2.jpg");
padding: 1px 4px;
}
#mp-log .l-combat, #mp-world-log .l-combat { color: #993300; }
#mp-log .l-bad, #mp-world-log .l-bad { color: #CC0000; }
#mp-log .l-good, #mp-world-log .l-good { color: #008040; }
#mp-log .l-info, #mp-world-log .l-info { color: #666666; }
#mp-status { font-weight: bold; }
#mp-effects .eq-line, #mp-effects .fx-empty { font-size: .88em; color: #333300; }
#screen-mp #panel-center { overflow: auto; }
/* ---------- Trésors du Hall (potions, parchemins & équipement) ---------- */ /* ---------- Trésors du Hall (potions, parchemins & équipement) ---------- */
#treasures-body { #treasures-body {
max-width: 1100px; max-width: 1100px;
+46
View File
@@ -19,6 +19,7 @@
<input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet"> <input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet">
<div id="race-list"></div> <div id="race-list"></div>
<button id="btn-start" class="big-btn">⚔️ Descendre dans le Monde Souterrain</button> <button id="btn-start" class="big-btn">⚔️ Descendre dans le Monde Souterrain</button>
<button id="btn-mp" class="big-btn">🧌🧌 Monde partagé (multijoueur) <span id="mp-info"></span></button>
<div class="menu-row"> <div class="menu-row">
<button id="btn-community" class="menu-btn">🌍 Niveaux de la communauté</button> <button id="btn-community" class="menu-btn">🌍 Niveaux de la communauté</button>
<button id="btn-editor" class="menu-btn">🛠️ Éditeur de niveaux</button> <button id="btn-editor" class="menu-btn">🛠️ Éditeur de niveaux</button>
@@ -135,6 +136,50 @@
</div> </div>
</div> </div>
<!-- Écran multijoueur : monde partagé persistant -->
<div id="screen-mp" class="screen hidden">
<header>
<h1>🧌🧌 Le Monde Souterrain partagé</h1>
<div class="header-right">
<div id="mp-status">Connexion…</div>
<button id="mp-leave" class="menu-btn">← Quitter</button>
</div>
</header>
<div id="layout">
<aside id="panel-left">
<h2 id="mp-troll-title"></h2>
<div id="mp-stats"></div>
<h3>S'améliorer (PI)</h3>
<div id="mp-improve"></div>
<h3>Effets actifs</h3>
<div id="mp-effects"></div>
</aside>
<main id="panel-center">
<canvas id="mp-map" width="960" height="672"></canvas>
<div id="mp-pa-bar"></div>
<div id="mp-actions"></div>
</main>
<aside id="panel-right">
<h3>Équipement</h3>
<div id="mp-equipment"></div>
<h3>Sac</h3>
<div id="mp-inventory"></div>
<h3>Aide</h3>
<div class="help">
<p>ZQSD / flèches : se déplacer (1 PA) — marcher sur un monstre l'attaque (3 PA)</p>
<p>Tes PA se rechargent à ta DLA (compte à rebours en haut).</p>
<p>Le monde vit en continu : chaque monstre agit à sa propre DLA, même quand tu ne regardes pas !</p>
</div>
</aside>
</div>
<div id="bottom-row">
<div id="mp-log"></div>
<div id="mp-world-log">
<div class="cd-header">🌍 Échos du Monde Souterrain</div>
</div>
</div>
</div>
<!-- Écran des règles des talents --> <!-- Écran des règles des talents -->
<div id="screen-rules" class="screen hidden"> <div id="screen-rules" class="screen hidden">
<header> <header>
@@ -169,6 +214,7 @@
<script src="js/gear.js"></script> <script src="js/gear.js"></script>
<script src="js/game.js"></script> <script src="js/game.js"></script>
<script src="js/editor.js"></script> <script src="js/editor.js"></script>
<script src="js/mp-client.js"></script>
<script src="js/rules.js"></script> <script src="js/rules.js"></script>
<script src="js/treasures.js"></script> <script src="js/treasures.js"></script>
</body> </body>
+4 -3
View File
@@ -4,7 +4,7 @@
"use strict"; "use strict";
const APP_VERSION = "1.9.0"; const APP_VERSION = "2.0.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;
@@ -1497,13 +1497,14 @@ if (typeof document !== "undefined") {
}); });
} }
/* Export pour les tests node */ /* Export pour les tests node et le moteur multijoueur (mp.js) */
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX,
RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, equipGear, RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear,
generateCavern, largestRegion, generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT,
newGame, refreshFov, inSightAt, newGame, refreshFov, inSightAt,
get state() { return G; }, get state() { return G; },
}; };
+343
View File
@@ -0,0 +1,343 @@
/* Client multijoueur MountyCrawl — polling de l'état serveur (/api/mp/*),
* rendu canvas et actions. L'identité du troll (id + secret) est gardée en
* localStorage : on retrouve son troll d'une session à l'autre.
* Partage les globales de game.js (RACES, GEAR_SLOTS, fmtStatLine…). */
"use strict";
const MP_TILE = 24;
const MP_KEY = "mc_mp_identity";
let MP = {
id: null, secret: null,
state: null,
timer: null,
seen: new Set(), // brouillard : cases déjà vues (mémoire locale)
lastLogT: 0,
pending: false,
};
/* ---------- Identité ---------- */
function mpLoadIdentity() {
try { return JSON.parse(localStorage.getItem(MP_KEY)); } catch { return null; }
}
function mpSaveIdentity(id, secret) {
localStorage.setItem(MP_KEY, JSON.stringify({ id, secret }));
}
/* ---------- Réseau ---------- */
async function mpJoin() {
const name = document.getElementById("troll-name").value.trim() || "Trõllinet";
const res = await fetch("api/mp/join", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, race: selectedRace }),
});
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 mpFetchState() {
const res = await fetch(`api/mp/state?id=${MP.id}&secret=${MP.secret}`);
if (res.status === 403) return null; // identité périmée (reset serveur ?)
if (!res.ok) throw new Error("état indisponible");
return res.json();
}
async function mpAction(action) {
if (MP.pending) return;
MP.pending = true;
try {
const res = await fetch("api/mp/action", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: MP.id, secret: MP.secret, action }),
});
const data = await res.json();
if (data.error) mpToast(data.error);
if (data.state) { MP.state = data.state; mpRender(); }
} catch { mpToast("le serveur ne répond pas"); }
MP.pending = false;
}
function mpToast(msg) {
const el = document.getElementById("mp-status");
if (el) {
el.textContent = `⚠️ ${msg}`;
setTimeout(() => mpRenderStatus(), 2500);
}
}
/* ---------- Entrée / sortie de l'écran ---------- */
async function mpEnter() {
document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-mp").classList.remove("hidden");
MP.seen = new Set();
try {
const saved = mpLoadIdentity();
if (saved && saved.id) {
MP.id = saved.id; MP.secret = saved.secret;
MP.state = await mpFetchState();
}
if (!MP.state) MP.state = await mpJoin();
mpRender();
mpSchedule();
} catch (e) {
mpToast(e.message || "connexion impossible");
setTimeout(mpLeave, 2500);
}
}
function mpLeave() {
if (MP.timer) { clearTimeout(MP.timer); MP.timer = null; }
document.getElementById("screen-mp").classList.add("hidden");
document.getElementById("screen-create").classList.remove("hidden");
}
function mpSchedule() {
if (MP.timer) clearTimeout(MP.timer);
const sec = MP.state?.config?.pollSec || 5;
MP.timer = setTimeout(async () => {
if (document.getElementById("screen-mp").classList.contains("hidden")) return;
try {
const st = await mpFetchState();
if (!st) { // identité refusée : on retente une création
localStorage.removeItem(MP_KEY);
MP.state = await mpJoin();
} else {
MP.state = st;
}
mpRender();
} catch { /* on retentera au prochain tour */ }
mpSchedule();
}, sec * 1000);
}
/* ---------- Rendu ---------- */
function mpFmtDelay(ms) {
const s = Math.ceil(ms / 1000);
if (s < 60) return `${s} s`;
return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s`;
}
function mpRenderStatus() {
const st = MP.state;
const el = document.getElementById("mp-status");
if (!st || !el) return;
el.textContent = st.you.dead
? `☠️ Terrassé — retour dans ${mpFmtDelay(st.you.respawnIn)}`
: `DLA n°${st.you.dla} · PA dans ${mpFmtDelay(st.you.nextDlaIn)} · 🔄 ${st.config.pollSec} s`;
}
function mpRender() {
const st = MP.state;
if (!st) return;
mpRenderStatus();
mpRenderMap(st);
mpRenderPanels(st);
mpRenderLogs(st);
}
function mpRenderMap(st) {
const canvas = document.getElementById("mp-map");
const w = st.config.mapW, h = st.config.mapH;
canvas.width = w * MP_TILE;
canvas.height = h * MP_TILE;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "18px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const you = st.you;
const vue = you.eff.vue;
const visible = (x, y) => Math.max(Math.abs(x - you.x), Math.abs(y - you.y)) <= vue;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (visible(x, y)) MP.seen.add(y * w + x);
const px = x * MP_TILE, py = y * MP_TILE;
if (!MP.seen.has(y * w + x)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, MP_TILE, MP_TILE); continue; }
const vis = visible(x, y);
const wall = st.map[y][x] === "#";
ctx.fillStyle = wall ? (vis ? "#4a3a22" : "#2c2315") : (vis ? "#7a6a45" : "#3d3522");
ctx.fillRect(px, py, MP_TILE - 1, MP_TILE - 1);
}
}
const disc = (x, y, color) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x * MP_TILE + MP_TILE / 2, y * MP_TILE + MP_TILE / 2, MP_TILE / 2 - 3, 0, Math.PI * 2);
ctx.fill();
};
for (const i of st.items) {
disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" || i.kind === "scroll" ? (i.color || "#5d8535") : "#7a8db0");
ctx.fillStyle = "#1a140e";
ctx.fillText(i.emoji, i.x * MP_TILE + MP_TILE / 2, i.y * MP_TILE + MP_TILE / 2 + 1);
}
for (const m of st.monsters) {
disc(m.x, m.y, "#8a3030");
ctx.fillStyle = "#1a140e";
ctx.fillText(m.emoji, m.x * MP_TILE + MP_TILE / 2, m.y * MP_TILE + MP_TILE / 2 + 1);
const bw = Math.max(2, Math.round((MP_TILE - 6) * m.pvPct));
ctx.fillStyle = "#b03030";
ctx.fillRect(m.x * MP_TILE + 3, m.y * MP_TILE + 1, bw, 3);
}
// les autres trolls : pastille bleue + nom
for (const o of st.trolls) {
disc(o.x, o.y, "#5a7abf");
ctx.fillStyle = "#1a140e";
ctx.fillText("🧌", o.x * MP_TILE + MP_TILE / 2, o.y * MP_TILE + MP_TILE / 2 + 1);
ctx.fillStyle = "#dfe8ff";
ctx.font = "10px sans-serif";
ctx.fillText(o.name, o.x * MP_TILE + MP_TILE / 2, o.y * MP_TILE - 5);
ctx.font = "18px serif";
}
// toi
if (!you.dead) {
disc(you.x, you.y, "#8fbf5a");
ctx.fillStyle = "#1a140e";
ctx.fillText("🧌", you.x * MP_TILE + MP_TILE / 2, you.y * MP_TILE + MP_TILE / 2 + 1);
if (you.camo) {
ctx.strokeStyle = "#8fbf5a";
ctx.strokeRect(you.x * MP_TILE + 1, you.y * MP_TILE + 1, MP_TILE - 2, MP_TILE - 2);
}
}
}
function mpRenderPanels(st) {
const you = st.you;
const e = you.eff;
document.getElementById("mp-troll-title").textContent =
`${RACES[you.race].emoji} ${you.name}, ${you.race} niv. ${you.level}`;
const pct = Math.max(0, you.pv / you.pvMax);
const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : "";
document.getElementById("mp-stats").innerHTML = `
<div><span>PV</span><span class="stat-val">${you.pv} / ${you.pvMax}</span></div>
<div class="hp-bar-wrap"><div class="hp-bar ${hpClass}" style="width:${pct * 100}%"></div></div>
<div><span>Attaque</span><span class="stat-val">${fmtStatLine(you.att, e.att, 6, e.attFlat, 0, { phys: e.attFlatPhys, mag: e.attFlatMag })}</span></div>
<div><span>Esquive</span><span class="stat-val">${fmtStatLine(you.esq, e.esq, 6, e.esqFlat, 0, { phys: e.esqFlatPhys, mag: e.esqFlatMag })}</span></div>
<div><span>Dégâts</span><span class="stat-val">${fmtStatLine(you.deg, e.deg, 3, e.degFlat, e.degBonus, { phys: e.degFlatPhys, mag: e.degFlatMag })}</span></div>
<div><span>Régénération</span><span class="stat-val">${fmtStatLine(you.reg, e.reg, 3, e.regFlat, 0, { phys: e.regFlatPhys, mag: e.regFlatMag })}</span></div>
<div><span>Armure phy.</span><span class="stat-val">${e.armorPhys}${you.armorDice ? "+" + you.armorDice + "D3" : ""}</span></div>
<div><span>Armure mag.</span><span class="stat-val">${e.armorMag > 0 ? "+" : ""}${e.armorMag}</span></div>
<div><span>Vue</span><span class="stat-val">${e.vue}</span></div>
<div><span>${RACES[you.race].comp.name}</span><span class="stat-val">${you.comp.pct} %</span></div>
<div><span>${RACES[you.race].sort.name}</span><span class="stat-val">${you.sort.pct} %</span></div>
${you.race === "Kastar" ? `<div><span>Fatigue</span><span class="stat-val">${you.fatigue}</span></div>` : ""}
${you.camo ? '<div><span>🌫️ Camouflé</span><span class="stat-val">oui</span></div>' : ""}
<div><span>PI</span><span class="stat-val">${you.pi}</span></div>
<div><span>Mountyzédons</span><span class="stat-val">${you.gold}</span></div>
<div><span>Monstres tués</span><span class="stat-val">${you.kills}</span></div>`;
const improve = document.getElementById("mp-improve");
improve.innerHTML = "";
const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D3", reg: "Régén. +1D3", pv: "PV max +10", vue: "Vue +1", armor: "Armure phy. +1D3" };
for (const stat of Object.keys(labels)) {
const cost = improveCost(stat, you.bought[stat], you.race);
const btn = document.createElement("button");
const fav = RACES[you.race].favored === stat ? " ★" : "";
btn.textContent = `${labels[stat]}${cost} PI${fav}`;
btn.disabled = you.pi < cost || you.dead;
btn.onclick = () => mpAction({ type: "train", stat });
improve.appendChild(btn);
}
const fx = document.getElementById("mp-effects");
fx.innerHTML = you.effects.length
? you.effects.map(ef => `<div class="eq-line">${ef.emoji} ${ef.name} <small>(${ef.turnsLeft} DLA)</small>${ef.modLines.length ? `<div class="eq-mods">${ef.modLines.join(" · ")}</div>` : ""}</div>`).join("")
: '<p class="fx-empty">Aucun bonus ni malus magique actif.</p>';
document.getElementById("mp-pa-bar").textContent = "⚡".repeat(you.pa) + "▫️".repeat(Math.max(0, 6 - you.pa));
const actions = document.getElementById("mp-actions");
actions.innerHTML = "";
const addBtn = (label, fn, enabled) => {
const b = document.createElement("button");
b.textContent = label;
b.disabled = !enabled || you.dead;
b.onclick = fn;
actions.appendChild(b);
};
const adj = st.monsters.find(m => Math.max(Math.abs(m.x - you.x), Math.abs(m.y - you.y)) <= 1);
addBtn(`⚔️ Attaquer (${3} PA)`, () => adj && mpAction({ type: "attack", target: adj.id }), adj && you.pa >= 3);
const comp = RACES[you.race].comp, sort = RACES[you.race].sort;
addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${you.comp.pct} %)`, () => mpAction({ type: "comp" }), you.pa >= comp.cost);
addBtn(`🔮 ${sort.name} (${sort.cost} PA · ${you.sort.pct} %)`, () => mpAction({ type: "sort" }), you.pa >= sort.cost);
const onItem = st.items.some(i => i.x === you.x && i.y === you.y);
addBtn(`🖐️ Ramasser (1 PA)`, () => mpAction({ type: "pickup" }), onItem && you.pa >= 1);
document.getElementById("mp-equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => {
const it = you.equip[slot];
if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fxs = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${it.name}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}${fxs ? `<div class="eq-mods">${fxs}</div>` : ""}</div>`;
}).join("");
const inv = document.getElementById("mp-inventory");
inv.innerHTML = "";
if (!you.bag.length) inv.innerHTML = '<div class="empty">— vide —</div>';
you.bag.forEach((item, idx) => {
const b = document.createElement("button");
const action = item.kind === "potion" ? "boire (1 PA)" : item.kind === "scroll" ? "lire (1 PA)" : "équiper (2 PA)";
b.textContent = `${item.emoji} ${item.name}${action}`;
if (item.kind === "gear") b.title = formatGearMods(item.mods) + (item.twoHanded ? " · 2 mains" : "");
b.disabled = you.dead;
b.onclick = () => mpAction({ type: "use", idx });
inv.appendChild(b);
});
}
function mpRenderLogs(st) {
const logEl = document.getElementById("mp-log");
logEl.innerHTML = st.privLog.map(l =>
`<div class="l-${l.cls}">${new Date(l.t).toLocaleTimeString("fr-FR")}${l.msg}</div>`).join("");
logEl.scrollTop = logEl.scrollHeight;
const wl = document.getElementById("mp-world-log");
wl.innerHTML = '<div class="cd-header">🌍 Échos du Monde Souterrain</div>' + st.log.map(l =>
`<div class="l-${l.cls}">${new Date(l.t).toLocaleTimeString("fr-FR")}${l.msg}</div>`).join("");
wl.scrollTop = wl.scrollHeight;
}
/* ---------- Clavier & branchement ---------- */
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
const btn = document.getElementById("btn-mp");
if (btn) btn.onclick = mpEnter;
document.getElementById("mp-leave").onclick = mpLeave;
document.addEventListener("keydown", e => {
if (document.getElementById("screen-mp").classList.contains("hidden")) return;
const k = e.key.toLowerCase();
const moves = {
arrowup: [0, -1], z: [0, -1],
arrowdown: [0, 1], s: [0, 1],
arrowleft: [-1, 0], q: [-1, 0],
arrowright: [1, 0], d: [1, 0],
};
if (moves[k]) { e.preventDefault(); mpAction({ type: "move", dx: moves[k][0], dy: moves[k][1] }); }
else if (k === "e") mpAction({ type: "pickup" });
});
// compteur de la Taverne : « X trõlls en ligne »
fetch("api/mp/info").then(r => r.json()).then(info => {
const el = document.getElementById("mp-info");
if (el && info && typeof info.online === "number")
el.textContent = `· ${info.online} trõll(s) en ligne`;
}).catch(() => {});
// ?screen=mp : entrer directement dans le monde partagé (tests/captures)
if (new URLSearchParams(location.search).get("screen") === "mp") mpEnter();
});
}
+812
View File
@@ -0,0 +1,812 @@
/* Moteur multijoueur MountyCrawl — monde partagé persistant, autoritaire côté
* serveur. Node pur, réutilise les règles de js/game.js (dés, combat, races,
* monstres, équipement, potions, parchemins).
*
* Principes :
* - Le monde vit en mémoire et tique en continu (tick() appelée chaque seconde
* par server.js) : chaque monstre a sa propre DLA (période en ms, tirée dans
* une fourchette paramétrable) et agit à son échéance, joueurs connectés ou non.
* - Les monstres ne s'attaquent jamais entre eux.
* - Chaque troll a 6 PA, rechargés à sa DLA personnelle (période paramétrable) ;
* les actions (déplacement, attaque, talents…) coûtent des PA comme en solo.
* - À 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.
* - La configuration (world.config) est modifiable à chaud via l'API admin.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const g = require("./js/game.js");
const p = require("./js/potions.js");
const sc = require("./js/scrolls.js");
const gearLib = require("./js/gear.js");
/* ---------- Configuration par défaut (tout est réglable via l'admin) ---------- */
const DEFAULT_CONFIG = {
mapW: 40, mapH: 28, // taille du monde partagé
worldDepth: 2, // « profondeur » : pools de monstres/objets (1-5)
monsterDlaMinSec: 90, // DLA des monstres : période tirée dans [min, max]
monsterDlaMaxSec: 360,
trollDlaSec: 180, // recharge des PA des trolls
pollSec: 5, // intervalle de rafraîchissement conseillé au client
monsterTarget: 24, // population de monstres visée
repopSec: 120, // période de vérification du repeuplement
itemTarget: 16, // trésors au sol visés
deathRespawnSec: 90, // délai de réapparition d'un troll terrassé
maxTrolls: 40,
logSize: 150,
};
const CONFIG_BOUNDS = {
mapW: [24, 80], mapH: [16, 60], worldDepth: [1, 5],
monsterDlaMinSec: [5, 3600], monsterDlaMaxSec: [5, 7200],
trollDlaSec: [5, 3600], pollSec: [1, 60],
monsterTarget: [0, 120], repopSec: [10, 3600],
itemTarget: [0, 120], deathRespawnSec: [0, 3600],
maxTrolls: [1, 200], logSize: [20, 500],
};
/* ---------- Création du monde ---------- */
function randomFloor(world, taken = null) {
const { grid, config } = world;
for (let tries = 0; tries < 800; tries++) {
const x = 1 + Math.floor(Math.random() * (config.mapW - 2));
const y = 1 + Math.floor(Math.random() * (config.mapH - 2));
if (grid[y][x] !== g.T_FLOOR) continue;
if (taken && taken.has(y * config.mapW + x)) continue;
if (occupied(world, x, y)) continue;
if (taken) taken.add(y * config.mapW + x);
return { x, y };
}
return null;
}
function occupied(world, x, y) {
return world.monsters.some(m => m.x === x && m.y === y)
|| Object.values(world.trolls).some(t => !t.dead && t.x === x && t.y === y);
}
function monsterDlaMs(config) {
const min = Math.min(config.monsterDlaMinSec, config.monsterDlaMaxSec);
const max = Math.max(config.monsterDlaMinSec, config.monsterDlaMaxSec);
return (min + Math.random() * (max - min)) * 1000;
}
function spawnMonster(world, now = Date.now()) {
const pos = randomFloor(world);
if (!pos) return null;
const m = g.makeMonster(world.config.worldDepth, pos.x, pos.y);
m.id = world.nextId++;
m.dlaMs = Math.round(monsterDlaMs(world.config));
m.nextDla = now + m.dlaMs;
world.monsters.push(m);
return m;
}
function spawnItem(world) {
const pos = randomFloor(world);
if (!pos) return null;
const r = Math.random();
const depth = world.config.worldDepth;
const item = r < 0.34 ? p.makeRandomPotion()
: r < 0.48 ? sc.makeRandomScroll()
: r < 0.78 ? gearLib.makeRandomGear(depth)
: { kind: "gold", gold: g.rollDice(depth, 6).total * 10, emoji: "💰" };
if (item.kind === "gold") item.name = `${item.gold} Mountyzédons`;
world.items.push({ ...item, x: pos.x, y: pos.y });
return item;
}
function createWorld(config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const world = {
config: cfg,
grid: g.generateCavern(cfg.mapW, cfg.mapH),
trolls: {},
monsters: [],
items: [],
log: [],
nextId: 1,
nextRepop: 0,
createdAt: Date.now(),
};
for (let i = 0; i < cfg.monsterTarget; i++) spawnMonster(world);
for (let i = 0; i < cfg.itemTarget; i++) spawnItem(world);
worldLog(world, "🌋 Le Monde Souterrain partagé s'éveille…");
return world;
}
/* ---------- Journal ---------- */
function worldLog(world, msg, cls = "info") {
world.log.push({ t: Date.now(), msg, cls });
while (world.log.length > world.config.logSize) world.log.shift();
}
function privLog(troll, msg, cls = "info") {
troll.privLog = troll.privLog || [];
troll.privLog.push({ t: Date.now(), msg, cls });
while (troll.privLog.length > 60) troll.privLog.shift();
}
/* ---------- Trolls ---------- */
function newTroll(world, name, race, now = Date.now()) {
if (!g.RACES[race]) return { error: "race inconnue" };
name = String(name || "").trim().slice(0, 20) || "Trõllinet";
const living = Object.keys(world.trolls).length;
if (living >= world.config.maxTrolls) return { error: "le Hall est plein (max atteint)" };
const pos = randomFloor(world);
if (!pos) return { error: "pas de place dans la caverne" };
const s = g.RACES[race].stats;
const troll = {
id: crypto.randomBytes(6).toString("hex"),
secret: crypto.randomBytes(16).toString("hex"),
name, race, x: pos.x, y: pos.y,
att: s.att, esq: s.esq, deg: s.deg, reg: s.reg,
pv: s.pvMax, pvMax: s.pvMax, vue: s.vue,
degBonus: 0, armor: 0, armorDice: 0,
bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 },
comp: { pct: g.START_COMP_PCT }, sort: { pct: g.START_SORT_PCT },
fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false,
equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null },
gearMods: null,
pa: g.PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
bag: [], camo: false, kills: 0, dla: 1, tour: 1,
potionEffects: [], blockCamoTurns: 0,
dead: false, respawnAt: 0,
nextDla: now + world.config.trollDlaSec * 1000,
lastSeen: now,
privLog: [],
};
world.trolls[troll.id] = troll;
worldLog(world, `${g.RACES[race].emoji} ${name} le ${race} entre dans le Monde Souterrain !`, "good");
return { troll };
}
function authTroll(world, id, secret) {
const t = world.trolls[id];
if (!t || t.secret !== secret) return null;
t.lastSeen = Date.now();
return t;
}
const chebyshev = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
function effTrollMP(t) {
return p.effTroll(t);
}
/* ---------- DLA et tick du monde ---------- */
/* DLA d'un troll : PA rechargés, régénération, effets magiques décomptés. */
function trollDla(world, t, now) {
t.dla++;
const dlaBonusPA = p.tickPotionTurns(t, () => {});
const te = effTrollMP(t);
if (!t.dead && t.pv < t.pvMax && t.pv > 0) {
const heal = Math.max(0, g.rollDice(te.reg, 3).total + (te.regFlat || 0));
t.pv = Math.min(t.pvMax, t.pv + heal);
if (heal) privLog(t, `💤 DLA n°${t.dla} : tu régénères ${heal} PV.`, "info");
}
t.pa = Math.max(1, Math.min(g.PA_PER_TURN + 3, g.PA_PER_TURN + dlaBonusPA));
t.compUsed = false;
t.compPXTurn = false;
t.sortPXTurn = false;
t.fatigue = Math.floor(t.fatigue / 1.25);
t.nextDla = now + world.config.trollDlaSec * 1000;
}
function killTrollMP(world, t, killer, now) {
t.dead = true;
t.camo = false;
t.respawnAt = now + world.config.deathRespawnSec * 1000;
privLog(t, `☠️ ${killer} t'a terrassé… Les Dieux Trõlls te ramèneront bientôt.`, "bad");
worldLog(world, `☠️ ${t.name} a été terrassé par ${killer} !`, "bad");
}
function respawnTroll(world, t) {
const pos = randomFloor(world);
if (!pos) return;
t.dead = false;
t.x = pos.x; t.y = pos.y;
t.pv = t.pvMax;
t.pa = g.PA_PER_TURN;
t.potionEffects = [];
t.blockCamoTurns = 0;
privLog(t, "✨ Les Dieux Trõlls te ramènent dans la caverne, requinqué.", "good");
worldLog(world, `${t.name} est de retour dans le Monde Souterrain.`, "info");
}
/* Activation d'un monstre à sa DLA : attaque un troll adjacent, sinon se
* rapproche du troll visible le plus proche, sinon erre. Jamais de monstre
* contre monstre. */
function monsterAct(world, m, now) {
if (m.skip > 0) {
m.skip--;
return;
}
const targets = Object.values(world.trolls).filter(t => !t.dead && !t.camo);
const em = monsterEff(m);
let best = null, bestDist = Infinity;
for (const t of targets) {
const d = chebyshev(m, t);
if (d < bestDist) { best = t; bestDist = d; }
}
if (best && bestDist <= 1) {
const te = effTrollMP(best);
const r = g.resolveAttack(em, te);
if (r.hit) {
best.pv -= r.damage;
privLog(best, `${m.emoji} ${m.name} t'attaque : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts${r.armorReduction ? ` (armure ${r.armorReduction})` : ""} !`, "bad");
if (best.pv <= 0) killTrollMP(world, best, m.name, now);
} else {
privLog(best, `${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat");
}
} else if (best && bestDist <= em.vue) {
stepTowardMP(world, m, best);
} else if (!m.static && Math.random() < 0.6) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const [dx, dy] = dirs[Math.floor(Math.random() * 4)];
moveMonsterMP(world, m, m.x + dx, m.y + dy);
}
// les statuts s'estompent à chaque activation
if (m.esqHalfTurns > 0) m.esqHalfTurns--;
if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0;
if (m.vueMalusTurns > 0 && --m.vueMalusTurns === 0) m.vueMalus = 0;
}
function monsterEff(m) {
return {
...m,
att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)),
esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq,
vue: Math.max(1, m.vue - (m.vueMalusTurns > 0 ? m.vueMalus || 0 : 0)),
armorPhys: m.armor || 0,
armorMag: m.armorMag || 0,
};
}
function stepTowardMP(world, m, t) {
if (m.static) return;
const dx = Math.sign(t.x - m.x), dy = Math.sign(t.y - m.y);
if (dx !== 0 && moveMonsterMP(world, m, m.x + dx, m.y)) return;
if (dy !== 0 && moveMonsterMP(world, m, m.x, m.y + dy)) return;
if (dx !== 0 && dy !== 0) moveMonsterMP(world, m, m.x + dx, m.y + dy);
}
function moveMonsterMP(world, m, nx, ny) {
const { mapW, mapH } = world.config;
if (nx < 0 || ny < 0 || nx >= mapW || ny >= mapH) return false;
if (world.grid[ny][nx] === g.T_WALL) return false;
if (Object.values(world.trolls).some(t => !t.dead && t.x === nx && t.y === ny)) return false;
if (world.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false;
m.x = nx; m.y = ny;
return true;
}
/* Tick global : appelé régulièrement (1 s) par server.js. Retourne true si
* l'état a changé (pour la sauvegarde). */
function tick(world, now = Date.now()) {
let changed = false;
for (const m of world.monsters) {
if (m.nextDla <= now) {
monsterAct(world, m, now);
m.dlaMs = Math.round(monsterDlaMs(world.config)); // re-tirée : DLA vivante
m.nextDla = now + m.dlaMs;
changed = true;
}
}
for (const t of Object.values(world.trolls)) {
if (t.dead && t.respawnAt <= now) { respawnTroll(world, t); changed = true; }
if (!t.dead && t.nextDla <= now) { trollDla(world, t, now); changed = true; }
}
if (world.nextRepop <= now) {
world.nextRepop = now + world.config.repopSec * 1000;
while (world.monsters.length < world.config.monsterTarget) { spawnMonster(world, now); changed = true; }
while (world.items.length < world.config.itemTarget) { spawnItem(world); changed = true; }
}
return changed;
}
/* ---------- Actions des joueurs ---------- */
function spendPA(t, cost) {
if (t.pa < cost) return false;
t.pa -= cost;
return true;
}
function monsterById(world, id) {
return world.monsters.find(m => m.id === id);
}
function gainPXMP(t, n) {
if (!n) return;
t.pi += n;
t.totalPI += n;
}
function killMonsterMP(world, t, m) {
const lvl = g.levelFromTotalPI(t.totalPI);
const px = g.killPX(lvl, m.level);
if (px > 0) gainPXMP(t, px);
t.kills++;
privLog(t, `💀 ${m.name} est terrassé !${px > 0 ? ` +${px} PX.` : " Pas de PX (adversaire trop faible)."}`, "good");
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)) {
const lr = Math.random();
const drop = lr < 0.4 ? { ...p.makeRandomPotion(), x: m.x, y: m.y }
: lr < 0.65 ? { ...sc.makeRandomScroll(), 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);
privLog(t, `${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info");
}
world.monsters = world.monsters.filter(x => x !== m);
}
/* Attaque de base d'un troll sur un monstre (3 PA). */
function actAttack(world, t, m) {
if (!spendPA(t, g.COSTS.attack)) return { error: "pas assez de PA" };
const r = g.resolveAttack(effTrollMP(t), monsterEff(m));
if (t.camo) { t.camo = false; privLog(t, "Ton attaque brise le camouflage.", "info"); }
if (r.hit) {
m.pv -= r.damage;
gainPXMP(t, 1);
privLog(t, `Tu attaques ${m.name} : ${r.attRoll} vs ${r.esqRoll}${r.critical ? "CRITIQUE ! " : ""}${r.damage} dégâts${r.armorReduction ? ` (armure ${r.armorReduction})` : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else {
privLog(t, `Tu attaques ${m.name} : ${r.attRoll} vs ${r.esqRoll} → esquivé !`, "combat");
}
return { ok: true };
}
/* Talent (compétence/sortilège) : jet de maîtrise mutualisé avec le solo. */
function tryTalentMP(t, talent, cap, cost) {
if (!spendPA(t, cost)) return { error: "pas assez de PA" };
const before = talent.pct;
const effPct = p.talentPctWithPotions(t, talent, cap);
const r = g.masteryRoll(talent, cap, effPct);
talent.tries = (talent.tries || 0) + 1;
if (r.success) talent.successes = (talent.successes || 0) + 1;
talent.lastUse = Date.now();
if (!r.success) {
t.pa += Math.floor(cost / 2);
privLog(t, `Échec du talent (jet ${r.roll} > ${effPct} %). Moitié des PA remboursée.${talent.pct > before ? ` Maîtrise → ${talent.pct} %.` : ""}`, "combat");
return { failed: true };
}
if (talent.pct > before) privLog(t, `Maîtrise +${talent.pct - before} % → ${talent.pct} %.`, "good");
return { ok: true };
}
const trollMMMP = t => g.levelFromTotalPI(t.totalPI) + 1;
const monsterRMMP = m => Math.ceil(m.level / 2);
function resistMP(t, m) {
const te = effTrollMP(t);
const mm = trollMMMP(t) + Math.round(te.mmPct / 10);
const rm = monsterRMMP(m) + Math.round((te.rmPct || 0) / 10);
return !g.resolveSpell(mm, rm).success; // true = la cible résiste (effet réduit)
}
function actComp(world, t, m) {
const te = effTrollMP(t);
const comp = g.RACES[t.race].comp;
const pxOnce = () => { if (!t.compPXTurn) { gainPXMP(t, 1); t.compPXTurn = true; } };
if (t.race === "Skrim") {
if (t.compUsed) return { error: "Botte Secrète déjà utilisée cette DLA" };
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.compUsed = true;
const pseudo = {
att: Math.max(1, Math.floor(te.att * 2 / 3)), deg: Math.max(1, Math.floor(te.att / 2)),
degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat,
};
const r = g.resolveAttack(pseudo, monsterEff(m));
if (r.hit) {
m.pv -= r.damage;
privLog(t, `🥋 Botte Secrète : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts !`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🥋 Botte Secrète esquivée (${r.attRoll} vs ${r.esqRoll}).`, "combat");
} else if (t.race === "Durakuir") {
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
const dice = Math.max(1, Math.floor(t.pvMax / 15));
const heal = g.rollDice(dice, 3).total;
t.pv = Math.min(t.pvMax, t.pv + heal);
privLog(t, `🥋 Régénération Accrue : +${heal} PV (${dice}D3).`, "good");
} else if (t.race === "Kastar") {
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
const cost = g.rollDice(1, 3).total + t.fatigue;
t.fatigue += 1;
t.pv -= cost;
t.pa = Math.min(g.PA_PER_TURN, t.pa + 4);
privLog(t, `🥋 Métabolisme : ${cost} PV, +4 PA (fatigue ${t.fatigue}).`, "good");
if (t.pv <= 0) killTrollMP(world, t, "son propre métabolisme", Date.now());
} else if (t.race === "Tomawak") {
if (t.blockCamoTurns > 0) return { error: "la Pàïntûré t'empêche de te camoufler" };
if (t.camo) return { error: "déjà camouflé" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.camo = true;
privLog(t, "🌫️ Camouflage : les monstres ne te voient plus.", "good");
} else if (t.race === "Darkling") {
if (t.compUsed) return { error: "Balayage déjà utilisé cette DLA" };
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.compUsed = true;
const destab = g.rollDice(te.att, 6).total;
const stab = g.rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total;
if (destab > stab) {
m.skip = (m.skip || 0) + 1;
privLog(t, `🥋 Balayage : ${destab} vs ${stab}${m.name} est à terre (perd sa prochaine DLA) !`, "good");
} else privLog(t, `🥋 Balayage : ${destab} vs ${stab}${m.name} reste stable.`, "combat");
}
return { ok: true };
}
function actSort(world, t, m) {
const te = effTrollMP(t);
const sort = g.RACES[t.race].sort;
const pxOnce = () => { if (!t.sortPXTurn) { gainPXMP(t, 1); t.sortPXTurn = true; } };
if (t.race === "Skrim") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const resisted = resistMP(t, m);
m.esqHalfTurns = (m.esqHalfTurns || 0) + (resisted ? 1 : 2);
if (!resisted) m.skip = (m.skip || 0) + 1;
privLog(t, resisted
? `🔮 Hypnotisme : ${m.name} résiste — esquive réduite 1 DLA seulement.`
: `🔮 Hypnotisme : ${m.name} est hébété ! Esquive ÷2 et DLA perdue.`, "good");
} else if (t.race === "Durakuir") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const armMag = Math.max(0, monsterEff(m).armorMag);
let dmg = Math.max(1, g.rollDice(te.deg, 3).total - armMag);
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
privLog(t, `🔮 Rafale Psychique : touche automatique, ${dmg} dégâts${resisted ? " (résisté)" : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else if (t.race === "Kastar") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const pseudo = {
att: Math.max(1, Math.floor(te.deg * 2 / 3)), deg: te.deg,
degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat,
};
const r = g.resolveAttack(pseudo, monsterEff(m), { magic: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
const heal = Math.ceil(dmg / 2);
t.pv = Math.min(t.pvMax, t.pv + heal);
privLog(t, `🔮 Vampirisme : ${dmg} dégâts${resisted ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Vampirisme esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
} else if (t.race === "Tomawak") {
if (!m) return { error: "aucun monstre en vue" };
const dist = chebyshev(m, t);
if (dist > te.vue) return { error: "cible hors de portée (Vue)" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const proxBonus = Math.max(0, te.vue - dist);
const pseudo = { att: te.vue + proxBonus, deg: Math.max(1, Math.floor(te.vue / 2)), degBonus: 0 };
const r = g.resolveAttack(pseudo, monsterEff(m), { magic: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
privLog(t, `🔮 Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${resisted ? " (résisté)" : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Projectile Magique esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
if (t.camo && 1 + Math.floor(Math.random() * 100) > Math.floor(t.comp.pct * 0.25)) {
t.camo = false;
privLog(t, "Ton camouflage se dissipe dans l'éclat du projectile.", "info");
}
} else if (t.race === "Darkling") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const pseudo = { att: te.att, deg: te.reg, degBonus: 0, attFlat: te.attFlat, degFlat: te.degFlat };
const r = g.resolveAttack(pseudo, monsterEff(m), { ignoreArmor: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
const necrose = resisted ? Math.max(1, Math.floor(te.reg / 2)) : te.reg;
m.attDownDice = (m.attDownDice || 0) + necrose;
m.attDownTurns = 2;
privLog(t, `🔮 Siphon des Âmes : ${dmg} dégâts${resisted ? " (résisté)" : ""}, nécrose ${necrose} dé(s) d'ATT.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
}
return { ok: true };
}
/* Point d'entrée des actions : { type, ... }. Retourne { ok } ou { error }. */
function action(world, t, act) {
if (t.dead) return { error: "tu es terrassé — attends ta réapparition" };
const { mapW, mapH } = world.config;
if (act.type === "move") {
const dx = Math.sign(act.dx || 0), dy = Math.sign(act.dy || 0);
if (!dx && !dy) return { error: "déplacement nul" };
const nx = t.x + dx, ny = t.y + dy;
if (nx < 0 || ny < 0 || nx >= mapW || ny >= mapH) return { error: "hors du monde" };
if (world.grid[ny][nx] === g.T_WALL) return { error: "un mur" };
const m = world.monsters.find(m => m.x === nx && m.y === ny);
if (m) return actAttack(world, t, m);
if (Object.values(world.trolls).some(o => o !== t && !o.dead && o.x === nx && o.y === ny))
return { error: "un troll occupe cette case" };
if (!spendPA(t, g.COSTS.move)) return { error: "pas assez de PA" };
t.x = nx; t.y = ny;
if (t.camo) {
const threshold = Math.floor(t.comp.pct * 0.75);
if (1 + Math.floor(Math.random() * 100) > threshold) {
t.camo = false;
privLog(t, "Un caillou roule sous ton pied : te voilà repéré !", "bad");
}
}
return { ok: true };
}
if (act.type === "attack") {
const m = monsterById(world, act.target);
if (!m || chebyshev(m, t) > 1) return { error: "aucun monstre adjacent à attaquer" };
return actAttack(world, t, m);
}
if (act.type === "comp" || act.type === "sort") {
let m = act.target != null ? monsterById(world, act.target) : null;
if (!m) {
// cible implicite : adjacent (ou plus proche visible pour le Projectile)
const te = effTrollMP(t);
const range = act.type === "sort" && t.race === "Tomawak" ? te.vue : 1;
let bestDist = Infinity;
for (const cand of world.monsters) {
const d = chebyshev(cand, t);
if (d <= range && d < bestDist) { m = cand; bestDist = d; }
}
}
return act.type === "comp" ? actComp(world, t, m) : actSort(world, t, m);
}
if (act.type === "pickup") {
const i = world.items.find(it => it.x === t.x && it.y === t.y);
if (!i) return { error: "rien à ramasser ici" };
if (!spendPA(t, g.COSTS.pickup)) return { error: "pas assez de PA" };
world.items = world.items.filter(x => x !== i);
if (i.kind === "gold") {
t.gold += i.gold;
privLog(t, `💰 Tu ramasses ${i.gold} Mountyzédons.`, "good");
} else {
const { x, y, ...item } = i;
t.bag.push(item);
privLog(t, `Tu ramasses ${i.emoji} ${i.name}.`, "good");
}
return { ok: true };
}
if (act.type === "use") {
const item = t.bag[act.idx];
if (!item) return { error: "objet introuvable dans le sac" };
if (item.kind === "potion") {
if (!spendPA(t, g.COSTS.potion)) return { error: "pas assez de PA" };
if (!p.drinkPotion(t, item, g.rollDice, (msg, cls) => privLog(t, msg, cls))) return { error: "fiole inconnue" };
t.bag.splice(act.idx, 1);
if (t.pv <= 0) killTrollMP(world, t, "une fiole douteuse", Date.now());
} else if (item.kind === "scroll") {
if (!spendPA(t, g.COSTS.scroll)) return { error: "pas assez de PA" };
const res = sc.readScroll(t, item, g.rollDice, (msg, cls) => privLog(t, msg, cls));
if (!res) return { error: "parchemin illisible" };
t.bag.splice(act.idx, 1);
if (res.zone) applyZoneMP(world, t, res.zone);
if (t.pv <= 0) killTrollMP(world, t, "un parchemin retors", Date.now());
} else if (item.kind === "gear") {
if (!spendPA(t, g.COSTS.equip)) return { error: "pas assez de PA" };
t.bag.splice(act.idx, 1);
g.equipGear(t, item);
privLog(t, `Tu t'équipes : ${item.emoji} ${item.name}.`, "good");
}
return { ok: true };
}
if (act.type === "train") {
const stat = act.stat;
if (!["att", "esq", "deg", "reg", "pv", "vue", "armor"].includes(stat)) return { error: "caractéristique inconnue" };
const cost = g.improveCost(stat, t.bought[stat], t.race);
if (t.pi < cost) return { error: `il te faut ${cost} PI` };
t.pi -= cost;
t.bought[stat] += 1;
if (stat === "pv") { t.pvMax += 10; t.pv += 10; }
else if (stat === "armor") t.armorDice += 1;
else t[stat] += 1;
privLog(t, `📈 Entraînement : ${stat} améliorée pour ${cost} PI.`, "good");
return { ok: true };
}
return { error: "action inconnue" };
}
/* Effet de zone d'un parchemin en multi : monstres à 3 cases ou moins. */
function applyZoneMP(world, t, zone) {
const targets = world.monsters.filter(m => chebyshev(m, t) <= sc.SCROLL_ZONE_RADIUS);
for (const m of targets) {
if (zone.type === "damage") {
const armMag = Math.max(0, m.armorMag || 0);
const dmg = Math.max(1, zone.total - armMag);
m.pv -= dmg;
privLog(t, `💥 ${m.name} est pris dans l'explosion : ${dmg} PV.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else if (zone.type === "vue") {
m.vueMalus = Math.max(m.vueMalus || 0, zone.malus);
m.vueMalusTurns = Math.max(m.vueMalusTurns || 0, zone.turns);
privLog(t, `🌶️ ${m.name} est aveuglé : VUE ${zone.malus}.`, "good");
}
}
}
/* ---------- État envoyé au client ---------- */
function gridStrings(world) {
return world.grid.map(row => row.map(c => c === g.T_WALL ? "#" : ".").join(""));
}
/* Vue du troll : terrain complet (le brouillard est géré côté client),
* entités limitées à la portée de Vue. Les trolls camouflés sont invisibles
* pour les autres. */
function stateFor(world, t, now = Date.now()) {
const te = effTrollMP(t);
const vue = te.vue;
const seen = e => chebyshev(e, t) <= vue;
return {
now,
config: { pollSec: world.config.pollSec, trollDlaSec: world.config.trollDlaSec, mapW: world.config.mapW, mapH: world.config.mapH },
map: gridStrings(world),
you: {
id: t.id, name: t.name, race: t.race, x: t.x, y: t.y,
pv: t.pv, pvMax: t.pvMax, pa: t.pa, pi: t.pi, gold: t.gold, kills: t.kills,
dla: t.dla, level: g.levelFromTotalPI(t.totalPI),
dead: t.dead, respawnIn: t.dead ? Math.max(0, t.respawnAt - now) : 0,
nextDlaIn: Math.max(0, t.nextDla - now),
camo: t.camo, fatigue: t.fatigue, compUsed: t.compUsed,
att: t.att, esq: t.esq, deg: t.deg, reg: t.reg, vue: t.vue,
armorDice: t.armorDice, bought: t.bought,
comp: { pct: t.comp.pct }, sort: { pct: t.sort.pct },
eff: te,
equip: t.equip, bag: t.bag,
effects: (t.potionEffects || []).map(e => ({ name: e.name, emoji: e.emoji, turnsLeft: e.turnsLeft, modLines: e.modLines || [] })),
blockCamoTurns: t.blockCamoTurns,
},
trolls: Object.values(world.trolls)
.filter(o => o !== t && !o.dead && !o.camo && seen(o))
.map(o => ({ name: o.name, race: o.race, x: o.x, y: o.y, level: g.levelFromTotalPI(o.totalPI), pvPct: Math.max(0, o.pv / o.pvMax) })),
monsters: world.monsters.filter(seen)
.map(m => ({ id: m.id, name: m.name, emoji: m.emoji, x: m.x, y: m.y, level: m.level, pvPct: Math.max(0, m.pv / m.pvMax), nextDlaIn: Math.max(0, m.nextDla - now) })),
items: world.items.filter(seen)
.map(i => ({ name: i.name, emoji: i.emoji, x: i.x, y: i.y, kind: i.kind, color: i.color })),
log: world.log.slice(-25),
privLog: (t.privLog || []).slice(-30),
};
}
/* ---------- Admin ---------- */
function adminOverview(world, now = Date.now()) {
return {
config: world.config,
bounds: CONFIG_BOUNDS,
uptime: now - world.createdAt,
trolls: Object.values(world.trolls).map(t => ({
id: t.id, name: t.name, race: t.race, level: g.levelFromTotalPI(t.totalPI),
pv: t.pv, pvMax: t.pvMax, pa: t.pa, kills: t.kills, gold: t.gold,
dead: t.dead, x: t.x, y: t.y,
lastSeenAgo: now - (t.lastSeen || now),
})),
monsters: world.monsters.map(m => ({
id: m.id, name: m.name, emoji: m.emoji, level: m.level, pv: m.pv, pvMax: m.pvMax,
x: m.x, y: m.y, dlaSec: Math.round(m.dlaMs / 1000), nextDlaIn: Math.max(0, m.nextDla - now),
})),
items: world.items.length,
logTail: world.log.slice(-30),
};
}
/* Applique des réglages (validés et bornés). La taille de carte ne change
* qu'à la régénération du monde. Retourne la config effective. */
function adminSetConfig(world, patch, now = Date.now()) {
const cfg = world.config;
for (const [key, bounds] of Object.entries(CONFIG_BOUNDS)) {
if (patch[key] === undefined) continue;
const v = Number(patch[key]);
if (!Number.isFinite(v)) continue;
cfg[key] = Math.max(bounds[0], Math.min(bounds[1], Math.round(v)));
}
if (cfg.monsterDlaMinSec > cfg.monsterDlaMaxSec)
cfg.monsterDlaMaxSec = cfg.monsterDlaMinSec;
// effet immédiat : les DLA déjà planifiées au-delà des nouvelles bornes sont re-tirées
for (const m of world.monsters) {
if (m.nextDla - now > cfg.monsterDlaMaxSec * 1000) {
m.dlaMs = Math.round(monsterDlaMs(cfg));
m.nextDla = now + m.dlaMs;
}
}
for (const t of Object.values(world.trolls)) {
if (t.nextDla - now > cfg.trollDlaSec * 1000) t.nextDla = now + cfg.trollDlaSec * 1000;
}
return cfg;
}
/* Régénère le monde (carte, monstres, trésors) en conservant trolls et config ;
* les trolls sont replacés. */
function adminResetWorld(world) {
const cfg = world.config;
world.grid = g.generateCavern(cfg.mapW, cfg.mapH);
world.monsters = [];
world.items = [];
world.nextId = 1;
world.nextRepop = 0;
for (let i = 0; i < cfg.monsterTarget; i++) spawnMonster(world);
for (let i = 0; i < cfg.itemTarget; i++) spawnItem(world);
for (const t of Object.values(world.trolls)) {
const pos = randomFloor(world);
if (pos) { t.x = pos.x; t.y = pos.y; }
privLog(t, "🌋 Le Monde Souterrain a été régénéré par les Dieux Trõlls !", "info");
}
worldLog(world, "🌋 Le Monde Souterrain a été régénéré !", "good");
}
/* ---------- Persistance ---------- */
function saveWorld(world, file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(world));
fs.renameSync(tmp, file);
}
function loadWorld(file) {
try {
const world = JSON.parse(fs.readFileSync(file, "utf8"));
if (!world || !world.grid || !world.trolls) return null;
world.config = { ...DEFAULT_CONFIG, ...world.config };
return world;
} catch { return null; }
}
module.exports = {
DEFAULT_CONFIG, CONFIG_BOUNDS,
createWorld, tick, newTroll, authTroll, action, stateFor,
adminOverview, adminSetConfig, adminResetWorld,
saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
};
+116 -4
View File
@@ -1,5 +1,6 @@
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires. /* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires
* Node pur, aucune dépendance. Stockage : un fichier JSON (LEVELS_FILE). */ * + monde multijoueur persistant (mp.js).
* Node pur, aucune dépendance. Stockage : fichiers JSON (LEVELS_FILE, WORLD_FILE). */
"use strict"; "use strict";
@@ -7,10 +8,12 @@ const http = require("http");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const mp = require("./mp.js");
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const ROOT = __dirname; const ROOT = __dirname;
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json"; const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
const WORLD_FILE = process.env.WORLD_FILE || "/data/world.json";
const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large
const MAX_LEVELS = 500; const MAX_LEVELS = 500;
@@ -111,7 +114,115 @@ function readBody(req, res, cb) {
}); });
} }
/* ---------- Multijoueur : monde partagé (mp.js) ---------- */
let WORLD = null; // initialisé au démarrage du serveur seulement
let worldDirty = false;
let ADMIN_TOKEN = null;
function initMP() {
WORLD = mp.loadWorld(WORLD_FILE) || mp.createWorld();
// token admin : variable d'environnement, sinon généré et persisté à côté du monde
ADMIN_TOKEN = process.env.MP_ADMIN_TOKEN || null;
if (!ADMIN_TOKEN) {
const tokenFile = path.join(path.dirname(WORLD_FILE), "admin-token.txt");
try { ADMIN_TOKEN = fs.readFileSync(tokenFile, "utf8").trim(); } catch {}
if (!ADMIN_TOKEN) {
ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
try {
fs.mkdirSync(path.dirname(tokenFile), { recursive: true });
fs.writeFileSync(tokenFile, ADMIN_TOKEN);
} catch {}
}
}
console.log(`Monde multijoueur prêt (${Object.keys(WORLD.trolls).length} troll(s), ${WORLD.monsters.length} monstre(s)).`);
console.log(`Token admin : ${ADMIN_TOKEN}`);
setInterval(() => {
if (mp.tick(WORLD)) worldDirty = true;
}, 1000);
setInterval(() => {
if (!worldDirty) return;
worldDirty = false;
try { mp.saveWorld(WORLD, WORLD_FILE); } catch (e) { console.error("sauvegarde du monde :", e.message); }
}, 15000);
process.on("SIGTERM", () => {
try { mp.saveWorld(WORLD, WORLD_FILE); } catch {}
process.exit(0);
});
}
function isAdmin(req, url) {
const provided = req.headers["x-admin-token"] || url.searchParams.get("token");
return ADMIN_TOKEN && provided === ADMIN_TOKEN;
}
function handleMP(req, res, url) {
if (!WORLD) return sendJSON(res, 503, { error: "monde non initialisé" });
if (req.method === "POST" && url.pathname === "/api/mp/join") {
return readBody(req, res, body => {
const r = mp.newTroll(WORLD, body.name, body.race);
if (r.error) return sendJSON(res, 400, { error: r.error });
worldDirty = true;
return sendJSON(res, 201, {
id: r.troll.id, secret: r.troll.secret,
state: mp.stateFor(WORLD, r.troll),
});
});
}
if (req.method === "GET" && url.pathname === "/api/mp/state") {
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" });
return sendJSON(res, 200, mp.stateFor(WORLD, t));
}
if (req.method === "POST" && url.pathname === "/api/mp/action") {
return readBody(req, res, body => {
const t = mp.authTroll(WORLD, body.id, body.secret);
if (!t) return sendJSON(res, 403, { error: "troll inconnu ou clé invalide" });
const r = mp.action(WORLD, t, body.action || {});
worldDirty = true;
return sendJSON(res, r.error ? 400 : 200, { ...r, state: mp.stateFor(WORLD, t) });
});
}
if (req.method === "GET" && url.pathname === "/api/mp/info") {
const now = Date.now();
const online = Object.values(WORLD.trolls).filter(t => now - (t.lastSeen || 0) < 5 * 60 * 1000).length;
return sendJSON(res, 200, {
trolls: Object.keys(WORLD.trolls).length, online,
monsters: WORLD.monsters.length, pollSec: WORLD.config.pollSec,
});
}
/* --- Admin (X-Admin-Token ou ?token=) --- */
if (url.pathname.startsWith("/api/mp/admin")) {
if (!isAdmin(req, url)) return sendJSON(res, 403, { error: "token admin invalide" });
if (req.method === "GET" && url.pathname === "/api/mp/admin") {
return sendJSON(res, 200, mp.adminOverview(WORLD));
}
if (req.method === "PUT" && url.pathname === "/api/mp/admin/config") {
return readBody(req, res, body => {
const cfg = mp.adminSetConfig(WORLD, body);
worldDirty = true;
mp.worldLog(WORLD, "⚙️ Les Dieux Trõlls ont ajusté les lois du monde.");
return sendJSON(res, 200, cfg);
});
}
if (req.method === "POST" && url.pathname === "/api/mp/admin/reset") {
mp.adminResetWorld(WORLD);
worldDirty = true;
return sendJSON(res, 200, { ok: true });
}
}
sendJSON(res, 404, { error: "route multijoueur inconnue" });
}
function handleAPI(req, res, url) { function handleAPI(req, res, url) {
if (url.pathname.startsWith("/api/mp/")) return handleMP(req, res, url);
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/); const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
if (req.method === "GET" && url.pathname === "/api/levels") { if (req.method === "GET" && url.pathname === "/api/levels") {
@@ -175,7 +286,7 @@ function handleStatic(req, res, url) {
let p = decodeURIComponent(url.pathname); let p = decodeURIComponent(url.pathname);
if (p === "/") p = "/index.html"; if (p === "/") p = "/index.html";
const file = path.normalize(path.join(ROOT, p)); const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js")) { if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js") || file === path.join(ROOT, "mp.js")) {
res.writeHead(403); return res.end("interdit"); res.writeHead(403); return res.end("interdit");
} }
fs.readFile(file, (err, data) => { fs.readFile(file, (err, data) => {
@@ -193,7 +304,8 @@ const server = http.createServer((req, res) => {
}); });
if (require.main === module) { if (require.main === module) {
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}`)); initMP();
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}, monde dans ${WORLD_FILE}`));
} }
module.exports = { validateLevel, MAP_W, MAP_H }; module.exports = { validateLevel, MAP_W, MAP_H };
+236
View File
@@ -0,0 +1,236 @@
/* Tests du moteur multijoueur — exécuter avec : node test/mp.js */
"use strict";
const assert = require("assert");
const mp = require("../mp.js");
const g = require("../js/game.js");
/* Monde de test : petit, DLA courtes pour simuler. */
function makeWorld(over = {}) {
return mp.createWorld({
mapW: 30, mapH: 20, monsterTarget: 8, itemTarget: 5,
monsterDlaMinSec: 60, monsterDlaMaxSec: 120, trollDlaSec: 60,
deathRespawnSec: 30, ...over,
});
}
// Création du monde : carte, monstres avec DLA propres, trésors
{
const w = makeWorld();
assert.strictEqual(w.grid.length, 20);
assert.strictEqual(w.grid[0].length, 30);
assert(w.monsters.length === 8, "population initiale de monstres");
assert(w.items.length === 5, "trésors initiaux");
for (const m of w.monsters) {
assert(typeof m.id === "number", "monstre : id");
assert(m.dlaMs >= 60000 && m.dlaMs <= 120000, "DLA du monstre dans la fourchette : " + m.dlaMs);
assert(m.nextDla > Date.now() - 1000, "prochaine DLA planifiée");
assert(typeof m.armorMag === "number", "armure magique présente");
}
// pas deux entités sur la même case
const cells = new Set();
for (const m of w.monsters) {
const k = m.y * 30 + m.x;
assert(!cells.has(k), "deux monstres sur la même case");
cells.add(k);
}
}
// Création de troll : profil de race, identité secrète
{
const w = makeWorld();
const r = mp.newTroll(w, "Grosbill", "Durakuir");
assert(r.troll, "troll créé");
assert.strictEqual(r.troll.pvMax, 40, "PV max Durakuir");
assert(r.troll.id.length === 12 && r.troll.secret.length === 32, "identité");
assert(mp.authTroll(w, r.troll.id, r.troll.secret) === r.troll, "auth ok");
assert(mp.authTroll(w, r.troll.id, "mauvaise-clé") === null, "auth refusée");
assert(mp.newTroll(w, "X", "Elfe").error, "race inconnue refusée");
const full = makeWorld({ maxTrolls: 1 });
mp.newTroll(full, "A", "Skrim");
assert(mp.newTroll(full, "B", "Skrim").error, "limite de trolls");
}
// Tick : les monstres agissent à leur DLA (et seulement à leur DLA)
{
const w = makeWorld();
const now = Date.now();
const m = w.monsters[0];
const before = { x: m.x, y: m.y, nextDla: m.nextDla };
assert.strictEqual(mp.tick(w, now), false, "aucune DLA échue → pas de changement");
m.nextDla = now - 1;
assert.strictEqual(mp.tick(w, now), true, "DLA échue → activation");
assert(m.nextDla > now, "DLA re-planifiée : " + m.nextDla);
assert(m.dlaMs >= 60000 && m.dlaMs <= 120000, "nouvelle période dans la fourchette");
void before;
}
// DLA du troll : recharge des PA, régénération
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Regen", "Durakuir");
t.pa = 0;
t.pv = 10;
const now = Date.now();
t.nextDla = now - 1;
mp.tick(w, now);
assert.strictEqual(t.pa, g.PA_PER_TURN, "PA rechargés");
assert(t.pv > 10, "régénération à la DLA");
assert(t.nextDla > now, "prochaine DLA planifiée");
}
// Mort et réapparition
{
const w = makeWorld({ deathRespawnSec: 1 });
const { troll: t } = mp.newTroll(w, "Mortel", "Skrim");
// un monstre surpuissant adjacent le terrasse à sa DLA
const m = w.monsters[0];
m.x = t.x; m.y = t.y + (w.grid[t.y + 1] && w.grid[t.y + 1][t.x] !== undefined ? 1 : -1);
// place le monstre adjacent quoi qu'il arrive
m.x = t.x; m.y = t.y; m.x = Math.max(0, t.x - 1);
m.att = 80; m.deg = 50;
t.pv = 1;
const now = Date.now();
m.nextDla = now - 1;
mp.tick(w, now);
assert(t.dead, "troll terrassé");
const r = mp.action(w, t, { type: "move", dx: 1, dy: 0 });
assert(r.error, "un mort ne joue pas");
mp.tick(w, now + 2000);
assert(!t.dead, "réapparition après le délai");
assert.strictEqual(t.pv, t.pvMax, "réapparition PV pleins");
}
// Actions : déplacement, PA, ramassage, entraînement
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Actif", "Skrim");
// trouve une direction libre
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let moved = false;
for (const [dx, dy] of dirs) {
const r = mp.action(w, t, { type: "move", dx, dy });
if (r.ok) { moved = true; break; }
}
assert(moved, "au moins une direction praticable");
assert.strictEqual(t.pa, g.PA_PER_TURN - 1, "déplacement = 1 PA");
t.pa = 0;
assert(mp.action(w, t, { type: "move", dx: 1, dy: 0 }).error, "sans PA, pas d'action");
// ramassage (on libère d'abord la case d'un éventuel trésor aléatoire)
t.pa = 6;
w.items = w.items.filter(i => i.x !== t.x || i.y !== t.y);
w.items.push({ kind: "gold", gold: 50, name: "50 Mountyzédons", emoji: "💰", x: t.x, y: t.y });
const r2 = mp.action(w, t, { type: "pickup" });
assert(r2.ok && t.gold === 50, "or ramassé");
// entraînement
t.pi = 12;
const r3 = mp.action(w, t, { type: "train", stat: "att" });
assert(r3.ok && t.att === 5 && t.pi === 0, "ATT Skrim améliorée pour 12 PI");
assert(mp.action(w, t, { type: "train", stat: "att" }).error, "plus assez de PI");
}
// Combat troll → monstre, PX et mise à mort
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Tueur", "Kastar");
t.att = 50; t.deg = 20; // écrasant
const m = w.monsters[0];
m.x = t.x + 1; m.y = t.y;
m.pv = 5;
t.pa = 6;
const r = mp.action(w, t, { type: "attack", target: m.id });
assert(r.ok, "attaque résolue");
assert(!w.monsters.includes(m), "monstre terrassé");
assert(t.kills === 1 && t.totalPI > 0, "PX de mise à mort");
}
// Les monstres ne s'attaquent jamais entre eux : un monstre adjacent à un autre
// monstre (sans troll) ne fait que bouger ou rester sur place
{
const w = makeWorld();
// aucun troll : on tique longtemps, les PV des monstres ne bougent pas
const pvs = w.monsters.map(m => m.pv);
let now = Date.now();
for (let i = 0; i < 50; i++) {
now += 200000;
mp.tick(w, now);
}
const alive = w.monsters.map(m => m.pv);
assert(alive.every(pv => pv > 0), "tous les monstres vivants");
// (la population peut avoir augmenté par repop, mais aucun PV n'a baissé)
for (let i = 0; i < Math.min(pvs.length, alive.length); i++) {
assert(alive[i] > 0, "pas d'attaque entre monstres");
}
}
// Repeuplement : la population remonte vers la cible
{
const w = makeWorld({ monsterTarget: 6, repopSec: 10 });
w.monsters = w.monsters.slice(0, 2);
mp.tick(w, Date.now() + 11000 + 1);
assert(w.monsters.length >= 6, "repeuplement vers la cible : " + w.monsters.length);
}
// Vue : un troll ne voit que ce qui est à portée ; camouflage invisible aux autres
{
const w = makeWorld();
const { troll: t1 } = mp.newTroll(w, "Voyeur", "Skrim");
const { troll: t2 } = mp.newTroll(w, "Discret", "Tomawak");
t1.x = 5; t1.y = 5; // positions forcées : la visibilité ne dépend pas du terrain
t2.x = 7; t2.y = 5;
let st = mp.stateFor(w, t1);
assert(st.trolls.some(o => o.name === "Discret"), "troll visible à 2 cases");
t2.camo = true;
st = mp.stateFor(w, t1);
assert(!st.trolls.some(o => o.name === "Discret"), "troll camouflé invisible");
t2.camo = false;
t2.x = 25; t2.y = 18; // distance Chebyshev 20 ≫ vue 3
st = mp.stateFor(w, t1);
assert(!st.trolls.some(o => o.name === "Discret"), "troll hors de vue");
// l'état ne fuit jamais le secret
assert(!JSON.stringify(st).includes(t2.secret), "pas de fuite de secret");
assert(st.map.length === w.config.mapH, "carte envoyée");
assert(st.you.nextDlaIn >= 0, "compte à rebours de DLA");
}
// Admin : config bornée et appliquée à chaud, reset, overview
{
const w = makeWorld();
mp.adminSetConfig(w, { trollDlaSec: 42, monsterDlaMinSec: 999999, pollSec: 2 });
assert.strictEqual(w.config.trollDlaSec, 42, "réglage appliqué");
assert.strictEqual(w.config.monsterDlaMinSec, 3600, "borné au max");
assert(w.config.monsterDlaMaxSec >= w.config.monsterDlaMinSec, "min ≤ max garanti");
assert.strictEqual(w.config.pollSec, 2);
mp.adminSetConfig(w, { trollDlaSec: "nawak" });
assert.strictEqual(w.config.trollDlaSec, 42, "valeur invalide ignorée");
// une baisse de la DLA des monstres re-planifie immédiatement les échéances
const now = Date.now();
mp.adminSetConfig(w, { monsterDlaMinSec: 5, monsterDlaMaxSec: 10 }, now);
for (const m of w.monsters) {
assert(m.nextDla - now <= 10000, "DLA re-planifiée dans la nouvelle borne : " + (m.nextDla - now));
}
const ov = mp.adminOverview(w);
assert(ov.config && ov.monsters.length === w.monsters.length && Array.isArray(ov.trolls), "overview");
mp.newTroll(w, "Persistant", "Skrim");
mp.adminResetWorld(w);
assert(Object.keys(w.trolls).length === 1, "trolls conservés au reset");
assert(w.monsters.length === w.config.monsterTarget, "monstres régénérés");
}
// Persistance : save + load
{
const os = require("os");
const path = require("path");
const file = path.join(os.tmpdir(), "mc-world-test.json");
const w = makeWorld();
mp.newTroll(w, "Durable", "Darkling");
mp.saveWorld(w, file);
const w2 = mp.loadWorld(file);
assert(w2, "monde rechargé");
assert.strictEqual(Object.keys(w2.trolls).length, 1, "troll persisté");
assert.strictEqual(w2.monsters.length, w.monsters.length, "monstres persistés");
assert.strictEqual(w2.config.trollDlaSec, 60, "config persistée");
require("fs").unlinkSync(file);
}
console.log("✅ Tous les tests multijoueur passent.");