2.0.0 : multijoueur — monde partagé persistant, DLA paramétrables par monstre, admin à chaud
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
/* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user