diff --git a/README.md b/README.md
index 69441b0..6388828 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,17 @@ non affilié au jeu original de Mountyhall SARL.
## Versions
+- **2.11.0** (2026-06-15) — Bestiaire, étape 4 : **les monstres du bestiaire
+ apparaissent dans le jeu** (solo ET monde partagé), à la place des 7 anciens.
+ À chaque apparition, on choisit un monstre dont le niveau de base tombe dans la
+ **tranche autour de la profondeur** (± quelques niveaux), un **âge au hasard**
+ dans sa fourchette (multiplicateur d'âge appliqué), puis chaque stat est tirée
+ dans sa plage ; le nom prend le préfixe d'âge (« Mûr Ankheg ») et un emoji de
+ famille. La **profondeur** du monde partagé est désormais réglable **jusqu'à 45**
+ (pour atteindre les monstres de haut niveau). Le multi lit la base (tunée
+ admin), le solo les valeurs statiques. *(Les monstres placés à la main dans
+ l'éditeur de niveaux et le Béhémoth gardent l'ancien système ; les capacités
+ spéciales et drapeaux sont portés mais pas encore actifs.)*
- **2.10.2** (2026-06-15) — Bestiaire, étape 3 : **encyclopédie**. Nouvelle
section « 🐲 Bestiaire » dans « Trésors du Hall » (accessible par le menu) qui
liste les 150 monstres **groupés par famille**, avec leur **blason**, leurs
diff --git a/index.html b/index.html
index 26f1e68..af189a7 100644
--- a/index.html
+++ b/index.html
@@ -231,6 +231,7 @@
+
diff --git a/js/game.js b/js/game.js
index b48158d..16cec58 100644
--- a/js/game.js
+++ b/js/game.js
@@ -4,7 +4,7 @@
"use strict";
-const APP_VERSION = "2.10.2";
+const APP_VERSION = "2.11.0";
/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90;
@@ -210,7 +210,49 @@ function applyTemplate(type, tpl, x, y) {
};
}
+const FAMILY_EMOJI_G = { Insecte: "🐛", Animal: "🐾", "Démon": "👿", Humanoide: "🧝", Monstre: "👹", "Mort-Vivant": "💀" };
+
+/* Construit un monstre du BESTIAIRE pour une profondeur : on choisit un monstre
+ * dont le niveau de base tombe dans une tranche autour de la profondeur (± bande),
+ * un âge au hasard dans sa fourchette (le multiplicateur d'âge est appliqué), et
+ * on tire chaque stat dans sa plage. `list` = lignes du bestiaire, `ageMults` =
+ * [m0..m7], `ageNames` = noms d'âge par famille. Partagé solo (données statiques
+ * de bestiary.js) / multi (données de la base, tunées admin). */
+function buildBestiaryMonster(list, ageMults, ageNames, depth, x, y) {
+ if (!list || !list.length) return null;
+ const band = 3;
+ let pool = list.filter(m => m.levelMin <= depth + band && m.levelMax >= depth - band);
+ if (!pool.length) { // hors tranche : on prend les plus proches en niveau
+ let best = Infinity;
+ for (const m of list) best = Math.min(best, Math.abs((m.levelMin + m.levelMax) / 2 - depth));
+ pool = list.filter(m => Math.abs((m.levelMin + m.levelMax) / 2 - depth) <= best + 1);
+ }
+ const b = pool[Math.floor(Math.random() * pool.length)];
+ const age = b.minAge + Math.floor(Math.random() * (b.maxAge - b.minAge + 1));
+ const f = (ageMults[age] || 1) / (ageMults[b.minAge] || 1);
+ const roll = (mn, mx) => { const lo = Math.round(mn * f), hi = Math.round(mx * f); return lo + Math.floor(Math.random() * (Math.max(lo, hi) - lo + 1)); };
+ const att = Math.max(1, roll(b.attMin, b.attMax)), deg = Math.max(1, roll(b.degMin, b.degMax)), pv = Math.max(1, roll(b.pvMin, b.pvMax));
+ const names = (ageNames && ageNames[b.family]) || [];
+ return {
+ name: ((names[age] ? names[age] + " " : "") + b.name).trim(),
+ emoji: FAMILY_EMOJI_G[b.family] || "👹",
+ level: Math.max(1, roll(b.levelMin, b.levelMax)),
+ att, esq: Math.max(1, roll(b.esqMin, b.esqMax)), deg,
+ reg: Math.max(0, roll(b.regMin, b.regMax)),
+ attMag: b.magic ? att : 0, degMag: b.magic ? deg : 0,
+ pv, pvMax: pv,
+ armor: Math.max(0, roll(b.armPhysMin, b.armPhysMax)), armorMag: Math.max(0, roll(b.armMagMin, b.armMagMax)),
+ vue: Math.max(1, roll(b.vueMin, b.vueMax)),
+ static: false, boss: false, x, y,
+ family: b.family, fly: !!b.fly, ranged: !!b.ranged, seesHidden: !!b.seesHidden, speed: b.speed, nbAtt: b.nbAtt, capacities: b.capacities,
+ };
+}
+
function makeMonster(depth, x, y) {
+ // Bestiaire (données statiques chargées dans le navigateur en solo)
+ if (typeof BESTIARY !== "undefined" && BESTIARY.length)
+ return buildBestiaryMonster(BESTIARY, AGE_MULT, AGE_NAMES, depth, x, y);
+ // Repli (bestiaire non chargé) : ancien système des 7 types
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);
@@ -1702,7 +1744,7 @@ if (typeof document !== "undefined") {
if (typeof module !== "undefined" && module.exports) {
module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX,
- RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear, unequipToBag,
+ RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, buildBestiaryMonster, monsterFromSpec, itemFromSpec, equipGear, unequipToBag,
generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT,
diff --git a/mp.js b/mp.js
index ba3849f..9d0eca9 100644
--- a/mp.js
+++ b/mp.js
@@ -24,6 +24,7 @@ const g = require("./js/game.js");
const p = require("./js/potions.js");
const sc = require("./js/scrolls.js");
const gearLib = require("./js/gear.js");
+const bestiaryLib = require("./js/bestiary.js");
const db = require("./db.js");
/* ---------- Configuration par défaut (tout est réglable via l'admin) ---------- */
@@ -45,7 +46,7 @@ const DEFAULT_CONFIG = {
};
const CONFIG_BOUNDS = {
- mapW: [24, 80], mapH: [16, 60], worldDepth: [1, 5],
+ mapW: [24, 80], mapH: [16, 60], worldDepth: [1, 45],
monsterDlaMinSec: [5, 3600], monsterDlaMaxSec: [5, 7200],
trollDlaSec: [5, 3600], pollSec: [1, 60],
monsterTarget: [0, 120], repopSec: [10, 3600],
@@ -158,16 +159,12 @@ function monsterDlaMs(config) {
function spawnMonster(world, now = Date.now()) {
const pos = randomFloor(world);
if (!pos) return null;
- // même logique que makeMonster (pool par profondeur + gabarit d'âge),
- // mais sur les types tunés par l'admin
+ // Bestiaire : on tire un monstre dont le niveau tombe dans la tranche de la
+ // profondeur, avec un âge au hasard. Données de la base (tunées admin) +
+ // multiplicateurs d'âge.
const depth = world.config.worldDepth;
- const types = tunedMonsterTypes().filter(t => !t.boss);
- const pool = types.filter(t => t.level <= depth + 1 && t.level >= Math.max(1, depth - 2));
- const list = pool.length ? pool : types;
- const type = list[Math.floor(Math.random() * list.length)];
- const tplMax = Math.min(g.TEMPLATES.length - 1, depth - 1);
- const tpl = g.TEMPLATES[Math.floor(Math.random() * (tplMax + 1))];
- const m = g.applyTemplate(type, tpl, pos.x, pos.y);
+ const m = g.buildBestiaryMonster(db.bestiaryAll(), db.monsterAges().map(a => a.mult), bestiaryLib.AGE_NAMES, depth, pos.x, pos.y);
+ if (!m) return null;
m.id = world.nextId++;
m.dlaMs = Math.round(monsterDlaMs(world.config));
m.nextDla = now + m.dlaMs;
diff --git a/test/mp.js b/test/mp.js
index 7a0abe3..04f3843 100644
--- a/test/mp.js
+++ b/test/mp.js
@@ -238,29 +238,38 @@ function makeWorld(over = {}) {
assert(!st.includes(r.troll.passHash) && !st.includes(r.troll.salt), "pas de fuite du hash/sel");
}
-// Tuning admin : bestiaire appliqué aux nouveaux spawns
+// Tuning admin : ancienne table monsters (legacy) — validation/bornage/reset
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
- const tun1 = mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9, pv: 77, armorMag: 3 } } });
+ const tun1 = mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9 } } });
assert.strictEqual(tun1.monsters.Gobelin.att, 9, "écart ATT visible dans le tuning courant");
- // spawn forcé jusqu'à obtenir un Gobelin
- let gob = null;
- for (let i = 0; i < 300 && !gob; i++) {
- const m = mp.spawnMonster(w);
- if (m && m.name.includes("Gobelin")) gob = m;
- }
- assert(gob, "un Gobelin a fini par apparaître");
- // gabarit « Jeune/normal » à depth 1 : mult 0.7 ou 1.0 → att 6 ou 9, pv 54 ou 77
- assert([6, 9].includes(gob.att), "ATT tunée (×gabarit) : " + gob.att);
- assert([54, 77].includes(gob.pv), "PV tunés (×gabarit) : " + gob.pv);
- assert.strictEqual(gob.armorMag, 3, "armure magique tunée (gabarit ne la multiplie pas)");
- // bornage et noms inconnus ignorés
const tun2 = mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } });
assert(!tun2.monsters.Dragon, "type inconnu ignoré");
assert.strictEqual(tun2.monsters.Gobelin.att, 99, "ATT bornée à 99");
- // retour au vanilla : reset explicite de la catégorie
const tun3 = mp.adminSetTuning(w, { reset: ["monsters"] });
- assert.strictEqual(Object.keys(tun3.monsters).length, 0, "bestiaire d'origine restauré");
+ assert.strictEqual(Object.keys(tun3.monsters).length, 0, "monstres legacy d'origine restaurés");
+}
+
+// Bestiaire : les apparitions du monde partagé viennent du bestiaire
+{
+ const best = require("../js/bestiary.js");
+ const db = require("../db.js");
+ const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 12 });
+ const m = mp.spawnMonster(w);
+ assert(m && m.name && m.att >= 1 && m.deg >= 1 && m.pv >= 1 && typeof m.family === "string", "spawn d'un monstre du bestiaire : " + (m && m.name));
+ // tirage déterministe : 1 monstre, plages fixes, multiplicateurs neutres
+ const one = [{ name: "Cobaye", family: "Monstre", minAge: 0, maxAge: 0, levelMin: 10, levelMax: 10,
+ pvMin: 5000, pvMax: 5000, attMin: 7, attMax: 7, esqMin: 3, esqMax: 3, degMin: 4, degMax: 4, regMin: 1, regMax: 1,
+ armPhysMin: 2, armPhysMax: 2, armMagMin: 0, armMagMax: 0, vueMin: 4, vueMax: 4, mmMin: 0, mmMax: 0, rmMin: 0, rmMax: 0,
+ fly: 0, ranged: 0, magic: 1, seesHidden: 0, speed: "Normale", nbAtt: 1, capacities: "", blason: "" }];
+ const bm = g.buildBestiaryMonster(one, [1, 1, 1, 1, 1, 1, 1, 1], best.AGE_NAMES, 10, 0, 0);
+ assert.strictEqual(bm.pv, 5000, "PV tiré dans la plage du bestiaire");
+ assert.strictEqual(bm.att, 7, "ATT tirée dans la plage");
+ assert.strictEqual(bm.attMag, 7, "monstre magique : attMag = att");
+ // édition admin du bestiaire reflétée en base
+ const name = db.bestiaryAll()[0].name;
+ mp.adminSetTuning(w, { bestiary: { [name]: { pvMin: 4242, pvMax: 4242 } } });
+ assert.strictEqual(db.bestiaryAll().find(b => b.name === name).pvMin, 4242, "édition du bestiaire écrite en base");
}
// Tuning admin : mises à jour CIBLÉES — éditer un objet n'écrase pas les autres
@@ -327,17 +336,9 @@ function makeWorld(over = {}) {
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
const tunMag = mp.adminSetTuning(w, {
- monsters: { "Gobelin": { attMag: 6, degMag: 4 } },
gear: { "arme/Bâton de mage": { attMag: 4, armMag: 2 } },
});
- assert.strictEqual(tunMag.monsters.Gobelin.attMag, 6, "ATT mag du Gobelin tunée");
assert.strictEqual(tunMag.gear["arme/Bâton de mage"].armMag, 2, "Armure mag du bâton tunée");
- let gob = null;
- for (let i = 0; i < 300 && !gob; i++) {
- const m = mp.spawnMonster(w);
- if (m && m.name.includes("Gobelin")) gob = m;
- }
- assert(gob && gob.attMag > 0 && gob.degMag > 0, "le Gobelin spawné a une attaque magique");
const gearLib = require("../js/gear.js");
const baton = mp.applyGearTuning(gearLib.gearItemByName("arme", "Bâton de mage"));
assert.strictEqual(baton.mods.attMag, 4, "ATT mag du bâton droppé");