Version initiale de MountyCrawl 1.0.0

Roguelike navigateur hommage à MountyHall : 5 races, combat aux dés
(ATT/ESQ/DEG D6), DLA à 6 PA, progression PX/PI, brouillard de guerre
basé sur la Vue, 5 profondeurs et boss Béhémoth. Vanilla JS sans
dépendance, tests de fumée node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 11:11:46 +02:00
co-authored by Claude Fable 5
commit 293066df5c
5 changed files with 1205 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# 🧌 MountyCrawl — Le Monde Souterrain
Un roguelike navigateur en hommage à [MountyHall](https://www.mountyhall.com), la Terre des Trõlls.
Tu incarnes un Trõll qui descend dans le Monde Souterrain à la recherche de monstres,
de trésors et de PX — jusqu'au Béhémoth qui rôde à la profondeur 5.
100 % vanilla HTML/CSS/JS, aucune dépendance, aucun build. Ouvrir `index.html` et jouer.
## Lancer le jeu
```bash
# Directement
xdg-open index.html
# Ou via un petit serveur
python3 -m http.server 8080
# → http://localhost:8080
```
Paramètres d'URL pratiques : `index.html?autostart=1&race=Durakuir&name=Grosbill`
lance directement une partie (utilisé pour les captures d'écran et les tests).
## Mécaniques reprises de MountyHall
| Mécanique | Dans MountyHall | Dans MountyCrawl |
|---|---|---|
| Tour de jeu | DLA de 12 h, 6 PA | DLA instantanée quand les PA sont épuisés (ou bouton « Passer la DLA »), 6 PA |
| Caractéristiques | ATT, ESQ, DEG, REG, PV, Vue en dés | Identique : ATT/ESQ/DEG en D6, REG en D3, Vue = rayon de vision (brouillard de guerre) |
| Combat | Somme ATT D6 vs somme ESQ D6, dégâts DEG D6 armure | Identique, avec le détail des jets dans le journal |
| Magie | Seuil de Résistance basé sur MM vs RM, borné 1090 % | Identique (Siphon d'Âme du Darkling) |
| Progression | PX → PI à l'entraînement, coût (dés + 1) × coût racial | Identique, avec réduction sur la caractéristique favorite de la race |
| Niveaux | 10 × N PI pour atteindre le niveau N | Identique |
| Races | 5 races, chacune avec sort/compétence propre | Les 5 races avec leur capacité signature (voir ci-dessous) |
| Coûts en PA | Déplacement 13, attaque, ramasser, équiper, potion… | Déplacement 1, attaque 3, ramasser 1, équiper 2, potion 1 |
Simplifications assumées : les PX sont convertis en PI immédiatement (pas de phase
d'entraînement), la DLA ne dure pas 12 h réelles (sinon la partie durerait trois mois),
et le Monde Souterrain est généré procéduralement à chaque descente.
## Les 5 races
| Race | Favori (PI réduits) | Capacité signature |
|---|---|---|
| 🟢 Skrim | Attaque | Frappe Double (4 PA) : deux attaques dans le même assaut |
| 🔴 Kastar | Dégâts | Morsure Vampirique (4 PA) : soigne 50 % des dégâts infligés |
| 🟤 Durakuir | PV | Peau de Pierre (2 PA) : +3 d'armure jusqu'à la prochaine DLA |
| 🟡 Tomawak | Vue | Camouflage (3 PA) : invisible jusqu'à la prochaine DLA |
| 🟣 Darkling | Régénération | Siphon d'Âme (3 PA) : 2D6 dégâts magiques MM vs RM, ignore l'armure, soigne la moitié |
## Contrôles
- **ZQSD / flèches** : se déplacer (1 PA) — marcher sur un monstre l'attaque (3 PA)
- **Espace** : passer la DLA (les monstres agissent, tu régénères REG D3)
- **E** : ramasser l'objet sous tes pieds (1 PA)
- Boutons à l'écran pour la capacité raciale, l'équipement, les potions et l'entraînement (PI)
## Bestiaire
Gobelin, Champignon Vénéneux, Araignée Géante, Gargouille, Momie, Sorcière, Golem de
Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur,
et le **Béhémoth** comme boss final à la profondeur 5.
## Tests
```bash
node test/smoke.js
```
Vérifie les dés, la résolution de combat, le Seuil de Résistance, les coûts
d'amélioration, les niveaux, la connexité des cavernes générées et le bestiaire.
## Sources
Mécaniques reconstituées depuis le [site officiel](https://www.mountyhall.com),
les [règles](https://www.mountyhall.com/MH_Rules/Rules_4.php) et la
[Mountypedia](https://mountypedia.mountyhall.com). MountyCrawl est un hommage
non affilié au jeu original de Mountyhall SARL.
## Versions
- **1.0.0** (2026-06-11) — Version initiale : 5 races, 5 profondeurs, combat aux dés
fidèle aux règles MH, progression PI, brouillard de guerre, boss Béhémoth.
+185
View File
@@ -0,0 +1,185 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1a140e;
color: #e8d9b0;
font-family: Georgia, "Times New Roman", serif;
min-height: 100vh;
}
.screen { min-height: 100vh; }
.hidden { display: none !important; }
/* ---------- Écrans création / fin ---------- */
#screen-create, #screen-end {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.create-box {
max-width: 760px;
background: #2a2014;
border: 3px solid #6b5430;
border-radius: 10px;
padding: 30px;
text-align: center;
}
.create-box h1 { color: #8fbf5a; font-size: 2.2em; }
.subtitle { color: #b89c6a; font-style: italic; margin: 6px 0 14px; }
.lore { margin: 10px 0 20px; line-height: 1.5; color: #d8c79a; }
#troll-name {
width: 60%;
padding: 10px;
font-size: 1.1em;
background: #1a140e;
border: 2px solid #6b5430;
border-radius: 6px;
color: #e8d9b0;
text-align: center;
margin-bottom: 18px;
}
#race-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin-bottom: 20px;
text-align: left;
}
.race-card {
background: #1f1810;
border: 2px solid #4a3a20;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: border-color .15s, background .15s;
}
.race-card:hover { border-color: #8fbf5a; }
.race-card.selected { border-color: #8fbf5a; background: #28341c; }
.race-card h4 { color: #8fbf5a; margin-bottom: 4px; }
.race-card .race-desc { font-size: .85em; color: #c9b685; line-height: 1.35; }
.race-card .race-stats { font-size: .78em; color: #8a7a55; margin-top: 6px; font-family: monospace; }
.big-btn {
background: #4a6b2a;
color: #f0e6c0;
border: 2px solid #8fbf5a;
border-radius: 8px;
padding: 12px 26px;
font-size: 1.15em;
font-family: inherit;
cursor: pointer;
}
.big-btn:hover { background: #5d8535; }
/* ---------- Écran de jeu ---------- */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2a2014;
border-bottom: 2px solid #6b5430;
}
header h1 { font-size: 1.3em; color: #8fbf5a; }
#depth-label { font-size: 1.1em; color: #d8a040; font-weight: bold; }
#layout {
display: flex;
gap: 10px;
padding: 10px;
align-items: flex-start;
}
#panel-left, #panel-right {
width: 230px;
flex-shrink: 0;
background: #2a2014;
border: 2px solid #6b5430;
border-radius: 8px;
padding: 10px;
font-size: .9em;
}
#panel-left h2 { color: #8fbf5a; font-size: 1.05em; margin-bottom: 8px; }
#panel-left h3, #panel-right h3 {
color: #d8a040;
font-size: .95em;
margin: 12px 0 6px;
border-bottom: 1px solid #4a3a20;
}
#stats div { display: flex; justify-content: space-between; padding: 2px 0; }
#stats .stat-val { font-family: monospace; color: #f0e6c0; }
.hp-bar-wrap { background: #3a2a1a; border-radius: 4px; height: 14px; margin: 6px 0; overflow: hidden; }
.hp-bar { background: #b03030; height: 100%; transition: width .2s; }
.hp-bar.high { background: #5d8535; }
.hp-bar.mid { background: #d8a040; }
#improve button, #inventory button, #actions button {
display: block;
width: 100%;
margin: 4px 0;
padding: 6px 8px;
background: #1f1810;
border: 1px solid #6b5430;
border-radius: 5px;
color: #e8d9b0;
font-family: inherit;
font-size: .92em;
cursor: pointer;
text-align: left;
}
#improve button:hover:not(:disabled),
#inventory button:hover:not(:disabled),
#actions button:hover:not(:disabled) { border-color: #8fbf5a; background: #28341c; }
#improve button:disabled, #inventory button:disabled, #actions button:disabled {
opacity: .4; cursor: default;
}
#panel-center { flex-grow: 1; text-align: center; }
#map {
background: #0d0a06;
border: 2px solid #6b5430;
border-radius: 6px;
image-rendering: pixelated;
max-width: 100%;
}
#pa-bar { margin: 8px 0; font-size: 1.3em; letter-spacing: 4px; }
#actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
#actions button { width: auto; display: inline-block; }
.help p { font-size: .8em; color: #a89568; margin: 3px 0; }
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
#log {
margin: 0 10px 10px;
background: #14100a;
border: 2px solid #4a3a20;
border-radius: 8px;
padding: 8px 12px;
height: 140px;
overflow-y: auto;
font-size: .85em;
font-family: monospace;
line-height: 1.45;
}
#log .l-combat { color: #e0b070; }
#log .l-bad { color: #e06060; }
#log .l-good { color: #8fbf5a; }
#log .l-info { color: #9a8a65; }
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MountyCrawl — Le Monde Souterrain</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Écran de création du troll -->
<div id="screen-create" class="screen">
<div class="create-box">
<h1>🧌 MountyCrawl</h1>
<p class="subtitle">Le Monde Souterrain — hommage à MountyHall, la Terre des Trõlls</p>
<p class="lore">Au 24<sup>e</sup> cycle après Ragnarok, les Trõlls descendent dans le Monde Souterrain
en quête de monstres, de trésors et de gloire. Choisis ta race, jeune Trõllinet,
et pars affronter le Béhémoth qui rôde dans les profondeurs…</p>
<input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet">
<div id="race-list"></div>
<button id="btn-start" class="big-btn">⚔️ Descendre dans le Monde Souterrain</button>
</div>
</div>
<!-- Écran de jeu -->
<div id="screen-game" class="screen hidden">
<header>
<h1>🧌 MountyCrawl</h1>
<div id="depth-label">Profondeur 1</div>
</header>
<div id="layout">
<aside id="panel-left">
<h2 id="troll-title"></h2>
<div id="stats"></div>
<h3>S'améliorer (PI)</h3>
<div id="improve"></div>
</aside>
<main id="panel-center">
<canvas id="map" width="672" height="480"></canvas>
<div id="pa-bar"></div>
<div id="actions"></div>
</main>
<aside id="panel-right">
<h3>Équipement</h3>
<div id="equipment"></div>
<h3>Sac</h3>
<div id="inventory"></div>
<h3>Aide</h3>
<div class="help">
<p>ZQSD / flèches : se déplacer (1 PA)</p>
<p>Marcher sur un ennemi : attaquer (3 PA)</p>
<p>Espace : passer la DLA</p>
<p>La DLA passée, les monstres agissent et tu régénères.</p>
</div>
</aside>
</div>
<div id="log"></div>
</div>
<!-- Écran de fin -->
<div id="screen-end" class="screen hidden">
<div class="create-box">
<h1 id="end-title"></h1>
<p id="end-text" class="lore"></p>
<button id="btn-restart" class="big-btn">🍺 Retour à la Taverne (rejouer)</button>
</div>
</div>
<script src="js/game.js"></script>
</body>
</html>
+794
View File
@@ -0,0 +1,794 @@
/* MountyCrawl — roguelike hommage à MountyHall (https://www.mountyhall.com)
* Vanilla JS, aucune dépendance. Le cœur des règles (dés, combat, progression)
* est exporté pour les tests node (voir test/smoke.js). */
"use strict";
/* ================= Dés & règles de base ================= */
function rollDice(n, faces) {
const rolls = [];
for (let i = 0; i < n; i++) rolls.push(1 + Math.floor(Math.random() * faces));
return { total: rolls.reduce((a, b) => a + b, 0), rolls };
}
/* Jet d'attaque MountyHall : somme de ATT D6 contre somme de ESQ D6.
* Si l'attaque dépasse l'esquive, dégâts = DEG D6 (+ bonus d'arme) armure. */
function resolveAttack(attacker, defender, opts = {}) {
const att = rollDice(attacker.att, 6);
const esq = rollDice(defender.esq, 6);
const result = {
attRoll: att.total, esqRoll: esq.total,
attDice: attacker.att, esqDice: defender.esq,
hit: att.total > esq.total, damage: 0, rawDamage: 0,
};
if (result.hit) {
const deg = rollDice(attacker.deg, 6);
result.rawDamage = deg.total + (attacker.degBonus || 0);
const armor = opts.ignoreArmor ? 0 : (defender.armor || 0);
result.damage = Math.max(1, result.rawDamage - armor);
}
return result;
}
/* Sortilège façon MM vs RM : Seuil de Résistance borné à [10, 90] %. */
function resolveSpell(mm, rm) {
const sr = Math.min(90, Math.max(10, Math.round(50 + (mm - rm) * 5)));
const roll = 1 + Math.floor(Math.random() * 100);
return { sr, roll, success: roll <= sr };
}
/* Coût en PI du prochain dé d'une caractéristique : (dés actuels + 1) × coût de base,
* réduit pour la caractéristique favorite de la race (cf. règles MH). */
function improveCost(stat, currentValue, race) {
const base = { att: 18, esq: 18, deg: 18, reg: 15, pv: 10, vue: 15 };
const favored = RACES[race].favored === stat;
const mult = favored ? Math.ceil(base[stat] * 2 / 3) : base[stat];
if (stat === "pv") return mult; // +5 PV, coût fixe
return (currentValue + 1) * mult;
}
/* Niveau d'ancienneté : passer au niveau N coûte 10×N PI gagnés (cumulés). */
function levelFromTotalPI(totalPI) {
let level = 1, spent = 0;
while (spent + 10 * (level + 1) <= totalPI) { spent += 10 * (level + 1); level++; }
return level;
}
/* ================= Les 5 races ================= */
const RACES = {
Skrim: {
emoji: "🟢", favored: "att",
desc: "Rapides et précis, ils frappent deux fois là où d'autres hésitent.",
ability: "Frappe Double (4 PA) : deux attaques dans le même assaut.",
stats: { att: 4, esq: 4, deg: 1, reg: 2, pvMax: 35, vue: 3 },
},
Kastar: {
emoji: "🔴", favored: "deg",
desc: "Les vampires du Hall : leurs coups les nourrissent.",
ability: "Morsure Vampirique (4 PA) : attaque qui soigne 50 % des dégâts infligés.",
stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 40, vue: 2 },
},
Durakuir: {
emoji: "🟤", favored: "pv",
desc: "Les tanks du Hall, durs au mal et infatigables.",
ability: "Peau de Pierre (2 PA) : +3 d'armure jusqu'à la prochaine DLA.",
stats: { att: 3, esq: 2, deg: 2, reg: 3, pvMax: 55, vue: 2 },
},
Tomawak: {
emoji: "🟡", favored: "vue",
desc: "Trõlls furtifs, chasseurs embusqués aux yeux perçants.",
ability: "Camouflage (3 PA) : invisible aux monstres jusqu'à la prochaine DLA.",
stats: { att: 3, esq: 3, deg: 2, reg: 2, pvMax: 40, vue: 4 },
},
Darkling: {
emoji: "🟣", favored: "reg",
desc: "Mystiques des profondeurs, ils siphonnent l'âme de leurs proies.",
ability: "Siphon d'Âme (3 PA) : 2D6 dégâts magiques (MM vs RM, ignore l'armure), soigne la moitié.",
stats: { att: 2, esq: 3, deg: 2, reg: 3, pvMax: 38, vue: 3 },
},
};
/* ================= Bestiaire ================= */
const MONSTER_TYPES = [
{ name: "Gobelin", emoji: "👺", level: 1, att: 2, esq: 2, deg: 1, pv: 12, armor: 0, vue: 4 },
{ name: "Champignon Vénéneux", emoji: "🍄", level: 1, att: 3, esq: 1, deg: 2, pv: 10, armor: 0, vue: 1, static: true },
{ name: "Araignée Géante", emoji: "🕷️", level: 2, att: 3, esq: 3, deg: 2, pv: 16, armor: 0, vue: 5 },
{ name: "Gargouille", emoji: "🦇", level: 3, att: 3, esq: 3, deg: 2, pv: 22, armor: 2, vue: 4 },
{ name: "Momie", emoji: "🧟", level: 3, att: 4, esq: 2, deg: 3, pv: 26, armor: 1, vue: 3 },
{ name: "Sorcière", emoji: "🧙", level: 4, att: 4, esq: 3, deg: 3, pv: 24, armor: 0, vue: 6 },
{ name: "Golem de Pierre", emoji: "🗿", level: 5, att: 4, esq: 1, deg: 4, pv: 40, armor: 4, vue: 3 },
];
const BOSS = { name: "Béhémoth", emoji: "👹", level: 9, att: 6, esq: 4, deg: 5, pv: 90, armor: 3, vue: 8, boss: true };
/* Gabarits d'âge façon MountyHall : plus on descend, plus les bêtes sont vieilles. */
const TEMPLATES = [
{ prefix: "Jeune ", mult: 0.7 },
{ prefix: "", mult: 1.0 },
{ prefix: "Vieux ", mult: 1.3 },
{ prefix: "Ancien ", mult: 1.6 },
{ prefix: "Mythique ", mult: 2.0 },
];
function makeMonster(depth, x, y) {
const pool = MONSTER_TYPES.filter(m => m.level <= depth + 1 && m.level >= Math.max(1, depth - 2));
const type = pool[Math.floor(Math.random() * pool.length)];
const tplMax = Math.min(TEMPLATES.length - 1, depth - 1);
const tpl = TEMPLATES[Math.floor(Math.random() * (tplMax + 1))];
return {
name: tpl.prefix + type.name, emoji: type.emoji,
level: Math.max(1, Math.round(type.level * tpl.mult)),
att: Math.max(1, Math.round(type.att * tpl.mult)),
esq: Math.max(1, Math.round(type.esq * tpl.mult)),
deg: Math.max(1, Math.round(type.deg * tpl.mult)),
pv: Math.round(type.pv * tpl.mult), pvMax: Math.round(type.pv * tpl.mult),
armor: type.armor, vue: type.vue, static: !!type.static,
x, y, boss: false,
};
}
/* ================= Génération du Monde Souterrain ================= */
const MAP_W = 28, MAP_H = 20;
const T_WALL = 0, T_FLOOR = 1, T_STAIRS = 2;
function generateCavern(w, h) {
let grid = [];
for (let y = 0; y < h; y++) {
grid.push([]);
for (let x = 0; x < w; x++) {
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
grid[y].push(border || Math.random() < 0.42 ? T_WALL : T_FLOOR);
}
}
// Automate cellulaire : 4 passes de lissage
for (let pass = 0; pass < 4; pass++) {
const next = grid.map(row => row.slice());
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let walls = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
if (grid[y + dy][x + dx] === T_WALL) walls++;
next[y][x] = walls >= 5 ? T_WALL : T_FLOOR;
}
}
grid = next;
}
// Ne garder que la plus grande zone connexe
const region = largestRegion(grid, w, h);
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
if (grid[y][x] === T_FLOOR && !region.has(y * w + x)) grid[y][x] = T_WALL;
return grid;
}
function largestRegion(grid, w, h) {
const seen = new Set();
let best = new Set();
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const key = y * w + x;
if (grid[y][x] !== T_FLOOR || seen.has(key)) continue;
const region = new Set();
const stack = [[x, y]];
while (stack.length) {
const [cx, cy] = stack.pop();
const ck = cy * w + cx;
if (seen.has(ck) || grid[cy][cx] !== T_FLOOR) continue;
seen.add(ck); region.add(ck);
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
}
if (region.size > best.size) best = region;
}
}
return best;
}
function randomFloor(grid, taken) {
for (let tries = 0; tries < 500; tries++) {
const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (grid[y][x] === T_FLOOR && !taken.has(y * MAP_W + x)) {
taken.add(y * MAP_W + x);
return { x, y };
}
}
return null;
}
/* ================= Objets ================= */
const WEAPONS = [
{ name: "Dague Rouillée", emoji: "🗡️", slot: "weapon", bonus: 1 },
{ name: "Hache de Jet", emoji: "🪓", slot: "weapon", bonus: 2 },
{ name: "Épée Longue", emoji: "⚔️", slot: "weapon", bonus: 3 },
{ name: "Masse d'Armes", emoji: "🔨", slot: "weapon", bonus: 4 },
];
const ARMORS = [
{ name: "Armure de Cuir", emoji: "🦺", slot: "armor", bonus: 1 },
{ name: "Cotte de Mailles", emoji: "🥋", slot: "armor", bonus: 2 },
{ name: "Armure de Plates", emoji: "🛡️", slot: "armor", bonus: 3 },
];
function makeItem(depth) {
const r = Math.random();
if (r < 0.35) return { kind: "potion", name: "Potion de Vie", emoji: "🧪" };
if (r < 0.55) {
const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))];
return { kind: "gear", ...w };
}
if (r < 0.7) {
const a = ARMORS[Math.min(ARMORS.length - 1, Math.floor(Math.random() * depth))];
return { kind: "gear", ...a };
}
const gold = rollDice(depth, 6).total * 10;
return { kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold };
}
/* ================= État du jeu ================= */
const MAX_DEPTH = 5;
const PA_PER_TURN = 6;
const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1 };
let G = null; // état global de la partie
function newGame(name, race) {
const s = RACES[race].stats;
G = {
troll: {
name, race,
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, mm: race === "Darkling" ? 4 : 2,
weapon: null, armorItem: null,
pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
bag: [], camo: false, stoneSkin: 0, kills: 0, dla: 1,
},
depth: 1, grid: null, monsters: [], items: [], stairs: null,
seen: new Set(), over: false,
};
buildLevel();
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
}
function buildLevel() {
const grid = generateCavern(MAP_W, MAP_H);
const taken = new Set();
const start = randomFloor(grid, taken);
G.grid = grid;
G.troll.x = start.x; G.troll.y = start.y;
G.seen = new Set();
G.monsters = [];
if (G.depth === MAX_DEPTH) {
const bp = randomFloor(grid, taken);
G.monsters.push({ ...BOSS, pvMax: BOSS.pv, x: bp.x, y: bp.y, static: false });
}
const count = 4 + G.depth * 2 - (G.depth === MAX_DEPTH ? 3 : 0);
for (let i = 0; i < count; i++) {
const p = randomFloor(grid, taken);
if (p) G.monsters.push(makeMonster(G.depth, p.x, p.y));
}
G.items = [];
for (let i = 0; i < 3 + G.depth; i++) {
const p = randomFloor(grid, taken);
if (p) G.items.push({ ...makeItem(G.depth), x: p.x, y: p.y });
}
if (G.depth < MAX_DEPTH) {
const p = randomFloor(grid, taken);
grid[p.y][p.x] = T_STAIRS;
G.stairs = p;
} else {
G.stairs = null;
}
updateFov();
}
/* ================= Champ de vision (la Vue du troll) ================= */
function updateFov() {
const { x, y } = G.troll;
const r = G.troll.vue;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const tx = x + dx, ty = y + dy;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue;
if (dx * dx + dy * dy <= r * r) G.seen.add(ty * MAP_W + tx);
}
}
}
function inSight(e) {
const dx = e.x - G.troll.x, dy = e.y - G.troll.y;
return dx * dx + dy * dy <= G.troll.vue * G.troll.vue;
}
/* ================= Actions du troll ================= */
function spendPA(cost) {
if (G.troll.pa < cost) { log(`Pas assez de PA (${cost} requis).`, "info"); return false; }
G.troll.pa -= cost;
return true;
}
function tryMove(dx, dy) {
if (G.over) return;
const t = G.troll;
const nx = t.x + dx, ny = t.y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
if (G.grid[ny][nx] === T_WALL) return;
const target = G.monsters.find(m => m.x === nx && m.y === ny);
if (target) { attackMonster(target); return; }
if (!spendPA(COSTS.move)) return;
t.x = nx; t.y = ny;
updateFov();
if (G.grid[ny][nx] === T_STAIRS) {
log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "info");
}
const item = G.items.find(i => i.x === nx && i.y === ny);
if (item) log(`Tu vois : ${item.emoji} ${item.name}. Ramasse-le pour ${COSTS.pickup} PA.`, "info");
afterAction();
}
function attackMonster(m, opts = {}) {
const cost = opts.cost ?? COSTS.attack;
if (!spendPA(cost)) return false;
const swings = opts.swings || 1;
for (let i = 0; i < swings && m.pv > 0; i++) {
const r = resolveAttack(G.troll, m);
if (r.hit) {
m.pv -= r.damage;
G.troll.pi += 1; G.troll.totalPI += 1; // +1 PX par attaque réussie (converti en PI)
log(`Tu attaques ${m.name} : ATT ${r.attDice}D6=${r.attRoll} vs ESQ ${r.esqDice}D6=${r.esqRoll} → touché ! ${r.rawDamage} dégâts (${m.armor} armure) = ${r.damage} PV.`, "combat");
if (opts.vampiric) {
const heal = Math.ceil(r.damage / 2);
G.troll.pv = Math.min(G.troll.pvMax, G.troll.pv + heal);
log(`🩸 Morsure Vampirique : tu récupères ${heal} PV.`, "good");
}
if (m.pv <= 0) killMonster(m);
} else {
log(`Tu attaques ${m.name} : ATT ${r.attDice}D6=${r.attRoll} vs ESQ ${r.esqDice}D6=${r.esqRoll} → esquivé !`, "combat");
}
}
G.troll.camo = false;
afterAction();
return true;
}
function killMonster(m) {
const px = m.level * 2;
G.troll.pi += px; G.troll.totalPI += px;
G.troll.kills++;
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
G.monsters = G.monsters.filter(x => x !== m);
if (m.boss) win();
}
function useAbility() {
if (G.over) return;
const t = G.troll;
const race = t.race;
if (race === "Skrim") {
const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent pour la Frappe Double.", "info"); return; }
log("⚡ Frappe Double !", "good");
attackMonster(m, { cost: 4, swings: 2 });
} else if (race === "Kastar") {
const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent pour la Morsure Vampirique.", "info"); return; }
attackMonster(m, { cost: 4, vampiric: true });
} else if (race === "Durakuir") {
if (!spendPA(2)) return;
t.stoneSkin = 3;
log("🪨 Peau de Pierre : +3 d'armure jusqu'à la prochaine DLA.", "good");
afterAction();
} else if (race === "Tomawak") {
if (!spendPA(3)) return;
t.camo = true;
log("🌫️ Camouflage : les monstres ne te voient plus jusqu'à la prochaine DLA.", "good");
afterAction();
} else if (race === "Darkling") {
const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent pour le Siphon d'Âme.", "info"); return; }
if (!spendPA(3)) return;
const spell = resolveSpell(t.mm, Math.ceil(m.level / 2));
if (spell.success) {
const dmg = rollDice(2, 6).total;
m.pv -= dmg;
const heal = Math.ceil(dmg / 2);
t.pv = Math.min(t.pvMax, t.pv + heal);
G.troll.pi += 1; G.troll.totalPI += 1;
log(`🔮 Siphon d'Âme (SR ${spell.sr}%, jet ${spell.roll}) : ${dmg} dégâts, tu récupères ${heal} PV.`, "good");
if (m.pv <= 0) killMonster(m);
} else {
log(`🔮 Siphon d'Âme raté (SR ${spell.sr}%, jet ${spell.roll}).`, "combat");
}
afterAction();
}
}
function adjacentMonster() {
return G.monsters.find(m => Math.abs(m.x - G.troll.x) <= 1 && Math.abs(m.y - G.troll.y) <= 1);
}
function pickup() {
const i = G.items.find(it => it.x === G.troll.x && it.y === G.troll.y);
if (!i) return;
if (!spendPA(COSTS.pickup)) return;
G.items = G.items.filter(x => x !== i);
if (i.kind === "gold") {
G.troll.gold += i.gold;
log(`💰 Tu ramasses ${i.gold} Mountyzédons.`, "good");
} else {
G.troll.bag.push(i);
log(`Tu ramasses ${i.emoji} ${i.name}.`, "good");
}
afterAction();
}
function useBagItem(idx) {
const t = G.troll;
const item = t.bag[idx];
if (!item) return;
if (item.kind === "potion") {
if (!spendPA(COSTS.potion)) return;
const heal = rollDice(2, 6).total + 3;
t.pv = Math.min(t.pvMax, t.pv + heal);
t.bag.splice(idx, 1);
log(`🧪 Glou glou : +${heal} PV.`, "good");
} else if (item.kind === "gear") {
if (!spendPA(COSTS.equip)) return;
if (item.slot === "weapon") {
if (t.weapon) t.bag.push(t.weapon);
t.weapon = item; t.degBonus = item.bonus;
} else {
if (t.armorItem) t.bag.push(t.armorItem);
t.armorItem = item; t.armor = item.bonus;
}
t.bag.splice(t.bag.indexOf(item), 1);
log(`Tu t'équipes : ${item.emoji} ${item.name} (+${item.bonus}).`, "good");
}
afterAction();
}
function descend() {
if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return;
G.depth++;
log(`⬇️ Tu descends. Profondeur ${G.depth}. L'air devient lourd…`, "info");
if (G.depth === MAX_DEPTH) log("👹 Le sol tremble. Le Béhémoth est proche.", "bad");
buildLevel();
afterAction();
}
function improveStat(stat) {
const t = G.troll;
const current = stat === "pv" ? t.pvMax : t[stat];
const cost = improveCost(stat, stat === "pv" ? 0 : current, t.race);
if (t.pi < cost) return;
t.pi -= cost;
if (stat === "pv") { t.pvMax += 5; t.pv += 5; }
else t[stat] += 1;
const label = { att: "Attaque", esq: "Esquive", deg: "Dégâts", reg: "Régénération", pv: "PV max", vue: "Vue" }[stat];
log(`📈 Entraînement : ${label} améliorée pour ${cost} PI.`, "good");
if (stat === "vue") updateFov();
afterAction();
}
/* ================= La DLA : tour des monstres + régénération ================= */
function passDLA() {
if (G.over) return;
const t = G.troll;
t.dla++;
// Tour des monstres
for (const m of G.monsters) {
if (m.pv <= 0) continue;
const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y));
const seesTroll = !t.camo && dist <= m.vue;
if (dist <= 1 && !t.camo) {
const armorBonus = t.stoneSkin > 0 ? 3 : 0;
const r = resolveAttack(m, { ...t, armor: t.armor + armorBonus });
if (r.hit) {
t.pv -= r.damage;
log(`${m.emoji} ${m.name} t'attaque : ${r.attDice}D6=${r.attRoll} vs ${r.esqDice}D6=${r.esqRoll}${r.damage} dégâts !`, "bad");
if (t.pv <= 0) { die(m); return; }
} else {
log(`${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat");
}
} else if (seesTroll && !m.static) {
stepToward(m, t);
} else if (!m.static && Math.random() < 0.4) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const [dx, dy] = dirs[Math.floor(Math.random() * 4)];
moveMonster(m, m.x + dx, m.y + dy);
}
}
// Régénération (REG D3, comme à MountyHall)
if (t.pv < t.pvMax && t.pv > 0) {
const r = rollDice(t.reg, 3);
t.pv = Math.min(t.pvMax, t.pv + r.total);
log(`💤 Nouvelle DLA n°${t.dla} : tu régénères ${r.total} PV (${t.reg}D3).`, "info");
} else {
log(`💤 Nouvelle DLA n°${t.dla}.`, "info");
}
t.pa = PA_PER_TURN;
t.camo = false;
if (t.stoneSkin > 0) t.stoneSkin--;
afterAction();
}
function stepToward(m, t) {
const dx = Math.sign(t.x - m.x), dy = Math.sign(t.y - m.y);
if (dx !== 0 && moveMonster(m, m.x + dx, m.y)) return;
if (dy !== 0 && moveMonster(m, m.x, m.y + dy)) return;
if (dx !== 0 && dy !== 0) moveMonster(m, m.x + dx, m.y + dy);
}
function moveMonster(m, nx, ny) {
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return false;
if (G.grid[ny][nx] === T_WALL) return false;
if (nx === G.troll.x && ny === G.troll.y) return false;
if (G.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false;
m.x = nx; m.y = ny;
return true;
}
function die(killer) {
G.over = true;
log(`☠️ ${killer.name} t'a terrassé…`, "bad");
showEnd(false, killer);
}
function win() {
G.over = true;
log("🏆 Le Béhémoth s'effondre ! Le Trésor de MountyHall est à toi !", "good");
showEnd(true);
}
/* ================= Interface ================= */
const $ = id => document.getElementById(id);
const TILE = 24;
function log(msg, cls = "info") {
if (typeof document === "undefined") return;
const el = $("log");
if (!el) return;
const line = document.createElement("div");
line.className = "l-" + cls;
line.textContent = msg;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
while (el.children.length > 200) el.removeChild(el.firstChild);
}
function afterAction() {
if (typeof document === "undefined") return;
render();
renderPanels();
if (!G.over && G.troll.pa === 0) {
log("Plus de PA — la DLA expire…", "info");
setTimeout(passDLA, 350);
}
}
function render() {
const canvas = $("map");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "18px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const key = y * MAP_W + x;
const px = x * TILE, py = y * TILE;
if (!G.seen.has(key)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, TILE, TILE); continue; }
const visible = (x - G.troll.x) ** 2 + (y - G.troll.y) ** 2 <= G.troll.vue ** 2;
const t = G.grid[y][x];
if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315";
else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522";
ctx.fillRect(px, py, TILE - 1, TILE - 1);
if (t === T_STAIRS) {
ctx.fillStyle = "#111";
ctx.fillText("▼", px + TILE / 2, py + TILE / 2 + 1);
}
}
}
for (const i of G.items) {
if (!G.seen.has(i.y * MAP_W + i.x)) continue;
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
}
for (const m of G.monsters) {
if (!inSight(m)) continue;
ctx.fillText(m.emoji, m.x * TILE + TILE / 2, m.y * TILE + TILE / 2 + 1);
// barre de vie du monstre
const w = Math.max(2, Math.round((TILE - 6) * m.pv / m.pvMax));
ctx.fillStyle = "#b03030";
ctx.fillRect(m.x * TILE + 3, m.y * TILE + 1, w, 3);
}
ctx.fillText("🧌", G.troll.x * TILE + TILE / 2, G.troll.y * TILE + TILE / 2 + 1);
if (G.troll.camo) {
ctx.strokeStyle = "#8fbf5a";
ctx.strokeRect(G.troll.x * TILE + 1, G.troll.y * TILE + 1, TILE - 2, TILE - 2);
}
}
function renderPanels() {
const t = G.troll;
$("depth-label").textContent = `Profondeur ${G.depth} · DLA n°${t.dla}`;
$("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`;
const pct = Math.max(0, t.pv / t.pvMax);
const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : "";
$("stats").innerHTML = `
<div><span>PV</span><span class="stat-val">${t.pv} / ${t.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">${t.att}D6</span></div>
<div><span>Esquive</span><span class="stat-val">${t.esq}D6</span></div>
<div><span>Dégâts</span><span class="stat-val">${t.deg}D6${t.degBonus ? "+" + t.degBonus : ""}</span></div>
<div><span>Régénération</span><span class="stat-val">${t.reg}D3</span></div>
<div><span>Armure</span><span class="stat-val">${t.armor + (t.stoneSkin > 0 ? 3 : 0)}</span></div>
<div><span>Vue</span><span class="stat-val">${t.vue}</span></div>
<div><span>PI</span><span class="stat-val">${t.pi}</span></div>
<div><span>Mountyzédons</span><span class="stat-val">${t.gold}</span></div>
<div><span>Monstres tués</span><span class="stat-val">${t.kills}</span></div>`;
const improve = $("improve");
improve.innerHTML = "";
const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D6", reg: "Régén. +1D3", pv: "PV max +5", vue: "Vue +1" };
for (const stat of Object.keys(labels)) {
const current = stat === "pv" ? 0 : t[stat];
const cost = improveCost(stat, current, t.race);
const btn = document.createElement("button");
const fav = RACES[t.race].favored === stat ? " ★" : "";
btn.textContent = `${labels[stat]}${cost} PI${fav}`;
btn.disabled = t.pi < cost || G.over;
btn.onclick = () => improveStat(stat);
improve.appendChild(btn);
}
$("pa-bar").textContent = "⚡".repeat(t.pa) + "▫️".repeat(PA_PER_TURN - t.pa);
const actions = $("actions");
actions.innerHTML = "";
const addBtn = (label, fn, enabled) => {
const b = document.createElement("button");
b.textContent = label;
b.disabled = !enabled || G.over;
b.onclick = fn;
actions.appendChild(b);
};
const adj = adjacentMonster();
addBtn(`⚔️ Attaquer (${COSTS.attack} PA)`, () => adj && attackMonster(adj), adj && t.pa >= COSTS.attack);
const abilityCost = { Skrim: 4, Kastar: 4, Durakuir: 2, Tomawak: 3, Darkling: 3 }[t.race];
addBtn(`${RACES[t.race].ability.split(" (")[0]} (${abilityCost} PA)`, useAbility, t.pa >= abilityCost);
const onItem = G.items.some(i => i.x === t.x && i.y === t.y);
addBtn(`🖐️ Ramasser (${COSTS.pickup} PA)`, pickup, onItem && t.pa >= COSTS.pickup);
const onStairs = G.grid[t.y][t.x] === T_STAIRS;
addBtn("⬇️ Descendre", descend, onStairs);
addBtn("⏳ Passer la DLA", passDLA, true);
$("equipment").innerHTML =
`<div>Arme : ${t.weapon ? t.weapon.emoji + " " + t.weapon.name + " (+" + t.weapon.bonus + ")" : "—"}</div>` +
`<div>Armure : ${t.armorItem ? t.armorItem.emoji + " " + t.armorItem.name + " (+" + t.armorItem.bonus + ")" : "—"}</div>`;
const inv = $("inventory");
inv.innerHTML = "";
if (t.bag.length === 0) inv.innerHTML = '<div class="empty">— vide —</div>';
t.bag.forEach((item, idx) => {
const b = document.createElement("button");
const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` : `équiper (${COSTS.equip} PA)`;
b.textContent = `${item.emoji} ${item.name}${action}`;
b.disabled = G.over;
b.onclick = () => useBagItem(idx);
inv.appendChild(b);
});
}
function showEnd(victory, killer) {
const t = G.troll;
$("screen-game").classList.add("hidden");
$("screen-end").classList.remove("hidden");
$("end-title").textContent = victory ? "🏆 GLOIRE AU TRÕLL !" : "☠️ MORT DANS LES PROFONDEURS";
const score = t.gold + t.totalPI * 10 + t.kills * 25 + (victory ? 1000 : 0);
$("end-text").innerHTML = victory
? `${t.name} le ${t.race} a terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !<br><br>
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`
: `${t.name} le ${t.race} a été terrassé par ${killer ? killer.name : "les profondeurs"} à la profondeur ${G.depth}.<br>
À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.<br><br>
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`;
}
/* ================= Création du personnage ================= */
let selectedRace = "Skrim";
function initCreateScreen() {
const list = $("race-list");
list.innerHTML = "";
for (const [name, r] of Object.entries(RACES)) {
const card = document.createElement("div");
card.className = "race-card" + (name === selectedRace ? " selected" : "");
const s = r.stats;
card.innerHTML = `<h4>${r.emoji} ${name}</h4>
<div class="race-desc">${r.desc}<br><b>${r.ability}</b></div>
<div class="race-stats">ATT ${s.att}D6 · ESQ ${s.esq}D6 · DEG ${s.deg}D6 · REG ${s.reg}D3 · PV ${s.pvMax} · VUE ${s.vue}</div>`;
card.onclick = () => {
selectedRace = name;
initCreateScreen();
};
list.appendChild(card);
}
}
function startGame() {
const name = $("troll-name").value.trim() || "Trõllinet";
$("screen-create").classList.add("hidden");
$("screen-end").classList.add("hidden");
$("screen-game").classList.remove("hidden");
$("log").innerHTML = "";
newGame(name, selectedRace);
render();
renderPanels();
}
function bindKeys() {
document.addEventListener("keydown", e => {
if (!G || G.over || $("screen-game").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(); tryMove(...moves[k]); }
else if (k === " ") { e.preventDefault(); passDLA(); }
else if (k === "e") pickup();
});
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
initCreateScreen();
bindKeys();
$("btn-start").onclick = startGame;
// ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie
const params = new URLSearchParams(location.search);
if (params.get("autostart")) {
if (RACES[params.get("race")]) selectedRace = params.get("race");
if (params.get("name")) $("troll-name").value = params.get("name");
startGame();
}
$("btn-restart").onclick = () => {
$("screen-end").classList.add("hidden");
$("screen-create").classList.remove("hidden");
initCreateScreen();
};
});
}
/* Export pour les tests node */
if (typeof module !== "undefined" && module.exports) {
module.exports = {
rollDice, resolveAttack, resolveSpell, improveCost, levelFromTotalPI,
RACES, MONSTER_TYPES, BOSS, makeMonster, generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
};
}
+73
View File
@@ -0,0 +1,73 @@
/* Tests de fumée du cœur des règles — exécuter avec : node test/smoke.js */
"use strict";
const assert = require("assert");
const g = require("../js/game.js");
// Dés
for (let i = 0; i < 200; i++) {
const r = g.rollDice(3, 6);
assert(r.total >= 3 && r.total <= 18, "3D6 hors bornes : " + r.total);
assert.strictEqual(r.rolls.length, 3);
}
// Combat : un attaquant écrasant doit toucher la plupart du temps, et jamais l'inverse d'un mur
let hits = 0;
for (let i = 0; i < 500; i++) {
const r = g.resolveAttack({ att: 10, deg: 3, degBonus: 2 }, { esq: 1, armor: 2 });
if (r.hit) {
hits++;
assert(r.damage >= 1, "dégâts minimum 1");
assert(r.damage <= 3 * 6 + 2 - 2, "dégâts trop élevés : " + r.damage);
}
}
assert(hits > 450, "10D6 vs 1D6 devrait presque toujours toucher (" + hits + "/500)");
// Sortilège : SR borné [10, 90]
assert.strictEqual(g.resolveSpell(100, 0).sr, 90);
assert.strictEqual(g.resolveSpell(0, 100).sr, 10);
// Coûts d'amélioration : la race favorisée paie moins cher
const skrimAtt = g.improveCost("att", 3, "Skrim");
const kastarAtt = g.improveCost("att", 3, "Kastar");
assert(skrimAtt < kastarAtt, `Skrim devrait payer son ATT moins cher (${skrimAtt} vs ${kastarAtt})`);
// Niveaux : 0 PI → niv 1 ; 20 PI → niv 2 ; 50 PI → niv 3 (20 + 30)
assert.strictEqual(g.levelFromTotalPI(0), 1);
assert.strictEqual(g.levelFromTotalPI(20), 2);
assert.strictEqual(g.levelFromTotalPI(49), 2);
assert.strictEqual(g.levelFromTotalPI(50), 3);
// Les 5 races existent avec toutes leurs stats
assert.strictEqual(Object.keys(g.RACES).length, 5);
for (const r of Object.values(g.RACES)) {
for (const k of ["att", "esq", "deg", "reg", "pvMax", "vue"]) {
assert(typeof r.stats[k] === "number", "stat manquante : " + k);
}
}
// Génération de caverne : connexe, bordures murées, assez de sol
for (let i = 0; i < 20; i++) {
const grid = g.generateCavern(g.MAP_W, g.MAP_H);
let floor = 0;
for (let y = 0; y < g.MAP_H; y++) {
for (let x = 0; x < g.MAP_W; x++) {
if (grid[y][x] === g.T_FLOOR) floor++;
const border = x === 0 || y === 0 || x === g.MAP_W - 1 || y === g.MAP_H - 1;
if (border) assert.strictEqual(grid[y][x], g.T_WALL, "bordure non murée");
}
}
assert(floor > 50, "caverne trop petite : " + floor + " cases de sol");
const region = g.largestRegion(grid, g.MAP_W, g.MAP_H);
assert.strictEqual(region.size, floor, "caverne non connexe");
}
// Monstres : niveaux et stats positifs à toutes les profondeurs
for (let depth = 1; depth <= 5; depth++) {
for (let i = 0; i < 50; i++) {
const m = g.makeMonster(depth, 1, 1);
assert(m.level >= 1 && m.pv >= 1 && m.att >= 1 && m.deg >= 1, "monstre invalide : " + JSON.stringify(m));
}
}
console.log("✅ Tous les tests de fumée passent.");