Files
mountycrawl/mp.js
T

813 lines
32 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");
/* ---------- 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],
};
/* ---------- 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;
const m = g.makeMonster(world.config.worldDepth, 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 ? p.makeRandomPotion()
: r < 0.48 ? sc.makeRandomScroll()
: r < 0.78 ? 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 newTroll(world, name, race, now = Date.now()) {
if (!g.RACES[race]) return { error: "race inconnue" };
name = String(name || "").trim().slice(0, 20) || "Trõllinet";
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 s = g.RACES[race].stats;
const troll = {
id: crypto.randomBytes(6).toString("hex"),
secret: crypto.randomBytes(16).toString("hex"),
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;
}
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);
const r = g.resolveAttack(em, te);
if (r.hit) {
best.pv -= r.damage;
privLog(best, `${m.emoji} ${m.name} t'attaque : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts${r.armorReduction ? ` (armure ${r.armorReduction})` : ""} !`, "bad");
if (best.pv <= 0) killTrollMP(world, best, m.name, now);
} else {
privLog(best, `${m.emoji} ${m.name} t'attaque… 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 ? { ...p.makeRandomPotion(), x: m.x, y: m.y }
: lr < 0.65 ? { ...sc.makeRandomScroll(), 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 = {
att: Math.max(1, Math.floor(te.att * 2 / 3)), deg: Math.max(1, Math.floor(te.att / 2)),
degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat,
};
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 = {
att: Math.max(1, Math.floor(te.deg * 2 / 3)), deg: te.deg,
degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat,
};
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 = { att: te.att, deg: te.reg, degBonus: 0, attFlat: te.attFlat, degFlat: te.degFlat };
const r = g.resolveAttack(pseudo, monsterEff(m), { ignoreArmor: 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,
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;
}
/* 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 };
return world;
} catch { return null; }
}
module.exports = {
DEFAULT_CONFIG, CONFIG_BOUNDS,
createWorld, tick, newTroll, authTroll, action, stateFor,
adminOverview, adminSetConfig, adminResetWorld,
saveWorld, loadWorld,
spawnMonster, spawnItem, monsterAct, trollDla, worldLog,
};