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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
@@ -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.");
|
||||||
Reference in New Issue
Block a user