v2.7.0 : templates de drop (table BDD, proba admin, application au drop)

- db.js : table templates + seed des 19 templates du CSV (mappes vers les
  mods + nouveau champ tour), templatesAll/setTemplate/resetCategory.
- server.js : /api/reference/templates.
- mp.js : config templateChance (0-100, bornee) ; applyGearTemplates applique
  jusqu'a 3 templates (6 si 2 mains), distincts, proba par emplacement,
  fusionne les mods (dont tour) et compose le nom ; applique au spawnItem.
  Solo vanilla.
- gear.js : GEAR_MOD_LABELS += tour (affichage 'TOUR +X mn').
- admin.html : champ Proba de template / emplacement.
- test/mp.js : test des templates (nb selon mains, distincts, proba 0).

Note : effet gameplay de tour (DLA) non cable, en attente de la regle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-14 00:17:53 +02:00
co-authored by Claude Opus 4.8
parent b55a6f8ac9
commit 00a4669928
8 changed files with 116 additions and 8 deletions
+11
View File
@@ -234,6 +234,17 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## Versions
- **2.7.0** (2026-06-14) — **Templates de drop** façon MountyHall. 19 suffixes
(« de l'Aigle », « des Mages », « des Béhémoths »…) stockés dans une nouvelle
table `templates` de la base (seedés au démarrage, éditables comme le reste via
sqlite-web / `GET /api/reference/templates`). À chaque **drop d'équipement du
monde partagé**, l'objet reçoit jusqu'à **3 templates** (6 pour une arme à
**2 mains**), chaque emplacement étant rempli avec une **probabilité réglable
sur la page admin** (`templateChance`, 0100 %). Les bonus des templates
s'ajoutent aux mods de l'objet (nouveau mod `TOUR`, en minutes) et leurs
suffixes au nom (« Épée Courte de l'Aigle des Mages »). Le solo reste vanilla.
*Note : le bonus `TOUR` est affiché mais son effet de jeu (DLA) reste à câbler
une fois la règle confirmée.*
- **2.6.3** (2026-06-13) — **Cache-busting** des fichiers du jeu : les balises - **2.6.3** (2026-06-13) — **Cache-busting** des fichiers du jeu : les balises
`<script>`/`<link>` de `index.html` portent `?v=__V__`, jeton que le serveur `<script>`/`<link>` de `index.html` portent `?v=__V__`, jeton que le serveur
remplace par la version de l'appli (donc différent à chaque déploiement). Le remplace par la version de l'appli (donc différent à chaque déploiement). Le
+1
View File
@@ -116,6 +116,7 @@ const FIELDS = [
["deathRespawnSec", "Réapparition des trolls (s)", "délai après une mort"], ["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"], ["worldDepth", "Profondeur du monde (1-5)", "puissance des monstres et des trésors"],
["maxTrolls", "Trolls max", "limite de personnages dans le monde"], ["maxTrolls", "Trolls max", "limite de personnages dans le monde"],
["templateChance", "Templates — proba / emplacement (%)", "chance qu'un emplacement de template soit rempli sur un drop (3 max en 1 main, 6 en 2 mains)"],
["mapW", "Largeur de carte", "appliqué à la prochaine régénération du 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"], ["mapH", "Hauteur de carte", "appliqué à la prochaine régénération du monde"],
]; ];
+55 -4
View File
@@ -23,6 +23,33 @@ const gearLib = require("./js/gear.js");
const MONSTER_KEYS = ["level", "att", "attMag", "esq", "deg", "degMag", "pv", "armor", "armorMag", "vue"]; const MONSTER_KEYS = ["level", "att", "attMag", "esq", "deg", "degMag", "pv", "armor", "armorMag", "vue"];
const GEAR_KEYS = ["att", "attMag", "esq", "deg", "degMag", "reg", "arm", "armMag", "vue", "pv", "rmPct", "mmPct"]; const GEAR_KEYS = ["att", "attMag", "esq", "deg", "degMag", "reg", "arm", "armMag", "vue", "pv", "rmPct", "mmPct"];
/* Templates de drop façon MountyHall : des suffixes (« de l'Aigle », « des
* Mages »…) ajoutés aléatoirement à l'équipement qui tombe, selon une proba
* réglable côté admin. Leurs bonus s'ajoutent aux mods de l'objet ; `tour` est
* un modificateur de TOUR (DLA) propre aux templates. */
const TEMPLATE_KEYS = ["att", "attMag", "esq", "vue", "deg", "degMag", "arm", "armMag", "rmPct", "mmPct", "reg", "pv", "tour"];
const VANILLA_TEMPLATES = [
[1, "de l'Aigle", [0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
[2, "des Béhémoths", [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 30]],
[3, "des Cyclopes", [0, 1, 0, -1, 0, 1, 0, 0, 0, 0, 0, 0, 0]],
[4, "des Enragés", [0, 1, -1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]],
[5, "de Feu", [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]],
[6, "des Mages", [0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0]],
[7, "de l'Orage", [0, 0, 2, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0]],
[8, "de l'Ours", [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 5, 30]],
[9, "du Pic", [0, 0, -1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0]],
[10, "du Rat", [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
[11, "de Résistance", [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]],
[12, "de la Salamandre", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0]],
[13, "du Temps", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -30]],
[14, "de la Terre", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5, 30]],
[15, "du Sable", [0, 0, 3, -1, 0, 0, 0, -1, 0, 0, 0, 0, 0]],
[16, "des Vampires", [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0]],
[17, "des Duellistes", [0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
[18, "des Champions", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
[19, "des Anciens", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
];
/* Fourchettes de puissance « niveau X » vanilla (Mountypedia). La Longue-Vue /* Fourchettes de puissance « niveau X » vanilla (Mountypedia). La Longue-Vue
* vanilla tire dans {1,2,3,5,8} : tant que sa fourchette en base reste celle * vanilla tire dans {1,2,3,5,8} : tant que sa fourchette en base reste celle
* d'origine, on garde le tirage officiel. */ * d'origine, on garde le tirage officiel. */
@@ -55,6 +82,12 @@ function vanillaGear() {
}))); })));
} }
function vanillaTemplates() {
return VANILLA_TEMPLATES.map(([id, name, vals]) => ({
id, name, ...Object.fromEntries(TEMPLATE_KEYS.map((k, i) => [k, vals[i]])),
}));
}
function vanillaTreasures(cat) { function vanillaTreasures(cat) {
const [ids, defs, ranges] = cat === "potions" const [ids, defs, ranges] = cat === "potions"
? [p.POTION_IDS, p.POTION_DEFS, POTION_POWER_DEFAULTS] ? [p.POTION_IDS, p.POTION_DEFS, POTION_POWER_DEFAULTS]
@@ -93,6 +126,9 @@ function init(file = ":memory:") {
id TEXT PRIMARY KEY, name TEXT, emoji TEXT, id TEXT PRIMARY KEY, name TEXT, emoji TEXT,
powerMin INTEGER, powerMax INTEGER powerMin INTEGER, powerMax INTEGER
); );
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY, name TEXT, ${TEMPLATE_KEYS.map(k => `${k} INTEGER`).join(", ")}
);
`); `);
const insM = DB.prepare(`INSERT OR IGNORE INTO monsters const insM = DB.prepare(`INSERT OR IGNORE INTO monsters
(name, emoji, boss, isStatic, ${MONSTER_KEYS.join(", ")}) (name, emoji, boss, isStatic, ${MONSTER_KEYS.join(", ")})
@@ -110,6 +146,9 @@ function init(file = ":memory:") {
const ins = DB.prepare(`INSERT OR IGNORE INTO ${cat} (id, name, emoji, powerMin, powerMax) VALUES (?, ?, ?, ?, ?)`); const ins = DB.prepare(`INSERT OR IGNORE INTO ${cat} (id, name, emoji, powerMin, powerMax) VALUES (?, ?, ?, ?, ?)`);
for (const t of vanillaTreasures(cat)) ins.run(t.id, t.name, t.emoji, t.powerMin, t.powerMax); for (const t of vanillaTreasures(cat)) ins.run(t.id, t.name, t.emoji, t.powerMin, t.powerMax);
} }
const insT = DB.prepare(`INSERT OR IGNORE INTO templates (id, name, ${TEMPLATE_KEYS.join(", ")})
VALUES (?, ?, ${TEMPLATE_KEYS.map(() => "?").join(", ")})`);
for (const t of vanillaTemplates()) insT.run(t.id, t.name, ...TEMPLATE_KEYS.map(k => t[k]));
// Correctif des bases déjà créées : PufPuff avait été seedé à tort en [0,2] // Correctif des bases déjà créées : PufPuff avait été seedé à tort en [0,2]
// (le niveau 0 d'une potion n'existe pas, minimum 1) → on le ramène à [1,3]. // (le niveau 0 d'une potion n'existe pas, minimum 1) → on le ramène à [1,3].
// Le garde `powerMin = 0` évite d'écraser une plage éditée volontairement. // Le garde `powerMin = 0` évite d'écraser une plage éditée volontairement.
@@ -148,6 +187,10 @@ function treasuresAll(cat) {
return db().prepare(`SELECT * FROM ${cat === "scrolls" ? "scrolls" : "potions"}`).all(); return db().prepare(`SELECT * FROM ${cat === "scrolls" ? "scrolls" : "potions"}`).all();
} }
function templatesAll() {
return db().prepare("SELECT * FROM templates ORDER BY id").all();
}
/* ---------- Écriture (page admin) ---------- */ /* ---------- Écriture (page admin) ---------- */
function setMonster(name, vals) { function setMonster(name, vals) {
@@ -169,10 +212,18 @@ function setTreasureRange(cat, id, [min, max]) {
.run(min, max, id); .run(min, max, id);
} }
function setTemplate(id, vals) {
const keys = TEMPLATE_KEYS.filter(k => vals[k] != null);
if (!keys.length) return;
db().prepare(`UPDATE templates SET ${keys.map(k => `${k} = ?`).join(", ")} WHERE id = ?`)
.run(...keys.map(k => vals[k]), id);
}
/* Remet une catégorie entière aux valeurs vanilla. */ /* Remet une catégorie entière aux valeurs vanilla. */
function resetCategory(cat) { function resetCategory(cat) {
if (cat === "monsters") for (const m of vanillaMonsters()) setMonster(m.name, m); if (cat === "monsters") for (const m of vanillaMonsters()) setMonster(m.name, m);
if (cat === "gear") for (const it of vanillaGear()) setGear(it.slot, it.name, it); if (cat === "gear") for (const it of vanillaGear()) setGear(it.slot, it.name, it);
if (cat === "templates") for (const t of vanillaTemplates()) setTemplate(t.id, t);
if (cat === "potions" || cat === "scrolls") { if (cat === "potions" || cat === "scrolls") {
for (const t of vanillaTreasures(cat)) setTreasureRange(cat, t.id, [t.powerMin, t.powerMax]); for (const t of vanillaTreasures(cat)) setTreasureRange(cat, t.id, [t.powerMin, t.powerMax]);
} }
@@ -180,8 +231,8 @@ function resetCategory(cat) {
module.exports = { module.exports = {
init, db, init, db,
MONSTER_KEYS, GEAR_KEYS, POTION_POWER_DEFAULTS, SCROLL_POWER_DEFAULTS, MONSTER_KEYS, GEAR_KEYS, TEMPLATE_KEYS, POTION_POWER_DEFAULTS, SCROLL_POWER_DEFAULTS,
vanillaMonsters, vanillaGear, vanillaTreasures, vanillaMonsters, vanillaGear, vanillaTreasures, vanillaTemplates,
monsters, gearAll, gearRow, treasureRange, treasuresAll, monsters, gearAll, gearRow, treasureRange, treasuresAll, templatesAll,
setMonster, setGear, setTreasureRange, resetCategory, setMonster, setGear, setTreasureRange, setTemplate, resetCategory,
}; };
+1 -1
View File
@@ -4,7 +4,7 @@
"use strict"; "use strict";
const APP_VERSION = "2.6.3"; const APP_VERSION = "2.7.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;
+1
View File
@@ -102,6 +102,7 @@ const GEAR_MOD_LABELS = [
["arm", "Armure phy.", ""], ["armMag", "Armure mag.", ""], ["arm", "Armure phy.", ""], ["armMag", "Armure mag.", ""],
["vue", "VUE", ""], ["pv", "PV", ""], ["vue", "VUE", ""], ["pv", "PV", ""],
["rmPct", "RM", " %"], ["mmPct", "MM", " %"], ["rmPct", "RM", " %"], ["mmPct", "MM", " %"],
["tour", "TOUR", " mn"], // apporté par les templates de drop (modif de DLA)
]; ];
function formatGearMods(mods, sep = " · ") { function formatGearMods(mods, sep = " · ") {
+31 -2
View File
@@ -41,6 +41,7 @@ const DEFAULT_CONFIG = {
deathRespawnSec: 90, // délai de réapparition d'un troll terrassé deathRespawnSec: 90, // délai de réapparition d'un troll terrassé
maxTrolls: 40, maxTrolls: 40,
logSize: 150, logSize: 150,
templateChance: 0, // proba (%) qu'un emplacement de template soit rempli sur un drop d'équipement
}; };
const CONFIG_BOUNDS = { const CONFIG_BOUNDS = {
@@ -50,6 +51,7 @@ const CONFIG_BOUNDS = {
monsterTarget: [0, 120], repopSec: [10, 3600], monsterTarget: [0, 120], repopSec: [10, 3600],
itemTarget: [0, 120], deathRespawnSec: [0, 3600], itemTarget: [0, 120], deathRespawnSec: [0, 3600],
maxTrolls: [1, 200], logSize: [20, 500], maxTrolls: [1, 200], logSize: [20, 500],
templateChance: [0, 100],
}; };
/* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ---------- /* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ----------
@@ -99,6 +101,33 @@ function applyGearTuning(item) {
return item; return item;
} }
/* Templates de drop : un objet qui tombe reçoit jusqu'à 3 templates (6 pour une
* arme à 2 mains), chaque emplacement étant rempli avec la proba `chance` (%)
* par un template DISTINCT tiré au hasard en base. Les bonus (dont `tour`)
* s'ajoutent aux mods de l'objet, et les suffixes au nom (« Épée Courte de
* l'Aigle des Mages »). N'affecte que le monde partagé (drops) ; le solo reste
* vanilla. */
function applyGearTemplates(item, chance) {
if (!item || item.kind !== "gear" || !(chance > 0)) return item;
const pool = db.templatesAll();
if (!pool.length) return item;
const slots = item.twoHanded ? 6 : 3;
const picked = [];
for (let i = 0; i < slots && pool.length; i++) {
if (Math.random() * 100 >= chance) continue;
picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
}
if (!picked.length) return item;
const mods = { ...(item.mods || {}) };
for (const t of picked) {
for (const k of db.TEMPLATE_KEYS) if (t[k]) mods[k] = (mods[k] || 0) + t[k];
}
item.mods = Object.fromEntries(Object.entries(mods).filter(([, v]) => v));
item.name = `${item.name} ${picked.map(t => t.name).join(" ")}`;
item.templates = picked.map(t => t.name);
return item;
}
/* ---------- Création du monde ---------- */ /* ---------- Création du monde ---------- */
function randomFloor(world, taken = null) { function randomFloor(world, taken = null) {
@@ -153,7 +182,7 @@ function spawnItem(world) {
const depth = world.config.worldDepth; const depth = world.config.worldDepth;
const item = r < 0.34 ? tunedRandomPotion() const item = r < 0.34 ? tunedRandomPotion()
: r < 0.48 ? tunedRandomScroll() : r < 0.48 ? tunedRandomScroll()
: r < 0.78 ? applyGearTuning(gearLib.makeRandomGear(depth)) : r < 0.78 ? applyGearTemplates(applyGearTuning(gearLib.makeRandomGear(depth)), world.config.templateChance)
: { kind: "gold", gold: g.rollDice(depth, 6).total * 10, emoji: "💰" }; : { kind: "gold", gold: g.rollDice(depth, 6).total * 10, emoji: "💰" };
if (item.kind === "gold") item.name = `${item.gold} Mountyzédons`; if (item.kind === "gold") item.name = `${item.gold} Mountyzédons`;
world.items.push({ ...item, x: pos.x, y: pos.y }); world.items.push({ ...item, x: pos.x, y: pos.y });
@@ -1038,5 +1067,5 @@ module.exports = {
adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll, adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll,
currentTuning, saveWorld, loadWorld, currentTuning, saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog, spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
tunedRandomPotion, tunedRandomScroll, applyGearTuning, tunedRandomPotion, tunedRandomScroll, applyGearTuning, applyGearTemplates,
}; };
+2 -1
View File
@@ -275,8 +275,9 @@ function handleAPI(req, res, url) {
monsters: () => db.monsters(), monsters: () => db.monsters(),
potions: () => db.treasuresAll("potions"), potions: () => db.treasuresAll("potions"),
scrolls: () => db.treasuresAll("scrolls"), scrolls: () => db.treasuresAll("scrolls"),
templates: () => db.templatesAll(),
}; };
const refMatch = url.pathname.match(/^\/api\/reference\/(gear|monsters|potions|scrolls)$/); const refMatch = url.pathname.match(/^\/api\/reference\/(gear|monsters|potions|scrolls|templates)$/);
if (req.method === "GET" && refMatch) { if (req.method === "GET" && refMatch) {
return sendJSON(res, 200, REFERENCE[refMatch[1]]()); return sendJSON(res, 200, REFERENCE[refMatch[1]]());
} }
+14
View File
@@ -280,6 +280,20 @@ function makeWorld(over = {}) {
assert.strictEqual(db.gearRow("arme", "Gourdin").att, 2, "reset gear → vanilla (gourdin)"); assert.strictEqual(db.gearRow("arme", "Gourdin").att, 2, "reset gear → vanilla (gourdin)");
} }
// Templates de drop : nombre selon les mains, distincts, bonus fusionnés, proba 0
{
const one = mp.applyGearTemplates({ kind: "gear", slot: "arme", name: "Épée Courte", twoHanded: false, mods: { deg: 2 } }, 100);
assert.strictEqual(one.templates.length, 3, "1 main : 3 templates max");
assert.strictEqual(one.mods.deg >= 2, true, "mod de base conservé");
assert(one.name.startsWith("Épée Courte "), "suffixes de template ajoutés au nom");
const two = mp.applyGearTemplates({ kind: "gear", slot: "arme", name: "Épée Longue", twoHanded: true, mods: {} }, 100);
assert.strictEqual(two.templates.length, 6, "2 mains : 6 templates max");
assert.strictEqual(new Set(two.templates).size, 6, "templates tous distincts");
const none = mp.applyGearTemplates({ kind: "gear", slot: "arme", name: "Gourdin", twoHanded: false, mods: { att: 2 } }, 0);
assert(!none.templates, "proba 0 : aucun template");
assert.strictEqual(none.name, "Gourdin", "nom inchangé sans template");
}
// Tuning admin : puissance des potions/parchemins et bonus d'équipement // Tuning admin : puissance des potions/parchemins et bonus d'équipement
{ {
const w = makeWorld({ monsterTarget: 0, itemTarget: 0 }); const w = makeWorld({ monsterTarget: 0, itemTarget: 0 });