diff --git a/Dockerfile b/Dockerfile index 74102d0..520f5f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM node:22-alpine +FROM node:24-alpine WORKDIR /app -COPY index.html admin.html server.js mp.js ./ +COPY index.html admin.html server.js mp.js db.js ./ COPY css ./css COPY js ./js COPY img ./img diff --git a/README.md b/README.md index cd7171c..6cbd969 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,15 @@ nouveaux spawns/drops utilisent les valeurs retouchées ; le solo reste vanilla) - **Tuning des potions & parchemins** : fourchette de puissance « niveau X » (min/max) tirée à chaque drop, par trésor. -Les cases modifiées sont surlignées ; seuls les écarts au vanilla sont stockés -(`world.tuning`), et chaque catégorie se remet d'origine d'un clic. +Les cases modifiées sont surlignées et chaque catégorie se remet d'origine d'un +clic. Depuis la 2.3.0, ces valeurs vivent dans une **base SQLite** +(`DB_FILE`, défaut `/data/mountycrawl.db`, via le module natif `node:sqlite` — +toujours zéro dépendance npm) : tables `monsters`, `gear`, `potions`, `scrolls`, +seedées vanilla au premier démarrage puis jamais écrasées. On peut donc aussi +les éditer directement avec n'importe quel outil SQLite (DB Browser, DBeaver, +sqlite3) — chaque spawn/drop relit la base, c'est pris en compte à chaud. +L'état vivant du monde (trolls, monstres actifs, objets au sol) reste dans +`world.json` ; l'ancien `world.tuning` est migré automatiquement dans la base. ### API multijoueur @@ -227,6 +234,16 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **2.3.0** (2026-06-12) — Les valeurs de référence du monde partagé passent dans + une **base SQLite** (`db.js`, module natif `node:sqlite` — toujours zéro + dépendance npm) : tables `monsters` (bestiaire), `gear` (55 objets), + `potions`/`scrolls` (fourchettes de puissance), dans `DB_FILE` (défaut + `/data/mountycrawl.db`). Seed vanilla au premier démarrage, jamais écrasé + ensuite ; chaque spawn/drop **relit la base**, donc une modification — page + admin ou édition directe au DB Browser/DBeaver/sqlite3 — s'applique à chaud. + L'ancien `world.tuning` de world.json est migré automatiquement puis retiré. + L'état vivant (trolls, monstres actifs, objets au sol) reste dans `world.json`. + Image Docker en node:24 (SQLite natif stable). API et page admin inchangées. - **2.2.0** (2026-06-12) — Le couple **physique/magique partout** : comme l'armure, l'ATT et les DEG existent désormais en deux saveurs pour les **monstres** (`attMag`, `degMag` — un monstre qui en a alterne au hasard entre attaque diff --git a/admin.html b/admin.html index a67dcf1..88b248a 100644 --- a/admin.html +++ b/admin.html @@ -89,8 +89,11 @@

⚔️ Tuning de l'équipement

-

Bonus/malus de chaque objet (physiques, fixes sur les jets ; RM/MM en %). - S'applique aux prochains drops du monde partagé.

+

Bonus/malus de chaque objet (fixes sur les jets ; RM/MM en %). + S'applique aux prochains drops du monde partagé. Ces valeurs vivent dans la + base SQLite data/mountycrawl.db (tables gear, + monsters, potions, scrolls) — tu peux + aussi les éditer avec n'importe quel outil SQLite, c'est relu à chaque drop.

