/* 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, };