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é");