diff --git a/db.js b/db.js new file mode 100644 index 0000000..e294ed0 --- /dev/null +++ b/db.js @@ -0,0 +1,183 @@ +/* Base de référence MountyCrawl — SQLite natif de Node (node:sqlite), toujours + * zéro dépendance npm. Elle contient les VALEURS DE RÉFÉRENCE du monde partagé : + * bestiaire, équipement, fourchettes de puissance des potions et parchemins. + * Chaque spawn/drop du serveur relit la base : une modification (page admin ou + * n'importe quel éditeur SQLite sur le fichier) est prise en compte à chaud. + * L'état vivant du monde (trolls, monstres actifs, objets au sol) reste dans + * world.json — seule la référence vit ici. + * + * Au premier démarrage la base est créée et remplie avec les valeurs vanilla + * (Mountypedia) ; ensuite les lignes existantes ne sont JAMAIS écrasées au + * démarrage (les retouches survivent aux mises à jour du jeu, et les nouveaux + * objets/monstres d'une future version sont ajoutés avec INSERT OR IGNORE). */ + +"use strict"; + +const { DatabaseSync } = require("node:sqlite"); + +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 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"]; + +/* 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 + * d'origine, on garde le tirage officiel. */ +const POTION_POWER_DEFAULTS = { + biskot: [0, 0], doverPowa: [11, 100], bonneBouffe: [3, 7], corruption: [3, 7], + fertilite: [3, 7], feu: [3, 7], longueVue: [1, 8], kouleMann: [1, 5], + djhinTonik: [1, 5], glacier: [3, 7], calvok: [2, 6], rhume: [1, 2], + grippe: [3, 4], pneumonie: [5, 5], cervelle: [2, 6], chronometre: [1, 5], + metomol: [1, 5], guerison: [1, 5], painture: [1, 5], pufPuff: [0, 2], + sangToh: [1, 5], sinneKhole: [11, 100], toxine: [1, 5], voiputrin: [1, 5], + zetCrak: [1, 5], +}; +const SCROLL_POWER_DEFAULTS = Object.fromEntries(sc.SCROLL_IDS.map(id => [id, [1, 5]])); + +let DB = null; + +/* ---------- Valeurs vanilla (sources JS du jeu) ---------- */ + +function vanillaMonsters() { + return [...g.MONSTER_TYPES, g.BOSS].map(t => ({ + name: t.name, emoji: t.emoji, boss: !!t.boss, static: !!t.static, + ...Object.fromEntries(MONSTER_KEYS.map(k => [k, t[k] || 0])), + })); +} + +function vanillaGear() { + return Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(def => ({ + slot, name: def.name, emoji: def.emoji, tier: def.tier, twoHanded: !!def.twoHanded, + ...Object.fromEntries(GEAR_KEYS.map(k => [k, def.mods[k] || 0])), + }))); +} + +function vanillaTreasures(cat) { + const [ids, defs, ranges] = cat === "potions" + ? [p.POTION_IDS, p.POTION_DEFS, POTION_POWER_DEFAULTS] + : [sc.SCROLL_IDS, sc.SCROLL_DEFS, SCROLL_POWER_DEFAULTS]; + return ids.map(id => ({ + id, name: defs[id].name, emoji: defs[id].emoji, + powerMin: ranges[id][0], powerMax: ranges[id][1], + })); +} + +/* ---------- Ouverture et seed ---------- */ + +function init(file = ":memory:") { + if (DB) DB.close(); + DB = new DatabaseSync(file); + DB.exec("PRAGMA journal_mode = WAL;"); + DB.exec(` + CREATE TABLE IF NOT EXISTS monsters ( + name TEXT PRIMARY KEY, emoji TEXT, boss INTEGER, isStatic INTEGER, + level INTEGER, att INTEGER, attMag INTEGER, esq INTEGER, + deg INTEGER, degMag INTEGER, pv INTEGER, + armor INTEGER, armorMag INTEGER, vue INTEGER + ); + CREATE TABLE IF NOT EXISTS gear ( + slot TEXT, name TEXT, emoji TEXT, tier INTEGER, twoHanded INTEGER, + att INTEGER, attMag INTEGER, esq INTEGER, deg INTEGER, degMag INTEGER, + reg INTEGER, arm INTEGER, armMag INTEGER, vue INTEGER, pv INTEGER, + rmPct INTEGER, mmPct INTEGER, + PRIMARY KEY (slot, name) + ); + CREATE TABLE IF NOT EXISTS potions ( + id TEXT PRIMARY KEY, name TEXT, emoji TEXT, + powerMin INTEGER, powerMax INTEGER + ); + CREATE TABLE IF NOT EXISTS scrolls ( + id TEXT PRIMARY KEY, name TEXT, emoji TEXT, + powerMin INTEGER, powerMax INTEGER + ); + `); + const insM = DB.prepare(`INSERT OR IGNORE INTO monsters + (name, emoji, boss, isStatic, ${MONSTER_KEYS.join(", ")}) + VALUES (?, ?, ?, ?, ${MONSTER_KEYS.map(() => "?").join(", ")})`); + for (const m of vanillaMonsters()) { + insM.run(m.name, m.emoji, m.boss ? 1 : 0, m.static ? 1 : 0, ...MONSTER_KEYS.map(k => m[k])); + } + const insG = DB.prepare(`INSERT OR IGNORE INTO gear + (slot, name, emoji, tier, twoHanded, ${GEAR_KEYS.join(", ")}) + VALUES (?, ?, ?, ?, ?, ${GEAR_KEYS.map(() => "?").join(", ")})`); + for (const it of vanillaGear()) { + insG.run(it.slot, it.name, it.emoji, it.tier, it.twoHanded ? 1 : 0, ...GEAR_KEYS.map(k => it[k])); + } + for (const cat of ["potions", "scrolls"]) { + 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); + } + return DB; +} + +function db() { + if (!DB) init(process.env.DB_FILE || ":memory:"); + return DB; +} + +/* ---------- Lecture (chaque spawn/drop relit la base : modifs à chaud) ---------- */ + +function monsters() { + return db().prepare("SELECT * FROM monsters").all().map(r => ({ + ...r, boss: !!r.boss, static: !!r.isStatic, isStatic: undefined, + })); +} + +function gearAll() { + return db().prepare("SELECT * FROM gear").all().map(r => ({ ...r, twoHanded: !!r.twoHanded })); +} + +function gearRow(slot, name) { + const r = db().prepare("SELECT * FROM gear WHERE slot = ? AND name = ?").get(slot, name); + return r ? { ...r, twoHanded: !!r.twoHanded } : null; +} + +function treasureRange(cat, id) { + const r = db().prepare(`SELECT powerMin, powerMax FROM ${cat === "scrolls" ? "scrolls" : "potions"} WHERE id = ?`).get(id); + return r ? [r.powerMin, r.powerMax] : null; +} + +function treasuresAll(cat) { + return db().prepare(`SELECT * FROM ${cat === "scrolls" ? "scrolls" : "potions"}`).all(); +} + +/* ---------- Écriture (page admin) ---------- */ + +function setMonster(name, vals) { + const keys = MONSTER_KEYS.filter(k => vals[k] != null); + if (!keys.length) return; + db().prepare(`UPDATE monsters SET ${keys.map(k => `${k} = ?`).join(", ")} WHERE name = ?`) + .run(...keys.map(k => vals[k]), name); +} + +function setGear(slot, name, vals) { + const keys = GEAR_KEYS.filter(k => vals[k] != null); + if (!keys.length) return; + db().prepare(`UPDATE gear SET ${keys.map(k => `${k} = ?`).join(", ")} WHERE slot = ? AND name = ?`) + .run(...keys.map(k => vals[k]), slot, name); +} + +function setTreasureRange(cat, id, [min, max]) { + db().prepare(`UPDATE ${cat === "scrolls" ? "scrolls" : "potions"} SET powerMin = ?, powerMax = ? WHERE id = ?`) + .run(min, max, id); +} + +/* Remet une catégorie entière aux valeurs vanilla. */ +function resetCategory(cat) { + 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 === "potions" || cat === "scrolls") { + for (const t of vanillaTreasures(cat)) setTreasureRange(cat, t.id, [t.powerMin, t.powerMax]); + } +} + +module.exports = { + init, db, + MONSTER_KEYS, GEAR_KEYS, POTION_POWER_DEFAULTS, SCROLL_POWER_DEFAULTS, + vanillaMonsters, vanillaGear, vanillaTreasures, + monsters, gearAll, gearRow, treasureRange, treasuresAll, + setMonster, setGear, setTreasureRange, resetCategory, +}; diff --git a/js/game.js b/js/game.js index 76db639..4f6ffc0 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "2.2.0"; +const APP_VERSION = "2.3.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; diff --git a/mp.js b/mp.js index ab7c78b..d077a6a 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 db = require("./db.js"); /* ---------- Configuration par défaut (tout est réglable via l'admin) ---------- */ @@ -52,59 +53,49 @@ const CONFIG_BOUNDS = { }; /* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ---------- - * world.tuning ne stocke que les écarts à la valeur de base (vanilla). Il - * s'applique aux nouveaux spawns/drops du monde partagé — le solo reste vanilla. */ + * Les valeurs de référence vivent dans la base SQLite (db.js) ; chaque + * spawn/drop la relit, donc une modification (page admin ou éditeur SQLite) + * s'applique à chaud aux nouveaux spawns/drops — le solo reste vanilla. */ -const MONSTER_TUNE_KEYS = ["level", "att", "attMag", "esq", "deg", "degMag", "pv", "armor", "armorMag", "vue"]; +const MONSTER_TUNE_KEYS = db.MONSTER_KEYS; const MONSTER_TUNE_BOUNDS = { level: [1, 99], att: [1, 99], attMag: [0, 99], esq: [1, 99], deg: [1, 99], degMag: [0, 99], pv: [1, 999], armor: [0, 99], armorMag: [0, 99], vue: [1, 30] }; -const GEAR_TUNE_KEYS = ["att", "attMag", "esq", "deg", "degMag", "reg", "arm", "armMag", "vue", "pv", "rmPct", "mmPct"]; +const GEAR_TUNE_KEYS = db.GEAR_KEYS; const GEAR_TUNE_BOUNDS = [-100, 100]; const POWER_BOUNDS = [0, 200]; -/* Fourchettes de puissance « niveau X » par défaut (Mountypedia). La Longue-Vue - * vanilla tire dans {1,2,3,5,8} : sans override on garde le tirage officiel. */ -const POTION_POWER_DEFAULTS = { - biskot: [0, 0], doverPowa: [11, 100], bonneBouffe: [3, 7], corruption: [3, 7], - fertilite: [3, 7], feu: [3, 7], longueVue: [1, 8], kouleMann: [1, 5], - djhinTonik: [1, 5], glacier: [3, 7], calvok: [2, 6], rhume: [1, 2], - grippe: [3, 4], pneumonie: [5, 5], cervelle: [2, 6], chronometre: [1, 5], - metomol: [1, 5], guerison: [1, 5], painture: [1, 5], pufPuff: [0, 2], - sangToh: [1, 5], sinneKhole: [11, 100], toxine: [1, 5], voiputrin: [1, 5], - zetCrak: [1, 5], -}; -const SCROLL_POWER_DEFAULTS = Object.fromEntries(sc.SCROLL_IDS.map(id => [id, [1, 5]])); - -function emptyTuning() { - return { monsters: {}, gear: {}, potions: {}, scrolls: {} }; -} - function randRange([min, max]) { const lo = Math.min(min, max), hi = Math.max(min, max); return lo + Math.floor(Math.random() * (hi - lo + 1)); } -/* Types de monstres avec le tuning admin appliqué (avant gabarit d'âge). */ -function tunedMonsterTypes(world) { - const tune = (world.tuning && world.tuning.monsters) || {}; - return [...g.MONSTER_TYPES, g.BOSS].map(t => tune[t.name] ? { ...t, ...tune[t.name] } : t); +const sameRange = (a, b) => a && b && Math.min(...a) === Math.min(...b) && Math.max(...a) === Math.max(...b); + +/* Types de monstres tels qu'en base (avant gabarit d'âge). */ +function tunedMonsterTypes() { + return db.monsters(); } -function tunedRandomPotion(world) { +function tunedRandomPotion() { const item = p.makeRandomPotion(); - const range = (world.tuning && world.tuning.potions || {})[item.potionId]; - return range ? p.makePotionItem(item.potionId, randRange(range)) : item; + const range = db.treasureRange("potions", item.potionId); + // fourchette d'origine → on garde le tirage vanilla (ex. Longue-Vue {1,2,3,5,8}) + if (!range || sameRange(range, db.POTION_POWER_DEFAULTS[item.potionId])) return item; + return p.makePotionItem(item.potionId, randRange(range)); } -function tunedRandomScroll(world) { +function tunedRandomScroll() { const item = sc.makeRandomScroll(); - const range = (world.tuning && world.tuning.scrolls || {})[item.scrollId]; - return range ? sc.makeScrollItem(item.scrollId, randRange(range)) : item; + const range = db.treasureRange("scrolls", item.scrollId); + if (!range || sameRange(range, db.SCROLL_POWER_DEFAULTS[item.scrollId])) return item; + return sc.makeScrollItem(item.scrollId, randRange(range)); } -function applyGearTuning(world, item) { +function applyGearTuning(item) { if (!item || item.kind !== "gear") return item; - const tune = (world.tuning && world.tuning.gear || {})[`${item.slot}/${item.name}`]; - if (tune) item.mods = { ...item.mods, ...tune }; + const row = db.gearRow(item.slot, item.name); + if (row) { + item.mods = Object.fromEntries(db.GEAR_KEYS.map(k => [k, row[k] || 0]).filter(([, v]) => v)); + } return item; } @@ -141,7 +132,7 @@ function spawnMonster(world, now = Date.now()) { // même logique que makeMonster (pool par profondeur + gabarit d'âge), // mais sur les types tunés par l'admin const depth = world.config.worldDepth; - const types = tunedMonsterTypes(world).filter(t => !t.boss); + 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)]; @@ -160,9 +151,9 @@ function spawnItem(world) { if (!pos) return null; const r = Math.random(); const depth = world.config.worldDepth; - const item = r < 0.34 ? tunedRandomPotion(world) - : r < 0.48 ? tunedRandomScroll(world) - : r < 0.78 ? applyGearTuning(world, gearLib.makeRandomGear(depth)) + const item = r < 0.34 ? tunedRandomPotion() + : r < 0.48 ? tunedRandomScroll() + : r < 0.78 ? applyGearTuning(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 }); @@ -173,7 +164,6 @@ function createWorld(config = {}) { const cfg = { ...DEFAULT_CONFIG, ...config }; const world = { config: cfg, - tuning: emptyTuning(), grid: g.generateCavern(cfg.mapW, cfg.mapH), trolls: {}, monsters: [], @@ -434,8 +424,8 @@ function killMonsterMP(world, t, m) { 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 ? { ...tunedRandomPotion(world), x: m.x, y: m.y } - : lr < 0.65 ? { ...tunedRandomScroll(world), x: m.x, y: m.y } + const drop = lr < 0.4 ? { ...tunedRandomPotion(), x: m.x, y: m.y } + : lr < 0.65 ? { ...tunedRandomScroll(), 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"); @@ -811,7 +801,7 @@ function adminOverview(world, now = Date.now()) { return { config: world.config, bounds: CONFIG_BOUNDS, - tuning: world.tuning || emptyTuning(), + tuning: currentTuning(), defaults: adminDefaults(), uptime: now - world.createdAt, trolls: Object.values(world.trolls).map(t => ({ @@ -857,41 +847,60 @@ function adminSetConfig(world, patch, now = Date.now()) { /* Valeurs de base (vanilla) pour construire les formulaires de tuning admin. */ function adminDefaults() { return { - monsters: [...g.MONSTER_TYPES, g.BOSS].map(t => ({ - name: t.name, emoji: t.emoji, boss: !!t.boss, - ...Object.fromEntries(MONSTER_TUNE_KEYS.map(k => [k, t[k] || 0])), - })), + monsters: db.vanillaMonsters(), monsterKeys: MONSTER_TUNE_KEYS, - gear: Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(def => ({ - slot, name: def.name, emoji: def.emoji, twoHanded: !!def.twoHanded, - mods: Object.fromEntries(GEAR_TUNE_KEYS.map(k => [k, def.mods[k] || 0])), - }))), + gear: db.vanillaGear().map(it => ({ + slot: it.slot, name: it.name, emoji: it.emoji, twoHanded: it.twoHanded, + mods: Object.fromEntries(GEAR_TUNE_KEYS.map(k => [k, it[k] || 0])), + })), gearKeys: GEAR_TUNE_KEYS, - potions: p.POTION_IDS.map(id => ({ - id, name: p.POTION_DEFS[id].name, emoji: p.POTION_DEFS[id].emoji, - min: POTION_POWER_DEFAULTS[id][0], max: POTION_POWER_DEFAULTS[id][1], - })), - scrolls: sc.SCROLL_IDS.map(id => ({ - id, name: sc.SCROLL_DEFS[id].name, emoji: sc.SCROLL_DEFS[id].emoji, - min: SCROLL_POWER_DEFAULTS[id][0], max: SCROLL_POWER_DEFAULTS[id][1], - })), + potions: db.vanillaTreasures("potions").map(t => ({ id: t.id, name: t.name, emoji: t.emoji, min: t.powerMin, max: t.powerMax })), + scrolls: db.vanillaTreasures("scrolls").map(t => ({ id: t.id, name: t.name, emoji: t.emoji, min: t.powerMin, max: t.powerMax })), }; } +/* Écarts actuels entre la base et le vanilla (pour surligner dans l'admin). */ +function currentTuning() { + const out = { monsters: {}, gear: {}, potions: {}, scrolls: {} }; + const vm = Object.fromEntries(db.vanillaMonsters().map(m => [m.name, m])); + for (const m of db.monsters()) { + if (!vm[m.name]) continue; + const d = {}; + for (const k of MONSTER_TUNE_KEYS) if ((m[k] || 0) !== vm[m.name][k]) d[k] = m[k]; + if (Object.keys(d).length) out.monsters[m.name] = d; + } + const vg = Object.fromEntries(db.vanillaGear().map(i => [`${i.slot}/${i.name}`, i])); + for (const it of db.gearAll()) { + const key = `${it.slot}/${it.name}`; + if (!vg[key]) continue; + const d = {}; + for (const k of GEAR_TUNE_KEYS) if ((it[k] || 0) !== vg[key][k]) d[k] = it[k]; + if (Object.keys(d).length) out.gear[key] = d; + } + for (const cat of ["potions", "scrolls"]) { + const defaults = cat === "potions" ? db.POTION_POWER_DEFAULTS : db.SCROLL_POWER_DEFAULTS; + for (const t of db.treasuresAll(cat)) { + if (defaults[t.id] && !sameRange([t.powerMin, t.powerMax], defaults[t.id])) { + out[cat][t.id] = [t.powerMin, t.powerMax]; + } + } + } + return out; +} + const clampInt = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v))); -/* Applique un patch de tuning : chaque catégorie fournie remplace l'existante - * (l'admin envoie l'ensemble de ses écarts). Tout est validé et borné. */ +/* Applique un patch de tuning : chaque catégorie fournie est remise au vanilla + * puis les écarts envoyés sont écrits en base (validés et bornés). */ function adminSetTuning(world, patch) { - world.tuning = world.tuning || emptyTuning(); const known = { - monsters: new Set([...g.MONSTER_TYPES, g.BOSS].map(t => t.name)), - gear: new Set(Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(d => `${slot}/${d.name}`))), + monsters: new Set(db.vanillaMonsters().map(t => t.name)), + gear: new Set(db.vanillaGear().map(i => `${i.slot}/${i.name}`)), potions: new Set(p.POTION_IDS), scrolls: new Set(sc.SCROLL_IDS), }; if (patch.monsters && typeof patch.monsters === "object") { - const out = {}; + db.resetCategory("monsters"); for (const [name, vals] of Object.entries(patch.monsters)) { if (!known.monsters.has(name) || typeof vals !== "object") continue; const entry = {}; @@ -899,12 +908,11 @@ function adminSetTuning(world, patch) { const v = Number(vals[k]); if (Number.isFinite(v)) entry[k] = clampInt(v, MONSTER_TUNE_BOUNDS[k][0], MONSTER_TUNE_BOUNDS[k][1]); } - if (Object.keys(entry).length) out[name] = entry; + db.setMonster(name, entry); } - world.tuning.monsters = out; } if (patch.gear && typeof patch.gear === "object") { - const out = {}; + db.resetCategory("gear"); for (const [key, mods] of Object.entries(patch.gear)) { if (!known.gear.has(key) || typeof mods !== "object") continue; const entry = {}; @@ -912,25 +920,24 @@ function adminSetTuning(world, patch) { const v = Number(mods[k]); if (Number.isFinite(v)) entry[k] = clampInt(v, GEAR_TUNE_BOUNDS[0], GEAR_TUNE_BOUNDS[1]); } - if (Object.keys(entry).length) out[key] = entry; + const slash = key.indexOf("/"); + db.setGear(key.slice(0, slash), key.slice(slash + 1), entry); } - world.tuning.gear = out; } for (const cat of ["potions", "scrolls"]) { if (!patch[cat] || typeof patch[cat] !== "object") continue; - const out = {}; + db.resetCategory(cat); for (const [id, range] of Object.entries(patch[cat])) { if (!known[cat].has(id) || !Array.isArray(range)) continue; const lo = Number(range[0]), hi = Number(range[1]); if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; - out[id] = [ + db.setTreasureRange(cat, id, [ clampInt(Math.min(lo, hi), POWER_BOUNDS[0], POWER_BOUNDS[1]), clampInt(Math.max(lo, hi), POWER_BOUNDS[0], POWER_BOUNDS[1]), - ]; + ]); } - world.tuning[cat] = out; } - return world.tuning; + return currentTuning(); } /* Supprime définitivement un troll du monde (admin). */ @@ -975,7 +982,13 @@ function loadWorld(file) { const world = JSON.parse(fs.readFileSync(file, "utf8")); if (!world || !world.grid || !world.trolls) return null; world.config = { ...DEFAULT_CONFIG, ...world.config }; - world.tuning = { ...emptyTuning(), ...world.tuning }; + // migration < 2.3.0 : les écarts de tuning vivaient dans world.json, + // on les déverse une fois dans la base puis on les retire du monde + if (world.tuning) { + const hasDeltas = Object.values(world.tuning).some(cat => cat && Object.keys(cat).length); + if (hasDeltas) adminSetTuning(world, world.tuning); + delete world.tuning; + } return world; } catch { return null; } } @@ -984,7 +997,7 @@ module.exports = { DEFAULT_CONFIG, CONFIG_BOUNDS, createWorld, tick, newTroll, authTroll, login, action, stateFor, adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll, - saveWorld, loadWorld, + currentTuning, saveWorld, loadWorld, spawnMonster, spawnItem, monsterAct, trollDla, worldLog, tunedRandomPotion, tunedRandomScroll, applyGearTuning, }; diff --git a/server.js b/server.js index 87c7441..4b74df2 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,9 @@ /* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires * + monde multijoueur persistant (mp.js). - * Node pur, aucune dépendance. Stockage : fichiers JSON (LEVELS_FILE, WORLD_FILE). */ + * Node pur, aucune dépendance npm. Stockage : fichiers JSON (LEVELS_FILE, + * WORLD_FILE pour l'état vivant) + base SQLite native (DB_FILE) pour les + * valeurs de référence — bestiaire, équipement, potions, parchemins — + * éditables à chaud par la page admin ou n'importe quel outil SQLite. */ "use strict"; @@ -8,12 +11,15 @@ const http = require("http"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); -const mp = require("./mp.js"); +const db = require("./db.js"); const PORT = process.env.PORT || 80; const ROOT = __dirname; const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json"; const WORLD_FILE = process.env.WORLD_FILE || "/data/world.json"; +const DB_FILE = process.env.DB_FILE || "/data/mountycrawl.db"; + +const mp = require("./mp.js"); const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large const MAX_LEVELS = 500; @@ -121,6 +127,7 @@ let worldDirty = false; let ADMIN_TOKEN = null; function initMP() { + db.init(DB_FILE); // avant loadWorld : la migration du tuning < 2.3.0 écrit dedans 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; @@ -332,7 +339,7 @@ const server = http.createServer((req, res) => { if (require.main === module) { initMP(); - server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}, monde dans ${WORLD_FILE}`)); + server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}, monde dans ${WORLD_FILE}, référence dans ${DB_FILE}`)); } module.exports = { validateLevel, MAP_W, MAP_H }; diff --git a/test/mp.js b/test/mp.js index 87278f9..73ffd92 100644 --- a/test/mp.js +++ b/test/mp.js @@ -241,7 +241,8 @@ function makeWorld(over = {}) { // Tuning admin : bestiaire appliqué aux nouveaux spawns { const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 }); - mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9, pv: 77, armorMag: 3 } } }); + const tun1 = mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9, pv: 77, armorMag: 3 } } }); + 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++) { @@ -254,33 +255,33 @@ function makeWorld(over = {}) { 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 - mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } }); - assert(!w.tuning.monsters.Dragon, "type inconnu ignoré"); - assert.strictEqual(w.tuning.monsters.Gobelin.att, 99, "ATT bornée à 99"); + 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 - mp.adminSetTuning(w, { monsters: {} }); - assert.strictEqual(Object.keys(w.tuning.monsters).length, 0, "bestiaire d'origine restauré"); + const tun3 = mp.adminSetTuning(w, { monsters: {} }); + assert.strictEqual(Object.keys(tun3.monsters).length, 0, "bestiaire d'origine restauré"); } // Tuning admin : puissance des potions/parchemins et bonus d'équipement { const w = makeWorld({ monsterTarget: 0, itemTarget: 0 }); - mp.adminSetTuning(w, { + const tun = mp.adminSetTuning(w, { potions: { guerison: [9, 9], nimporte: [1, 2] }, scrolls: { runeExplosive: [7, 3] }, // inversé : doit devenir [3, 7] gear: { "arme/Gourdin": { deg: 12 }, "arme/Excalibur": { deg: 99 } }, }); - assert(!w.tuning.potions.nimporte, "potion inconnue ignorée"); - assert.deepStrictEqual(w.tuning.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre"); - assert(!w.tuning.gear["arme/Excalibur"], "objet inconnu ignoré"); + assert(!tun.potions.nimporte, "potion inconnue ignorée"); + assert.deepStrictEqual(tun.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre"); + assert(!tun.gear["arme/Excalibur"], "objet inconnu ignoré"); // la puissance tirée respecte l'override for (let i = 0; i < 100; i++) { - const it = mp.tunedRandomPotion(w); + const it = mp.tunedRandomPotion(); if (it.potionId === "guerison") assert.strictEqual(it.power, 9, "Guérison forcée à X=9"); } // l'équipement tuné garde ses autres mods const gearLib = require("../js/gear.js"); - const club = mp.applyGearTuning(w, gearLib.gearItemByName("arme", "Gourdin")); + const club = mp.applyGearTuning(gearLib.gearItemByName("arme", "Gourdin")); assert.strictEqual(club.mods.deg, 12, "DEG du Gourdin tuné"); assert.strictEqual(club.mods.att, 2, "ATT du Gourdin inchangée"); // les défauts pour l'admin sont complets @@ -294,12 +295,12 @@ function makeWorld(over = {}) { // Tuning admin : saveurs magiques (attMag/degMag/armMag) et suppression de troll { const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 }); - mp.adminSetTuning(w, { + const tunMag = mp.adminSetTuning(w, { monsters: { "Gobelin": { attMag: 6, degMag: 4 } }, gear: { "arme/Bâton de mage": { attMag: 4, armMag: 2 } }, }); - assert.strictEqual(w.tuning.monsters.Gobelin.attMag, 6, "ATT mag du Gobelin tunée"); - assert.strictEqual(w.tuning.gear["arme/Bâton de mage"].armMag, 2, "Armure mag du bâton tunée"); + 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); @@ -307,7 +308,7 @@ function makeWorld(over = {}) { } 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(w, gearLib.gearItemByName("arme", "Bâton de mage")); + const baton = mp.applyGearTuning(gearLib.gearItemByName("arme", "Bâton de mage")); assert.strictEqual(baton.mods.attMag, 4, "ATT mag du bâton droppé"); assert.strictEqual(baton.mods.mmPct, 15, "MM % vanilla conservé"); // suppression admin d'un troll @@ -318,6 +319,39 @@ function makeWorld(over = {}) { assert(!mp.newTroll(w, "Banni", "Skrim").error, "son nom redevient libre"); } +// Base de référence : les retouches survivent au redémarrage (seed non destructif) +{ + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + const db = require("../db.js"); + const file = path.join(os.tmpdir(), "mc-test-ref.db"); + for (const f of [file, file + "-wal", file + "-shm"]) { try { fs.rmSync(f); } catch {} } + db.init(file); + db.setGear("arme", "Gourdin", { deg: 42 }); + db.setMonster("Sorcière", { attMag: 7, degMag: 5 }); + db.init(file); // « redémarrage » : INSERT OR IGNORE ne doit rien écraser + assert.strictEqual(db.gearRow("arme", "Gourdin").deg, 42, "retouche d'équipement conservée"); + const sorciere = db.monsters().find(m => m.name === "Sorcière"); + assert.strictEqual(sorciere.attMag, 7, "retouche du bestiaire conservée"); + assert.strictEqual(db.gearRow("arme", "Torche").vue, 1, "valeurs vanilla seedées"); + db.init(":memory:"); // base propre pour la suite des tests +} + +// Migration < 2.3.0 : le tuning de world.json est déversé une fois dans la base +{ + const os = require("os"); + const path = require("path"); + const w = makeWorld(); + w.tuning = { monsters: { "Gobelin": { att: 42 } }, gear: {}, potions: {}, scrolls: {} }; + const file = path.join(os.tmpdir(), "mc-world-migration.json"); + mp.saveWorld(w, file); + const loaded = mp.loadWorld(file); + assert(loaded && !loaded.tuning, "tuning retiré du monde migré"); + assert.strictEqual(mp.currentTuning().monsters.Gobelin.att, 42, "écart migré en base"); + mp.adminSetTuning(loaded, { monsters: {} }); // nettoyage +} + // Persistance : save + load { const os = require("os");