2.3.0 : valeurs de référence en base SQLite native (node:sqlite) — bestiaire, équipement, potions, parchemins éditables à chaud

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 16:12:22 +02:00
co-authored by Claude Fable 5
parent a1fe09e6b4
commit 031d64830c
8 changed files with 359 additions and 102 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
FROM node:22-alpine FROM node:24-alpine
WORKDIR /app 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 css ./css
COPY js ./js COPY js ./js
COPY img ./img COPY img ./img
+19 -2
View File
@@ -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 » - **Tuning des potions & parchemins** : fourchette de puissance « niveau X »
(min/max) tirée à chaque drop, par trésor. (min/max) tirée à chaque drop, par trésor.
Les cases modifiées sont surlignées ; seuls les écarts au vanilla sont stockés Les cases modifiées sont surlignées et chaque catégorie se remet d'origine d'un
(`world.tuning`), et chaque catégorie se remet d'origine d'un clic. 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 ### API multijoueur
@@ -227,6 +234,16 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## 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, - **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** 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 (`attMag`, `degMag` — un monstre qui en a alterne au hasard entre attaque
+5 -2
View File
@@ -89,8 +89,11 @@
<div class="admin-card" style="margin-top:16px"> <div class="admin-card" style="margin-top:16px">
<h2>⚔️ Tuning de l'équipement</h2> <h2>⚔️ Tuning de l'équipement</h2>
<p><small>Bonus/malus de chaque objet (physiques, fixes sur les jets ; RM/MM en %). <p><small>Bonus/malus de chaque objet (fixes sur les jets ; RM/MM en %).
S'applique aux prochains drops du monde partagé.</small></p> S'applique aux prochains drops du monde partagé. Ces valeurs vivent dans la
base SQLite <code>data/mountycrawl.db</code> (tables <code>gear</code>,
<code>monsters</code>, <code>potions</code>, <code>scrolls</code>) — tu peux
aussi les éditer avec n'importe quel outil SQLite, c'est relu à chaque drop.</small></p>
<div id="tune-gear"></div> <div id="tune-gear"></div>
<button id="tune-gear-save" class="big-btn">💾 Appliquer l'équipement</button> <button id="tune-gear-save" class="big-btn">💾 Appliquer l'équipement</button>
<button id="tune-gear-reset" class="menu-btn">↩️ Tout remettre d'origine</button> <button id="tune-gear-reset" class="menu-btn">↩️ Tout remettre d'origine</button>
+183
View File
@@ -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,
};
+1 -1
View File
@@ -4,7 +4,7 @@
"use strict"; "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. */ /* 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;
+89 -76
View File
@@ -24,6 +24,7 @@ const g = require("./js/game.js");
const p = require("./js/potions.js"); const p = require("./js/potions.js");
const sc = require("./js/scrolls.js"); const sc = require("./js/scrolls.js");
const gearLib = require("./js/gear.js"); const gearLib = require("./js/gear.js");
const db = require("./db.js");
/* ---------- Configuration par défaut (tout est réglable via l'admin) ---------- */ /* ---------- 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 ---------- /* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ----------
* world.tuning ne stocke que les écarts à la valeur de base (vanilla). Il * Les valeurs de référence vivent dans la base SQLite (db.js) ; chaque
* s'applique aux nouveaux spawns/drops du monde partagé — le solo reste vanilla. */ * 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 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 GEAR_TUNE_BOUNDS = [-100, 100];
const POWER_BOUNDS = [0, 200]; 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]) { function randRange([min, max]) {
const lo = Math.min(min, max), hi = Math.max(min, max); const lo = Math.min(min, max), hi = Math.max(min, max);
return lo + Math.floor(Math.random() * (hi - lo + 1)); return lo + Math.floor(Math.random() * (hi - lo + 1));
} }
/* Types de monstres avec le tuning admin appliqué (avant gabarit d'âge). */ const sameRange = (a, b) => a && b && Math.min(...a) === Math.min(...b) && Math.max(...a) === Math.max(...b);
function tunedMonsterTypes(world) {
const tune = (world.tuning && world.tuning.monsters) || {}; /* Types de monstres tels qu'en base (avant gabarit d'âge). */
return [...g.MONSTER_TYPES, g.BOSS].map(t => tune[t.name] ? { ...t, ...tune[t.name] } : t); function tunedMonsterTypes() {
return db.monsters();
} }
function tunedRandomPotion(world) { function tunedRandomPotion() {
const item = p.makeRandomPotion(); const item = p.makeRandomPotion();
const range = (world.tuning && world.tuning.potions || {})[item.potionId]; const range = db.treasureRange("potions", item.potionId);
return range ? p.makePotionItem(item.potionId, randRange(range)) : item; // 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 item = sc.makeRandomScroll();
const range = (world.tuning && world.tuning.scrolls || {})[item.scrollId]; const range = db.treasureRange("scrolls", item.scrollId);
return range ? sc.makeScrollItem(item.scrollId, randRange(range)) : item; 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; if (!item || item.kind !== "gear") return item;
const tune = (world.tuning && world.tuning.gear || {})[`${item.slot}/${item.name}`]; const row = db.gearRow(item.slot, item.name);
if (tune) item.mods = { ...item.mods, ...tune }; if (row) {
item.mods = Object.fromEntries(db.GEAR_KEYS.map(k => [k, row[k] || 0]).filter(([, v]) => v));
}
return item; return item;
} }
@@ -141,7 +132,7 @@ function spawnMonster(world, now = Date.now()) {
// même logique que makeMonster (pool par profondeur + gabarit d'âge), // même logique que makeMonster (pool par profondeur + gabarit d'âge),
// mais sur les types tunés par l'admin // mais sur les types tunés par l'admin
const depth = world.config.worldDepth; 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 pool = types.filter(t => t.level <= depth + 1 && t.level >= Math.max(1, depth - 2));
const list = pool.length ? pool : types; const list = pool.length ? pool : types;
const type = list[Math.floor(Math.random() * list.length)]; const type = list[Math.floor(Math.random() * list.length)];
@@ -160,9 +151,9 @@ function spawnItem(world) {
if (!pos) return null; if (!pos) return null;
const r = Math.random(); const r = Math.random();
const depth = world.config.worldDepth; const depth = world.config.worldDepth;
const item = r < 0.34 ? tunedRandomPotion(world) const item = r < 0.34 ? tunedRandomPotion()
: r < 0.48 ? tunedRandomScroll(world) : r < 0.48 ? tunedRandomScroll()
: r < 0.78 ? applyGearTuning(world, gearLib.makeRandomGear(depth)) : r < 0.78 ? applyGearTuning(gearLib.makeRandomGear(depth))
: { 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 });
@@ -173,7 +164,6 @@ function createWorld(config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config }; const cfg = { ...DEFAULT_CONFIG, ...config };
const world = { const world = {
config: cfg, config: cfg,
tuning: emptyTuning(),
grid: g.generateCavern(cfg.mapW, cfg.mapH), grid: g.generateCavern(cfg.mapW, cfg.mapH),
trolls: {}, trolls: {},
monsters: [], monsters: [],
@@ -434,8 +424,8 @@ function killMonsterMP(world, t, m) {
worldLog(world, `⚔️ ${t.name} a terrassé ${m.emoji} ${m.name} !`, "combat"); 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)) { if (Math.random() < 0.4 && !world.items.some(i => i.x === m.x && i.y === m.y)) {
const lr = Math.random(); const lr = Math.random();
const drop = lr < 0.4 ? { ...tunedRandomPotion(world), x: m.x, y: m.y } const drop = lr < 0.4 ? { ...tunedRandomPotion(), x: m.x, y: m.y }
: lr < 0.65 ? { ...tunedRandomScroll(world), 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 }; : { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y };
world.items.push(drop); world.items.push(drop);
privLog(t, `${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info"); privLog(t, `${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info");
@@ -811,7 +801,7 @@ function adminOverview(world, now = Date.now()) {
return { return {
config: world.config, config: world.config,
bounds: CONFIG_BOUNDS, bounds: CONFIG_BOUNDS,
tuning: world.tuning || emptyTuning(), tuning: currentTuning(),
defaults: adminDefaults(), defaults: adminDefaults(),
uptime: now - world.createdAt, uptime: now - world.createdAt,
trolls: Object.values(world.trolls).map(t => ({ 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. */ /* Valeurs de base (vanilla) pour construire les formulaires de tuning admin. */
function adminDefaults() { function adminDefaults() {
return { return {
monsters: [...g.MONSTER_TYPES, g.BOSS].map(t => ({ monsters: db.vanillaMonsters(),
name: t.name, emoji: t.emoji, boss: !!t.boss,
...Object.fromEntries(MONSTER_TUNE_KEYS.map(k => [k, t[k] || 0])),
})),
monsterKeys: MONSTER_TUNE_KEYS, monsterKeys: MONSTER_TUNE_KEYS,
gear: Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(def => ({ gear: db.vanillaGear().map(it => ({
slot, name: def.name, emoji: def.emoji, twoHanded: !!def.twoHanded, slot: it.slot, name: it.name, emoji: it.emoji, twoHanded: it.twoHanded,
mods: Object.fromEntries(GEAR_TUNE_KEYS.map(k => [k, def.mods[k] || 0])), mods: Object.fromEntries(GEAR_TUNE_KEYS.map(k => [k, it[k] || 0])),
}))), })),
gearKeys: GEAR_TUNE_KEYS, gearKeys: GEAR_TUNE_KEYS,
potions: p.POTION_IDS.map(id => ({ potions: db.vanillaTreasures("potions").map(t => ({ id: t.id, name: t.name, emoji: t.emoji, min: t.powerMin, max: t.powerMax })),
id, name: p.POTION_DEFS[id].name, emoji: p.POTION_DEFS[id].emoji, scrolls: db.vanillaTreasures("scrolls").map(t => ({ id: t.id, name: t.name, emoji: t.emoji, min: t.powerMin, max: t.powerMax })),
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],
})),
}; };
} }
/* É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))); 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 /* Applique un patch de tuning : chaque catégorie fournie est remise au vanilla
* (l'admin envoie l'ensemble de ses écarts). Tout est validé et borné. */ * puis les écarts envoyés sont écrits en base (validés et bornés). */
function adminSetTuning(world, patch) { function adminSetTuning(world, patch) {
world.tuning = world.tuning || emptyTuning();
const known = { const known = {
monsters: new Set([...g.MONSTER_TYPES, g.BOSS].map(t => t.name)), monsters: new Set(db.vanillaMonsters().map(t => t.name)),
gear: new Set(Object.entries(gearLib.GEAR).flatMap(([slot, list]) => list.map(d => `${slot}/${d.name}`))), gear: new Set(db.vanillaGear().map(i => `${i.slot}/${i.name}`)),
potions: new Set(p.POTION_IDS), potions: new Set(p.POTION_IDS),
scrolls: new Set(sc.SCROLL_IDS), scrolls: new Set(sc.SCROLL_IDS),
}; };
if (patch.monsters && typeof patch.monsters === "object") { if (patch.monsters && typeof patch.monsters === "object") {
const out = {}; db.resetCategory("monsters");
for (const [name, vals] of Object.entries(patch.monsters)) { for (const [name, vals] of Object.entries(patch.monsters)) {
if (!known.monsters.has(name) || typeof vals !== "object") continue; if (!known.monsters.has(name) || typeof vals !== "object") continue;
const entry = {}; const entry = {};
@@ -899,12 +908,11 @@ function adminSetTuning(world, patch) {
const v = Number(vals[k]); const v = Number(vals[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, MONSTER_TUNE_BOUNDS[k][0], MONSTER_TUNE_BOUNDS[k][1]); 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") { if (patch.gear && typeof patch.gear === "object") {
const out = {}; db.resetCategory("gear");
for (const [key, mods] of Object.entries(patch.gear)) { for (const [key, mods] of Object.entries(patch.gear)) {
if (!known.gear.has(key) || typeof mods !== "object") continue; if (!known.gear.has(key) || typeof mods !== "object") continue;
const entry = {}; const entry = {};
@@ -912,25 +920,24 @@ function adminSetTuning(world, patch) {
const v = Number(mods[k]); const v = Number(mods[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, GEAR_TUNE_BOUNDS[0], GEAR_TUNE_BOUNDS[1]); 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"]) { for (const cat of ["potions", "scrolls"]) {
if (!patch[cat] || typeof patch[cat] !== "object") continue; if (!patch[cat] || typeof patch[cat] !== "object") continue;
const out = {}; db.resetCategory(cat);
for (const [id, range] of Object.entries(patch[cat])) { for (const [id, range] of Object.entries(patch[cat])) {
if (!known[cat].has(id) || !Array.isArray(range)) continue; if (!known[cat].has(id) || !Array.isArray(range)) continue;
const lo = Number(range[0]), hi = Number(range[1]); const lo = Number(range[0]), hi = Number(range[1]);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; 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.min(lo, hi), POWER_BOUNDS[0], POWER_BOUNDS[1]),
clampInt(Math.max(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). */ /* Supprime définitivement un troll du monde (admin). */
@@ -975,7 +982,13 @@ function loadWorld(file) {
const world = JSON.parse(fs.readFileSync(file, "utf8")); const world = JSON.parse(fs.readFileSync(file, "utf8"));
if (!world || !world.grid || !world.trolls) return null; if (!world || !world.grid || !world.trolls) return null;
world.config = { ...DEFAULT_CONFIG, ...world.config }; 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; return world;
} catch { return null; } } catch { return null; }
} }
@@ -984,7 +997,7 @@ module.exports = {
DEFAULT_CONFIG, CONFIG_BOUNDS, DEFAULT_CONFIG, CONFIG_BOUNDS,
createWorld, tick, newTroll, authTroll, login, action, stateFor, createWorld, tick, newTroll, authTroll, login, action, stateFor,
adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll, adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll,
saveWorld, loadWorld, currentTuning, saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog, spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
tunedRandomPotion, tunedRandomScroll, applyGearTuning, tunedRandomPotion, tunedRandomScroll, applyGearTuning,
}; };
+10 -3
View File
@@ -1,6 +1,9 @@
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires /* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires
* + monde multijoueur persistant (mp.js). * + 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"; "use strict";
@@ -8,12 +11,15 @@ const http = require("http");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const mp = require("./mp.js"); const db = require("./db.js");
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const ROOT = __dirname; const ROOT = __dirname;
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json"; const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
const WORLD_FILE = process.env.WORLD_FILE || "/data/world.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_BODY = 100 * 1024; // 100 Ko par niveau, large
const MAX_LEVELS = 500; const MAX_LEVELS = 500;
@@ -121,6 +127,7 @@ let worldDirty = false;
let ADMIN_TOKEN = null; let ADMIN_TOKEN = null;
function initMP() { function initMP() {
db.init(DB_FILE); // avant loadWorld : la migration du tuning < 2.3.0 écrit dedans
WORLD = mp.loadWorld(WORLD_FILE) || mp.createWorld(); WORLD = mp.loadWorld(WORLD_FILE) || mp.createWorld();
// token admin : variable d'environnement, sinon généré et persisté à côté du monde // token admin : variable d'environnement, sinon généré et persisté à côté du monde
ADMIN_TOKEN = process.env.MP_ADMIN_TOKEN || null; ADMIN_TOKEN = process.env.MP_ADMIN_TOKEN || null;
@@ -332,7 +339,7 @@ const server = http.createServer((req, res) => {
if (require.main === module) { if (require.main === module) {
initMP(); 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 }; module.exports = { validateLevel, MAP_W, MAP_H };
+50 -16
View File
@@ -241,7 +241,8 @@ function makeWorld(over = {}) {
// Tuning admin : bestiaire appliqué aux nouveaux spawns // Tuning admin : bestiaire appliqué aux nouveaux spawns
{ {
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 }); 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 // spawn forcé jusqu'à obtenir un Gobelin
let gob = null; let gob = null;
for (let i = 0; i < 300 && !gob; i++) { 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([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)"); assert.strictEqual(gob.armorMag, 3, "armure magique tunée (gabarit ne la multiplie pas)");
// bornage et noms inconnus ignorés // bornage et noms inconnus ignorés
mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } }); const tun2 = mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } });
assert(!w.tuning.monsters.Dragon, "type inconnu ignoré"); assert(!tun2.monsters.Dragon, "type inconnu ignoré");
assert.strictEqual(w.tuning.monsters.Gobelin.att, 99, "ATT bornée à 99"); assert.strictEqual(tun2.monsters.Gobelin.att, 99, "ATT bornée à 99");
// retour au vanilla // retour au vanilla
mp.adminSetTuning(w, { monsters: {} }); const tun3 = mp.adminSetTuning(w, { monsters: {} });
assert.strictEqual(Object.keys(w.tuning.monsters).length, 0, "bestiaire d'origine restauré"); assert.strictEqual(Object.keys(tun3.monsters).length, 0, "bestiaire d'origine restauré");
} }
// 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 });
mp.adminSetTuning(w, { const tun = mp.adminSetTuning(w, {
potions: { guerison: [9, 9], nimporte: [1, 2] }, potions: { guerison: [9, 9], nimporte: [1, 2] },
scrolls: { runeExplosive: [7, 3] }, // inversé : doit devenir [3, 7] scrolls: { runeExplosive: [7, 3] }, // inversé : doit devenir [3, 7]
gear: { "arme/Gourdin": { deg: 12 }, "arme/Excalibur": { deg: 99 } }, gear: { "arme/Gourdin": { deg: 12 }, "arme/Excalibur": { deg: 99 } },
}); });
assert(!w.tuning.potions.nimporte, "potion inconnue ignorée"); assert(!tun.potions.nimporte, "potion inconnue ignorée");
assert.deepStrictEqual(w.tuning.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre"); assert.deepStrictEqual(tun.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre");
assert(!w.tuning.gear["arme/Excalibur"], "objet inconnu ignoré"); assert(!tun.gear["arme/Excalibur"], "objet inconnu ignoré");
// la puissance tirée respecte l'override // la puissance tirée respecte l'override
for (let i = 0; i < 100; i++) { 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"); if (it.potionId === "guerison") assert.strictEqual(it.power, 9, "Guérison forcée à X=9");
} }
// l'équipement tuné garde ses autres mods // l'équipement tuné garde ses autres mods
const gearLib = require("../js/gear.js"); 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.deg, 12, "DEG du Gourdin tuné");
assert.strictEqual(club.mods.att, 2, "ATT du Gourdin inchangée"); assert.strictEqual(club.mods.att, 2, "ATT du Gourdin inchangée");
// les défauts pour l'admin sont complets // 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 // Tuning admin : saveurs magiques (attMag/degMag/armMag) et suppression de troll
{ {
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 }); const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
mp.adminSetTuning(w, { const tunMag = mp.adminSetTuning(w, {
monsters: { "Gobelin": { attMag: 6, degMag: 4 } }, monsters: { "Gobelin": { attMag: 6, degMag: 4 } },
gear: { "arme/Bâton de mage": { attMag: 4, armMag: 2 } }, 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(tunMag.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.gear["arme/Bâton de mage"].armMag, 2, "Armure mag du bâton tunée");
let gob = null; let gob = null;
for (let i = 0; i < 300 && !gob; i++) { for (let i = 0; i < 300 && !gob; i++) {
const m = mp.spawnMonster(w); 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"); assert(gob && gob.attMag > 0 && gob.degMag > 0, "le Gobelin spawné a une attaque magique");
const gearLib = require("../js/gear.js"); 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.attMag, 4, "ATT mag du bâton droppé");
assert.strictEqual(baton.mods.mmPct, 15, "MM % vanilla conservé"); assert.strictEqual(baton.mods.mmPct, 15, "MM % vanilla conservé");
// suppression admin d'un troll // suppression admin d'un troll
@@ -318,6 +319,39 @@ function makeWorld(over = {}) {
assert(!mp.newTroll(w, "Banni", "Skrim").error, "son nom redevient libre"); 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 // Persistance : save + load
{ {
const os = require("os"); const os = require("os");