Files
mountycrawl/mp.js
T

1004 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Moteur multijoueur MountyCrawl — monde partagé persistant, autoritaire côté
* serveur. Node pur, réutilise les règles de js/game.js (dés, combat, races,
* monstres, équipement, potions, parchemins).
*
* Principes :
* - Le monde vit en mémoire et tique en continu (tick() appelée chaque seconde
* par server.js) : chaque monstre a sa propre DLA (période en ms, tirée dans
* une fourchette paramétrable) et agit à son échéance, joueurs connectés ou non.
* - Les monstres ne s'attaquent jamais entre eux.
* - Chaque troll a 6 PA, rechargés à sa DLA personnelle (période paramétrable) ;
* les actions (déplacement, attaque, talents…) coûtent des PA comme en solo.
* - À MountyHall on ne meurt jamais vraiment : un troll terrassé réapparaît
* après un délai paramétrable, PV pleins, en conservant tout son avoir.
* - La configuration (world.config) est modifiable à chaud via l'API admin.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
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) ---------- */
const DEFAULT_CONFIG = {
mapW: 40, mapH: 28, // taille du monde partagé
worldDepth: 2, // « profondeur » : pools de monstres/objets (1-5)
monsterDlaMinSec: 90, // DLA des monstres : période tirée dans [min, max]
monsterDlaMaxSec: 360,
trollDlaSec: 180, // recharge des PA des trolls
pollSec: 5, // intervalle de rafraîchissement conseillé au client
monsterTarget: 24, // population de monstres visée
repopSec: 120, // période de vérification du repeuplement
itemTarget: 16, // trésors au sol visés
deathRespawnSec: 90, // délai de réapparition d'un troll terrassé
maxTrolls: 40,
logSize: 150,
};
const CONFIG_BOUNDS = {
mapW: [24, 80], mapH: [16, 60], worldDepth: [1, 5],
monsterDlaMinSec: [5, 3600], monsterDlaMaxSec: [5, 7200],
trollDlaSec: [5, 3600], pollSec: [1, 60],
monsterTarget: [0, 120], repopSec: [10, 3600],
itemTarget: [0, 120], deathRespawnSec: [0, 3600],
maxTrolls: [1, 200], logSize: [20, 500],
};
/* ---------- Tuning : valeurs du bestiaire, de l'équipement et des trésors ----------
* 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 = 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 = db.GEAR_KEYS;
const GEAR_TUNE_BOUNDS = [-100, 100];
const POWER_BOUNDS = [0, 200];
function randRange([min, max]) {
const lo = Math.min(min, max), hi = Math.max(min, max);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}
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() {
const item = p.makeRandomPotion();
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() {
const item = sc.makeRandomScroll();
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(item) {
if (!item || item.kind !== "gear") return item;
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;
}
/* ---------- Création du monde ---------- */
function randomFloor(world, taken = null) {
const { grid, config } = world;
for (let tries = 0; tries < 800; tries++) {
const x = 1 + Math.floor(Math.random() * (config.mapW - 2));
const y = 1 + Math.floor(Math.random() * (config.mapH - 2));
if (grid[y][x] !== g.T_FLOOR) continue;
if (taken && taken.has(y * config.mapW + x)) continue;
if (occupied(world, x, y)) continue;
if (taken) taken.add(y * config.mapW + x);
return { x, y };
}
return null;
}
function occupied(world, x, y) {
return world.monsters.some(m => m.x === x && m.y === y)
|| Object.values(world.trolls).some(t => !t.dead && t.x === x && t.y === y);
}
function monsterDlaMs(config) {
const min = Math.min(config.monsterDlaMinSec, config.monsterDlaMaxSec);
const max = Math.max(config.monsterDlaMinSec, config.monsterDlaMaxSec);
return (min + Math.random() * (max - min)) * 1000;
}
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
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);
m.id = world.nextId++;
m.dlaMs = Math.round(monsterDlaMs(world.config));
m.nextDla = now + m.dlaMs;
world.monsters.push(m);
return m;
}
function spawnItem(world) {
const pos = randomFloor(world);
if (!pos) return null;
const r = Math.random();
const depth = world.config.worldDepth;
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 });
return item;
}
function createWorld(config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const world = {
config: cfg,
grid: g.generateCavern(cfg.mapW, cfg.mapH),
trolls: {},
monsters: [],
items: [],
log: [],
nextId: 1,
nextRepop: 0,
createdAt: Date.now(),
};
for (let i = 0; i < cfg.monsterTarget; i++) spawnMonster(world);
for (let i = 0; i < cfg.itemTarget; i++) spawnItem(world);
worldLog(world, "🌋 Le Monde Souterrain partagé s'éveille…");
return world;
}
/* ---------- Journal ---------- */
function worldLog(world, msg, cls = "info") {
world.log.push({ t: Date.now(), msg, cls });
while (world.log.length > world.config.logSize) world.log.shift();
}
function privLog(troll, msg, cls = "info") {
troll.privLog = troll.privLog || [];
troll.privLog.push({ t: Date.now(), msg, cls });
while (troll.privLog.length > 60) troll.privLog.shift();
}
/* ---------- Trolls ---------- */
function hashPass(salt, password) {
return crypto.createHash("sha256").update(`${salt}:${password}`).digest("hex");
}
/* Le troll vit côté serveur : le mot de passe (optionnel mais conseillé)
* permet de le retrouver depuis n'importe quel appareil via login(). */
function newTroll(world, name, race, password = "", now = Date.now()) {
if (!g.RACES[race]) return { error: "race inconnue" };
name = String(name || "").trim().slice(0, 20) || "Trõllinet";
password = String(password || "");
const taken = Object.values(world.trolls).some(t => t.name.toLowerCase() === name.toLowerCase());
if (taken) return { error: "ce nom de troll est déjà pris — choisis-en un autre ou retrouve ton troll avec ton mot de passe" };
const living = Object.keys(world.trolls).length;
if (living >= world.config.maxTrolls) return { error: "le Hall est plein (max atteint)" };
const pos = randomFloor(world);
if (!pos) return { error: "pas de place dans la caverne" };
const salt = crypto.randomBytes(8).toString("hex");
const s = g.RACES[race].stats;
const troll = {
id: crypto.randomBytes(6).toString("hex"),
secret: crypto.randomBytes(16).toString("hex"),
salt, passHash: password ? hashPass(salt, password) : null,
name, race, x: pos.x, y: pos.y,
att: s.att, esq: s.esq, deg: s.deg, reg: s.reg,
pv: s.pvMax, pvMax: s.pvMax, vue: s.vue,
degBonus: 0, armor: 0, armorDice: 0,
bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 },
comp: { pct: g.START_COMP_PCT }, sort: { pct: g.START_SORT_PCT },
fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false,
equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null },
gearMods: null,
pa: g.PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
bag: [], camo: false, kills: 0, dla: 1, tour: 1,
potionEffects: [], blockCamoTurns: 0,
dead: false, respawnAt: 0,
nextDla: now + world.config.trollDlaSec * 1000,
lastSeen: now,
privLog: [],
};
world.trolls[troll.id] = troll;
worldLog(world, `${g.RACES[race].emoji} ${name} le ${race} entre dans le Monde Souterrain !`, "good");
return { troll };
}
function authTroll(world, id, secret) {
const t = world.trolls[id];
if (!t || t.secret !== secret) return null;
t.lastSeen = Date.now();
return t;
}
/* Retrouver son troll par nom + mot de passe (multi-appareils). */
function login(world, name, password) {
name = String(name || "").trim().toLowerCase();
password = String(password || "");
const t = Object.values(world.trolls).find(t => t.name.toLowerCase() === name);
if (!t) return { error: "aucun troll de ce nom" };
if (!t.passHash) return { error: "ce troll n'a pas de mot de passe — il n'est accessible que depuis son navigateur d'origine" };
if (hashPass(t.salt, password) !== t.passHash) return { error: "mot de passe incorrect" };
t.lastSeen = Date.now();
return { troll: t };
}
const chebyshev = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
function effTrollMP(t) {
return p.effTroll(t);
}
/* ---------- DLA et tick du monde ---------- */
/* DLA d'un troll : PA rechargés, régénération, effets magiques décomptés. */
function trollDla(world, t, now) {
t.dla++;
const dlaBonusPA = p.tickPotionTurns(t, () => {});
const te = effTrollMP(t);
if (!t.dead && t.pv < t.pvMax && t.pv > 0) {
const heal = Math.max(0, g.rollDice(te.reg, 3).total + (te.regFlat || 0));
t.pv = Math.min(t.pvMax, t.pv + heal);
if (heal) privLog(t, `💤 DLA n°${t.dla} : tu régénères ${heal} PV.`, "info");
}
t.pa = Math.max(1, Math.min(g.PA_PER_TURN + 3, g.PA_PER_TURN + dlaBonusPA));
t.compUsed = false;
t.compPXTurn = false;
t.sortPXTurn = false;
t.fatigue = Math.floor(t.fatigue / 1.25);
t.nextDla = now + world.config.trollDlaSec * 1000;
}
function killTrollMP(world, t, killer, now) {
t.dead = true;
t.camo = false;
t.respawnAt = now + world.config.deathRespawnSec * 1000;
privLog(t, `☠️ ${killer} t'a terrassé… Les Dieux Trõlls te ramèneront bientôt.`, "bad");
worldLog(world, `☠️ ${t.name} a été terrassé par ${killer} !`, "bad");
}
function respawnTroll(world, t) {
const pos = randomFloor(world);
if (!pos) return;
t.dead = false;
t.x = pos.x; t.y = pos.y;
t.pv = t.pvMax;
t.pa = g.PA_PER_TURN;
t.potionEffects = [];
t.blockCamoTurns = 0;
privLog(t, "✨ Les Dieux Trõlls te ramènent dans la caverne, requinqué.", "good");
worldLog(world, `✨ ${t.name} est de retour dans le Monde Souterrain.`, "info");
}
/* Activation d'un monstre à sa DLA : attaque un troll adjacent, sinon se
* rapproche du troll visible le plus proche, sinon erre. Jamais de monstre
* contre monstre. */
function monsterAct(world, m, now) {
if (m.skip > 0) {
m.skip--;
return;
}
const targets = Object.values(world.trolls).filter(t => !t.dead && !t.camo);
const em = monsterEff(m);
let best = null, bestDist = Infinity;
for (const t of targets) {
const d = chebyshev(m, t);
if (d < bestDist) { best = t; bestDist = d; }
}
if (best && bestDist <= 1) {
const te = effTrollMP(best);
// un monstre doté d'une attaque magique (attMag/degMag) alterne au hasard
const magic = em.attMag > 0 && em.degMag > 0 && Math.random() < 0.5;
const r = g.resolveAttack(magic ? { ...em, att: em.attMag, deg: em.degMag } : em, te, { magic });
if (r.hit) {
best.pv -= r.damage;
privLog(best, `${m.emoji} ${m.name} t'attaque${magic ? " (magie)" : ""} : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts${r.armorReduction ? ` (armure${magic ? " magique" : ""} ${r.armorReduction})` : ""} !`, "bad");
if (best.pv <= 0) killTrollMP(world, best, m.name, now);
} else {
privLog(best, `${m.emoji} ${m.name} t'attaque${magic ? " (magie)" : ""}… et tu esquives !`, "combat");
}
} else if (best && bestDist <= em.vue) {
stepTowardMP(world, m, best);
} else if (!m.static && Math.random() < 0.6) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const [dx, dy] = dirs[Math.floor(Math.random() * 4)];
moveMonsterMP(world, m, m.x + dx, m.y + dy);
}
// les statuts s'estompent à chaque activation
if (m.esqHalfTurns > 0) m.esqHalfTurns--;
if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0;
if (m.vueMalusTurns > 0 && --m.vueMalusTurns === 0) m.vueMalus = 0;
}
function monsterEff(m) {
return {
...m,
att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)),
esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq,
vue: Math.max(1, m.vue - (m.vueMalusTurns > 0 ? m.vueMalus || 0 : 0)),
armorPhys: m.armor || 0,
armorMag: m.armorMag || 0,
};
}
function stepTowardMP(world, m, t) {
if (m.static) return;
const dx = Math.sign(t.x - m.x), dy = Math.sign(t.y - m.y);
if (dx !== 0 && moveMonsterMP(world, m, m.x + dx, m.y)) return;
if (dy !== 0 && moveMonsterMP(world, m, m.x, m.y + dy)) return;
if (dx !== 0 && dy !== 0) moveMonsterMP(world, m, m.x + dx, m.y + dy);
}
function moveMonsterMP(world, m, nx, ny) {
const { mapW, mapH } = world.config;
if (nx < 0 || ny < 0 || nx >= mapW || ny >= mapH) return false;
if (world.grid[ny][nx] === g.T_WALL) return false;
if (Object.values(world.trolls).some(t => !t.dead && t.x === nx && t.y === ny)) return false;
if (world.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false;
m.x = nx; m.y = ny;
return true;
}
/* Tick global : appelé régulièrement (1 s) par server.js. Retourne true si
* l'état a changé (pour la sauvegarde). */
function tick(world, now = Date.now()) {
let changed = false;
for (const m of world.monsters) {
if (m.nextDla <= now) {
monsterAct(world, m, now);
m.dlaMs = Math.round(monsterDlaMs(world.config)); // re-tirée : DLA vivante
m.nextDla = now + m.dlaMs;
changed = true;
}
}
for (const t of Object.values(world.trolls)) {
if (t.dead && t.respawnAt <= now) { respawnTroll(world, t); changed = true; }
if (!t.dead && t.nextDla <= now) { trollDla(world, t, now); changed = true; }
}
if (world.nextRepop <= now) {
world.nextRepop = now + world.config.repopSec * 1000;
while (world.monsters.length < world.config.monsterTarget) { spawnMonster(world, now); changed = true; }
while (world.items.length < world.config.itemTarget) { spawnItem(world); changed = true; }
}
return changed;
}
/* ---------- Actions des joueurs ---------- */
function spendPA(t, cost) {
if (t.pa < cost) return false;
t.pa -= cost;
return true;
}
function monsterById(world, id) {
return world.monsters.find(m => m.id === id);
}
function gainPXMP(t, n) {
if (!n) return;
t.pi += n;
t.totalPI += n;
}
function killMonsterMP(world, t, m) {
const lvl = g.levelFromTotalPI(t.totalPI);
const px = g.killPX(lvl, m.level);
if (px > 0) gainPXMP(t, px);
t.kills++;
privLog(t, `💀 ${m.name} est terrassé !${px > 0 ? ` +${px} PX.` : " Pas de PX (adversaire trop faible)."}`, "good");
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(), 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");
}
world.monsters = world.monsters.filter(x => x !== m);
}
/* Attaque de base d'un troll sur un monstre (3 PA). */
function actAttack(world, t, m) {
if (!spendPA(t, g.COSTS.attack)) return { error: "pas assez de PA" };
const r = g.resolveAttack(effTrollMP(t), monsterEff(m));
if (t.camo) { t.camo = false; privLog(t, "Ton attaque brise le camouflage.", "info"); }
if (r.hit) {
m.pv -= r.damage;
gainPXMP(t, 1);
privLog(t, `Tu attaques ${m.name} : ${r.attRoll} vs ${r.esqRoll}${r.critical ? "CRITIQUE ! " : ""}${r.damage} dégâts${r.armorReduction ? ` (armure ${r.armorReduction})` : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else {
privLog(t, `Tu attaques ${m.name} : ${r.attRoll} vs ${r.esqRoll} → esquivé !`, "combat");
}
return { ok: true };
}
/* Talent (compétence/sortilège) : jet de maîtrise mutualisé avec le solo. */
function tryTalentMP(t, talent, cap, cost) {
if (!spendPA(t, cost)) return { error: "pas assez de PA" };
const before = talent.pct;
const effPct = p.talentPctWithPotions(t, talent, cap);
const r = g.masteryRoll(talent, cap, effPct);
talent.tries = (talent.tries || 0) + 1;
if (r.success) talent.successes = (talent.successes || 0) + 1;
talent.lastUse = Date.now();
if (!r.success) {
t.pa += Math.floor(cost / 2);
privLog(t, `Échec du talent (jet ${r.roll} > ${effPct} %). Moitié des PA remboursée.${talent.pct > before ? ` Maîtrise → ${talent.pct} %.` : ""}`, "combat");
return { failed: true };
}
if (talent.pct > before) privLog(t, `Maîtrise +${talent.pct - before} % → ${talent.pct} %.`, "good");
return { ok: true };
}
const trollMMMP = t => g.levelFromTotalPI(t.totalPI) + 1;
const monsterRMMP = m => Math.ceil(m.level / 2);
function resistMP(t, m) {
const te = effTrollMP(t);
const mm = trollMMMP(t) + Math.round(te.mmPct / 10);
const rm = monsterRMMP(m) + Math.round((te.rmPct || 0) / 10);
return !g.resolveSpell(mm, rm).success; // true = la cible résiste (effet réduit)
}
function actComp(world, t, m) {
const te = effTrollMP(t);
const comp = g.RACES[t.race].comp;
const pxOnce = () => { if (!t.compPXTurn) { gainPXMP(t, 1); t.compPXTurn = true; } };
if (t.race === "Skrim") {
if (t.compUsed) return { error: "Botte Secrète déjà utilisée cette DLA" };
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.compUsed = true;
const pseudo = { ...te, att: Math.max(1, Math.floor(te.att * 2 / 3)), deg: Math.max(1, Math.floor(te.att / 2)) };
const r = g.resolveAttack(pseudo, monsterEff(m));
if (r.hit) {
m.pv -= r.damage;
privLog(t, `🥋 Botte Secrète : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts !`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🥋 Botte Secrète esquivée (${r.attRoll} vs ${r.esqRoll}).`, "combat");
} else if (t.race === "Durakuir") {
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
const dice = Math.max(1, Math.floor(t.pvMax / 15));
const heal = g.rollDice(dice, 3).total;
t.pv = Math.min(t.pvMax, t.pv + heal);
privLog(t, `🥋 Régénération Accrue : +${heal} PV (${dice}D3).`, "good");
} else if (t.race === "Kastar") {
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
const cost = g.rollDice(1, 3).total + t.fatigue;
t.fatigue += 1;
t.pv -= cost;
t.pa = Math.min(g.PA_PER_TURN, t.pa + 4);
privLog(t, `🥋 Métabolisme : ${cost} PV, +4 PA (fatigue ${t.fatigue}).`, "good");
if (t.pv <= 0) killTrollMP(world, t, "son propre métabolisme", Date.now());
} else if (t.race === "Tomawak") {
if (t.blockCamoTurns > 0) return { error: "la Pàïntûré t'empêche de te camoufler" };
if (t.camo) return { error: "déjà camouflé" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.camo = true;
privLog(t, "🌫️ Camouflage : les monstres ne te voient plus.", "good");
} else if (t.race === "Darkling") {
if (t.compUsed) return { error: "Balayage déjà utilisé cette DLA" };
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.comp, 90, comp.cost);
if (!res.ok) return res;
pxOnce();
t.compUsed = true;
const destab = g.rollDice(te.att, 6).total;
const stab = g.rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total;
if (destab > stab) {
m.skip = (m.skip || 0) + 1;
privLog(t, `🥋 Balayage : ${destab} vs ${stab}${m.name} est à terre (perd sa prochaine DLA) !`, "good");
} else privLog(t, `🥋 Balayage : ${destab} vs ${stab}${m.name} reste stable.`, "combat");
}
return { ok: true };
}
function actSort(world, t, m) {
const te = effTrollMP(t);
const sort = g.RACES[t.race].sort;
const pxOnce = () => { if (!t.sortPXTurn) { gainPXMP(t, 1); t.sortPXTurn = true; } };
if (t.race === "Skrim") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const resisted = resistMP(t, m);
m.esqHalfTurns = (m.esqHalfTurns || 0) + (resisted ? 1 : 2);
if (!resisted) m.skip = (m.skip || 0) + 1;
privLog(t, resisted
? `🔮 Hypnotisme : ${m.name} résiste — esquive réduite 1 DLA seulement.`
: `🔮 Hypnotisme : ${m.name} est hébété ! Esquive ÷2 et DLA perdue.`, "good");
} else if (t.race === "Durakuir") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const armMag = Math.max(0, monsterEff(m).armorMag);
let dmg = Math.max(1, g.rollDice(te.deg, 3).total - armMag);
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
privLog(t, `🔮 Rafale Psychique : touche automatique, ${dmg} dégâts${resisted ? " (résisté)" : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else if (t.race === "Kastar") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const pseudo = { ...te, att: Math.max(1, Math.floor(te.deg * 2 / 3)) };
const r = g.resolveAttack(pseudo, monsterEff(m), { magic: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
const heal = Math.ceil(dmg / 2);
t.pv = Math.min(t.pvMax, t.pv + heal);
privLog(t, `🔮 Vampirisme : ${dmg} dégâts${resisted ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Vampirisme esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
} else if (t.race === "Tomawak") {
if (!m) return { error: "aucun monstre en vue" };
const dist = chebyshev(m, t);
if (dist > te.vue) return { error: "cible hors de portée (Vue)" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const proxBonus = Math.max(0, te.vue - dist);
const pseudo = { att: te.vue + proxBonus, deg: Math.max(1, Math.floor(te.vue / 2)), degBonus: 0 };
const r = g.resolveAttack(pseudo, monsterEff(m), { magic: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
privLog(t, `🔮 Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${resisted ? " (résisté)" : ""}.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Projectile Magique esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
if (t.camo && 1 + Math.floor(Math.random() * 100) > Math.floor(t.comp.pct * 0.25)) {
t.camo = false;
privLog(t, "Ton camouflage se dissipe dans l'éclat du projectile.", "info");
}
} else if (t.race === "Darkling") {
if (!m) return { error: "aucun monstre adjacent" };
const res = tryTalentMP(t, t.sort, 80, sort.cost);
if (!res.ok) return res;
pxOnce();
const pseudo = { ...te, deg: te.reg, degBonus: 0 };
const r = g.resolveAttack(pseudo, monsterEff(m), { ignoreArmor: true, magic: true });
if (r.hit) {
let dmg = r.damage;
const resisted = resistMP(t, m);
if (resisted) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
const necrose = resisted ? Math.max(1, Math.floor(te.reg / 2)) : te.reg;
m.attDownDice = (m.attDownDice || 0) + necrose;
m.attDownTurns = 2;
privLog(t, `🔮 Siphon des Âmes : ${dmg} dégâts${resisted ? " (résisté)" : ""}, nécrose ${necrose} dé(s) d'ATT.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else privLog(t, `🔮 Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
}
return { ok: true };
}
/* Point d'entrée des actions : { type, ... }. Retourne { ok } ou { error }. */
function action(world, t, act) {
if (t.dead) return { error: "tu es terrassé — attends ta réapparition" };
const { mapW, mapH } = world.config;
if (act.type === "move") {
const dx = Math.sign(act.dx || 0), dy = Math.sign(act.dy || 0);
if (!dx && !dy) return { error: "déplacement nul" };
const nx = t.x + dx, ny = t.y + dy;
if (nx < 0 || ny < 0 || nx >= mapW || ny >= mapH) return { error: "hors du monde" };
if (world.grid[ny][nx] === g.T_WALL) return { error: "un mur" };
const m = world.monsters.find(m => m.x === nx && m.y === ny);
if (m) return actAttack(world, t, m);
if (Object.values(world.trolls).some(o => o !== t && !o.dead && o.x === nx && o.y === ny))
return { error: "un troll occupe cette case" };
if (!spendPA(t, g.COSTS.move)) return { error: "pas assez de PA" };
t.x = nx; t.y = ny;
if (t.camo) {
const threshold = Math.floor(t.comp.pct * 0.75);
if (1 + Math.floor(Math.random() * 100) > threshold) {
t.camo = false;
privLog(t, "Un caillou roule sous ton pied : te voilà repéré !", "bad");
}
}
return { ok: true };
}
if (act.type === "attack") {
const m = monsterById(world, act.target);
if (!m || chebyshev(m, t) > 1) return { error: "aucun monstre adjacent à attaquer" };
return actAttack(world, t, m);
}
if (act.type === "comp" || act.type === "sort") {
let m = act.target != null ? monsterById(world, act.target) : null;
if (!m) {
// cible implicite : adjacent (ou plus proche visible pour le Projectile)
const te = effTrollMP(t);
const range = act.type === "sort" && t.race === "Tomawak" ? te.vue : 1;
let bestDist = Infinity;
for (const cand of world.monsters) {
const d = chebyshev(cand, t);
if (d <= range && d < bestDist) { m = cand; bestDist = d; }
}
}
return act.type === "comp" ? actComp(world, t, m) : actSort(world, t, m);
}
if (act.type === "pickup") {
const i = world.items.find(it => it.x === t.x && it.y === t.y);
if (!i) return { error: "rien à ramasser ici" };
if (!spendPA(t, g.COSTS.pickup)) return { error: "pas assez de PA" };
world.items = world.items.filter(x => x !== i);
if (i.kind === "gold") {
t.gold += i.gold;
privLog(t, `💰 Tu ramasses ${i.gold} Mountyzédons.`, "good");
} else {
const { x, y, ...item } = i;
t.bag.push(item);
privLog(t, `Tu ramasses ${i.emoji} ${i.name}.`, "good");
}
return { ok: true };
}
if (act.type === "use") {
const item = t.bag[act.idx];
if (!item) return { error: "objet introuvable dans le sac" };
if (item.kind === "potion") {
if (!spendPA(t, g.COSTS.potion)) return { error: "pas assez de PA" };
if (!p.drinkPotion(t, item, g.rollDice, (msg, cls) => privLog(t, msg, cls))) return { error: "fiole inconnue" };
t.bag.splice(act.idx, 1);
if (t.pv <= 0) killTrollMP(world, t, "une fiole douteuse", Date.now());
} else if (item.kind === "scroll") {
if (!spendPA(t, g.COSTS.scroll)) return { error: "pas assez de PA" };
const res = sc.readScroll(t, item, g.rollDice, (msg, cls) => privLog(t, msg, cls));
if (!res) return { error: "parchemin illisible" };
t.bag.splice(act.idx, 1);
if (res.zone) applyZoneMP(world, t, res.zone);
if (t.pv <= 0) killTrollMP(world, t, "un parchemin retors", Date.now());
} else if (item.kind === "gear") {
if (!spendPA(t, g.COSTS.equip)) return { error: "pas assez de PA" };
t.bag.splice(act.idx, 1);
g.equipGear(t, item);
privLog(t, `Tu t'équipes : ${item.emoji} ${item.name}.`, "good");
}
return { ok: true };
}
if (act.type === "train") {
const stat = act.stat;
if (!["att", "esq", "deg", "reg", "pv", "vue", "armor"].includes(stat)) return { error: "caractéristique inconnue" };
const cost = g.improveCost(stat, t.bought[stat], t.race);
if (t.pi < cost) return { error: `il te faut ${cost} PI` };
t.pi -= cost;
t.bought[stat] += 1;
if (stat === "pv") { t.pvMax += 10; t.pv += 10; }
else if (stat === "armor") t.armorDice += 1;
else t[stat] += 1;
privLog(t, `📈 Entraînement : ${stat} améliorée pour ${cost} PI.`, "good");
return { ok: true };
}
return { error: "action inconnue" };
}
/* Effet de zone d'un parchemin en multi : monstres à 3 cases ou moins. */
function applyZoneMP(world, t, zone) {
const targets = world.monsters.filter(m => chebyshev(m, t) <= sc.SCROLL_ZONE_RADIUS);
for (const m of targets) {
if (zone.type === "damage") {
const armMag = Math.max(0, m.armorMag || 0);
const dmg = Math.max(1, zone.total - armMag);
m.pv -= dmg;
privLog(t, `💥 ${m.name} est pris dans l'explosion : ${dmg} PV.`, "combat");
if (m.pv <= 0) killMonsterMP(world, t, m);
} else if (zone.type === "vue") {
m.vueMalus = Math.max(m.vueMalus || 0, zone.malus);
m.vueMalusTurns = Math.max(m.vueMalusTurns || 0, zone.turns);
privLog(t, `🌶️ ${m.name} est aveuglé : VUE ${zone.malus}.`, "good");
}
}
}
/* ---------- État envoyé au client ---------- */
function gridStrings(world) {
return world.grid.map(row => row.map(c => c === g.T_WALL ? "#" : ".").join(""));
}
/* Vue du troll : terrain complet (le brouillard est géré côté client),
* entités limitées à la portée de Vue. Les trolls camouflés sont invisibles
* pour les autres. */
function stateFor(world, t, now = Date.now()) {
const te = effTrollMP(t);
const vue = te.vue;
const seen = e => chebyshev(e, t) <= vue;
return {
now,
config: { pollSec: world.config.pollSec, trollDlaSec: world.config.trollDlaSec, mapW: world.config.mapW, mapH: world.config.mapH },
map: gridStrings(world),
you: {
id: t.id, name: t.name, race: t.race, x: t.x, y: t.y,
pv: t.pv, pvMax: t.pvMax, pa: t.pa, pi: t.pi, gold: t.gold, kills: t.kills,
dla: t.dla, level: g.levelFromTotalPI(t.totalPI),
dead: t.dead, respawnIn: t.dead ? Math.max(0, t.respawnAt - now) : 0,
nextDlaIn: Math.max(0, t.nextDla - now),
camo: t.camo, fatigue: t.fatigue, compUsed: t.compUsed,
att: t.att, esq: t.esq, deg: t.deg, reg: t.reg, vue: t.vue,
armorDice: t.armorDice, bought: t.bought,
comp: { pct: t.comp.pct }, sort: { pct: t.sort.pct },
eff: te,
equip: t.equip, bag: t.bag,
effects: (t.potionEffects || []).map(e => ({ name: e.name, emoji: e.emoji, turnsLeft: e.turnsLeft, modLines: e.modLines || [] })),
blockCamoTurns: t.blockCamoTurns,
},
trolls: Object.values(world.trolls)
.filter(o => o !== t && !o.dead && !o.camo && seen(o))
.map(o => ({ name: o.name, race: o.race, x: o.x, y: o.y, level: g.levelFromTotalPI(o.totalPI), pvPct: Math.max(0, o.pv / o.pvMax) })),
monsters: world.monsters.filter(seen)
.map(m => ({ id: m.id, name: m.name, emoji: m.emoji, x: m.x, y: m.y, level: m.level, pvPct: Math.max(0, m.pv / m.pvMax), nextDlaIn: Math.max(0, m.nextDla - now) })),
items: world.items.filter(seen)
.map(i => ({ name: i.name, emoji: i.emoji, x: i.x, y: i.y, kind: i.kind, color: i.color })),
log: world.log.slice(-25),
privLog: (t.privLog || []).slice(-30),
};
}
/* ---------- Admin ---------- */
function adminOverview(world, now = Date.now()) {
return {
config: world.config,
bounds: CONFIG_BOUNDS,
tuning: currentTuning(),
defaults: adminDefaults(),
uptime: now - world.createdAt,
trolls: Object.values(world.trolls).map(t => ({
id: t.id, name: t.name, race: t.race, level: g.levelFromTotalPI(t.totalPI),
pv: t.pv, pvMax: t.pvMax, pa: t.pa, kills: t.kills, gold: t.gold,
dead: t.dead, x: t.x, y: t.y,
lastSeenAgo: now - (t.lastSeen || now),
})),
monsters: world.monsters.map(m => ({
id: m.id, name: m.name, emoji: m.emoji, level: m.level, pv: m.pv, pvMax: m.pvMax,
x: m.x, y: m.y, dlaSec: Math.round(m.dlaMs / 1000), nextDlaIn: Math.max(0, m.nextDla - now),
})),
items: world.items.length,
logTail: world.log.slice(-30),
};
}
/* Applique des réglages (validés et bornés). La taille de carte ne change
* qu'à la régénération du monde. Retourne la config effective. */
function adminSetConfig(world, patch, now = Date.now()) {
const cfg = world.config;
for (const [key, bounds] of Object.entries(CONFIG_BOUNDS)) {
if (patch[key] === undefined) continue;
const v = Number(patch[key]);
if (!Number.isFinite(v)) continue;
cfg[key] = Math.max(bounds[0], Math.min(bounds[1], Math.round(v)));
}
if (cfg.monsterDlaMinSec > cfg.monsterDlaMaxSec)
cfg.monsterDlaMaxSec = cfg.monsterDlaMinSec;
// effet immédiat : les DLA déjà planifiées au-delà des nouvelles bornes sont re-tirées
for (const m of world.monsters) {
if (m.nextDla - now > cfg.monsterDlaMaxSec * 1000) {
m.dlaMs = Math.round(monsterDlaMs(cfg));
m.nextDla = now + m.dlaMs;
}
}
for (const t of Object.values(world.trolls)) {
if (t.nextDla - now > cfg.trollDlaSec * 1000) t.nextDla = now + cfg.trollDlaSec * 1000;
}
return cfg;
}
/* Valeurs de base (vanilla) pour construire les formulaires de tuning admin. */
function adminDefaults() {
return {
monsters: db.vanillaMonsters(),
monsterKeys: MONSTER_TUNE_KEYS,
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: 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 est remise au vanilla
* puis les écarts envoyés sont écrits en base (validés et bornés). */
function adminSetTuning(world, patch) {
const known = {
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") {
db.resetCategory("monsters");
for (const [name, vals] of Object.entries(patch.monsters)) {
if (!known.monsters.has(name) || typeof vals !== "object") continue;
const entry = {};
for (const k of MONSTER_TUNE_KEYS) {
const v = Number(vals[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, MONSTER_TUNE_BOUNDS[k][0], MONSTER_TUNE_BOUNDS[k][1]);
}
db.setMonster(name, entry);
}
}
if (patch.gear && typeof patch.gear === "object") {
db.resetCategory("gear");
for (const [key, mods] of Object.entries(patch.gear)) {
if (!known.gear.has(key) || typeof mods !== "object") continue;
const entry = {};
for (const k of GEAR_TUNE_KEYS) {
const v = Number(mods[k]);
if (Number.isFinite(v)) entry[k] = clampInt(v, GEAR_TUNE_BOUNDS[0], GEAR_TUNE_BOUNDS[1]);
}
const slash = key.indexOf("/");
db.setGear(key.slice(0, slash), key.slice(slash + 1), entry);
}
}
for (const cat of ["potions", "scrolls"]) {
if (!patch[cat] || typeof patch[cat] !== "object") continue;
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;
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]),
]);
}
}
return currentTuning();
}
/* Supprime définitivement un troll du monde (admin). */
function adminKickTroll(world, id) {
const t = world.trolls[id];
if (!t) return { error: "troll inconnu" };
delete world.trolls[id];
worldLog(world, `⚡ Les Dieux Trõlls ont rappelé ${t.name} hors du Monde Souterrain.`);
return { ok: true, name: t.name };
}
/* Régénère le monde (carte, monstres, trésors) en conservant trolls et config ;
* les trolls sont replacés. */
function adminResetWorld(world) {
const cfg = world.config;
world.grid = g.generateCavern(cfg.mapW, cfg.mapH);
world.monsters = [];
world.items = [];
world.nextId = 1;
world.nextRepop = 0;
for (let i = 0; i < cfg.monsterTarget; i++) spawnMonster(world);
for (let i = 0; i < cfg.itemTarget; i++) spawnItem(world);
for (const t of Object.values(world.trolls)) {
const pos = randomFloor(world);
if (pos) { t.x = pos.x; t.y = pos.y; }
privLog(t, "🌋 Le Monde Souterrain a été régénéré par les Dieux Trõlls !", "info");
}
worldLog(world, "🌋 Le Monde Souterrain a été régénéré !", "good");
}
/* ---------- Persistance ---------- */
function saveWorld(world, file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(world));
fs.renameSync(tmp, file);
}
function loadWorld(file) {
try {
const world = JSON.parse(fs.readFileSync(file, "utf8"));
if (!world || !world.grid || !world.trolls) return null;
world.config = { ...DEFAULT_CONFIG, ...world.config };
// 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; }
}
module.exports = {
DEFAULT_CONFIG, CONFIG_BOUNDS,
createWorld, tick, newTroll, authTroll, login, action, stateFor,
adminOverview, adminSetConfig, adminSetTuning, adminDefaults, adminResetWorld, adminKickTroll,
currentTuning, saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
tunedRandomPotion, tunedRandomScroll, applyGearTuning,
};