Files
mountycrawl/test/mp.js
T

421 lines
18 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.
/* Tests du moteur multijoueur — exécuter avec : node test/mp.js */
"use strict";
const assert = require("assert");
const mp = require("../mp.js");
const g = require("../js/game.js");
/* Monde de test : petit, DLA courtes pour simuler. */
function makeWorld(over = {}) {
return mp.createWorld({
mapW: 30, mapH: 20, monsterTarget: 8, itemTarget: 5,
monsterDlaMinSec: 60, monsterDlaMaxSec: 120, trollDlaSec: 60,
deathRespawnSec: 30, ...over,
});
}
// Création du monde : carte, monstres avec DLA propres, trésors
{
const w = makeWorld();
assert.strictEqual(w.grid.length, 20);
assert.strictEqual(w.grid[0].length, 30);
assert(w.monsters.length === 8, "population initiale de monstres");
assert(w.items.length === 5, "trésors initiaux");
for (const m of w.monsters) {
assert(typeof m.id === "number", "monstre : id");
assert(m.dlaMs >= 60000 && m.dlaMs <= 120000, "DLA du monstre dans la fourchette : " + m.dlaMs);
assert(m.nextDla > Date.now() - 1000, "prochaine DLA planifiée");
assert(typeof m.armorMag === "number", "armure magique présente");
}
// pas deux entités sur la même case
const cells = new Set();
for (const m of w.monsters) {
const k = m.y * 30 + m.x;
assert(!cells.has(k), "deux monstres sur la même case");
cells.add(k);
}
}
// Création de troll : profil de race, identité secrète
{
const w = makeWorld();
const r = mp.newTroll(w, "Grosbill", "Durakuir");
assert(r.troll, "troll créé");
assert.strictEqual(r.troll.pvMax, 40, "PV max Durakuir");
assert(r.troll.id.length === 12 && r.troll.secret.length === 32, "identité");
assert(mp.authTroll(w, r.troll.id, r.troll.secret) === r.troll, "auth ok");
assert(mp.authTroll(w, r.troll.id, "mauvaise-clé") === null, "auth refusée");
assert(mp.newTroll(w, "X", "Elfe").error, "race inconnue refusée");
const full = makeWorld({ maxTrolls: 1 });
mp.newTroll(full, "A", "Skrim");
assert(mp.newTroll(full, "B", "Skrim").error, "limite de trolls");
}
// Tick : les monstres agissent à leur DLA (et seulement à leur DLA)
{
const w = makeWorld();
const now = Date.now();
const m = w.monsters[0];
const before = { x: m.x, y: m.y, nextDla: m.nextDla };
assert.strictEqual(mp.tick(w, now), false, "aucune DLA échue → pas de changement");
m.nextDla = now - 1;
assert.strictEqual(mp.tick(w, now), true, "DLA échue → activation");
assert(m.nextDla > now, "DLA re-planifiée : " + m.nextDla);
assert(m.dlaMs >= 60000 && m.dlaMs <= 120000, "nouvelle période dans la fourchette");
void before;
}
// DLA du troll : recharge des PA, régénération
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Regen", "Durakuir");
t.pa = 0;
t.pv = 10;
const now = Date.now();
t.nextDla = now - 1;
mp.tick(w, now);
assert.strictEqual(t.pa, g.PA_PER_TURN, "PA rechargés");
assert(t.pv > 10, "régénération à la DLA");
assert(t.nextDla > now, "prochaine DLA planifiée");
}
// Mort et réapparition
{
const w = makeWorld({ deathRespawnSec: 1 });
const { troll: t } = mp.newTroll(w, "Mortel", "Skrim");
// un monstre surpuissant adjacent le terrasse à sa DLA
const m = w.monsters[0];
m.x = t.x; m.y = t.y + (w.grid[t.y + 1] && w.grid[t.y + 1][t.x] !== undefined ? 1 : -1);
// place le monstre adjacent quoi qu'il arrive
m.x = t.x; m.y = t.y; m.x = Math.max(0, t.x - 1);
m.att = 80; m.deg = 50;
t.pv = 1;
const now = Date.now();
m.nextDla = now - 1;
mp.tick(w, now);
assert(t.dead, "troll terrassé");
const r = mp.action(w, t, { type: "move", dx: 1, dy: 0 });
assert(r.error, "un mort ne joue pas");
mp.tick(w, now + 2000);
assert(!t.dead, "réapparition après le délai");
assert.strictEqual(t.pv, t.pvMax, "réapparition PV pleins");
}
// Actions : déplacement, PA, ramassage, entraînement
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Actif", "Skrim");
// trouve une direction libre
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let moved = false;
for (const [dx, dy] of dirs) {
const r = mp.action(w, t, { type: "move", dx, dy });
if (r.ok) { moved = true; break; }
}
assert(moved, "au moins une direction praticable");
assert.strictEqual(t.pa, g.PA_PER_TURN - 1, "déplacement = 1 PA");
t.pa = 0;
assert(mp.action(w, t, { type: "move", dx: 1, dy: 0 }).error, "sans PA, pas d'action");
// ramassage (on libère d'abord la case d'un éventuel trésor aléatoire)
t.pa = 6;
w.items = w.items.filter(i => i.x !== t.x || i.y !== t.y);
w.items.push({ kind: "gold", gold: 50, name: "50 Mountyzédons", emoji: "💰", x: t.x, y: t.y });
const r2 = mp.action(w, t, { type: "pickup" });
assert(r2.ok && t.gold === 50, "or ramassé");
// entraînement
t.pi = 12;
const r3 = mp.action(w, t, { type: "train", stat: "att" });
assert(r3.ok && t.att === 5 && t.pi === 0, "ATT Skrim améliorée pour 12 PI");
assert(mp.action(w, t, { type: "train", stat: "att" }).error, "plus assez de PI");
}
// Combat troll → monstre, PX et mise à mort
{
const w = makeWorld();
const { troll: t } = mp.newTroll(w, "Tueur", "Kastar");
t.att = 50; t.deg = 20; // écrasant
const m = w.monsters[0];
m.x = t.x + 1; m.y = t.y;
m.pv = 5;
t.pa = 6;
const r = mp.action(w, t, { type: "attack", target: m.id });
assert(r.ok, "attaque résolue");
assert(!w.monsters.includes(m), "monstre terrassé");
assert(t.kills === 1 && t.totalPI > 0, "PX de mise à mort");
}
// Les monstres ne s'attaquent jamais entre eux : un monstre adjacent à un autre
// monstre (sans troll) ne fait que bouger ou rester sur place
{
const w = makeWorld();
// aucun troll : on tique longtemps, les PV des monstres ne bougent pas
const pvs = w.monsters.map(m => m.pv);
let now = Date.now();
for (let i = 0; i < 50; i++) {
now += 200000;
mp.tick(w, now);
}
const alive = w.monsters.map(m => m.pv);
assert(alive.every(pv => pv > 0), "tous les monstres vivants");
// (la population peut avoir augmenté par repop, mais aucun PV n'a baissé)
for (let i = 0; i < Math.min(pvs.length, alive.length); i++) {
assert(alive[i] > 0, "pas d'attaque entre monstres");
}
}
// Repeuplement : la population remonte vers la cible
{
const w = makeWorld({ monsterTarget: 6, repopSec: 10 });
w.monsters = w.monsters.slice(0, 2);
mp.tick(w, Date.now() + 11000 + 1);
assert(w.monsters.length >= 6, "repeuplement vers la cible : " + w.monsters.length);
}
// Vue : un troll ne voit que ce qui est à portée ; camouflage invisible aux autres
{
const w = makeWorld();
const { troll: t1 } = mp.newTroll(w, "Voyeur", "Skrim");
const { troll: t2 } = mp.newTroll(w, "Discret", "Tomawak");
t1.x = 5; t1.y = 5; // positions forcées : la visibilité ne dépend pas du terrain
t2.x = 7; t2.y = 5;
let st = mp.stateFor(w, t1);
assert(st.trolls.some(o => o.name === "Discret"), "troll visible à 2 cases");
t2.camo = true;
st = mp.stateFor(w, t1);
assert(!st.trolls.some(o => o.name === "Discret"), "troll camouflé invisible");
t2.camo = false;
t2.x = 25; t2.y = 18; // distance Chebyshev 20 ≫ vue 3
st = mp.stateFor(w, t1);
assert(!st.trolls.some(o => o.name === "Discret"), "troll hors de vue");
// l'état ne fuit jamais le secret
assert(!JSON.stringify(st).includes(t2.secret), "pas de fuite de secret");
assert(st.map.length === w.config.mapH, "carte envoyée");
assert(st.you.nextDlaIn >= 0, "compte à rebours de DLA");
}
// Admin : config bornée et appliquée à chaud, reset, overview
{
const w = makeWorld();
mp.adminSetConfig(w, { trollDlaSec: 42, monsterDlaMinSec: 999999, pollSec: 2 });
assert.strictEqual(w.config.trollDlaSec, 42, "réglage appliqué");
assert.strictEqual(w.config.monsterDlaMinSec, 3600, "borné au max");
assert(w.config.monsterDlaMaxSec >= w.config.monsterDlaMinSec, "min ≤ max garanti");
assert.strictEqual(w.config.pollSec, 2);
mp.adminSetConfig(w, { trollDlaSec: "nawak" });
assert.strictEqual(w.config.trollDlaSec, 42, "valeur invalide ignorée");
// une baisse de la DLA des monstres re-planifie immédiatement les échéances
const now = Date.now();
mp.adminSetConfig(w, { monsterDlaMinSec: 5, monsterDlaMaxSec: 10 }, now);
for (const m of w.monsters) {
assert(m.nextDla - now <= 10000, "DLA re-planifiée dans la nouvelle borne : " + (m.nextDla - now));
}
const ov = mp.adminOverview(w);
assert(ov.config && ov.monsters.length === w.monsters.length && Array.isArray(ov.trolls), "overview");
mp.newTroll(w, "Persistant", "Skrim");
mp.adminResetWorld(w);
assert(Object.keys(w.trolls).length === 1, "trolls conservés au reset");
assert(w.monsters.length === w.config.monsterTarget, "monstres régénérés");
}
// Identité côté serveur : mot de passe, login multi-appareils, unicité du nom
{
const w = makeWorld();
const r = mp.newTroll(w, "Perco", "Durakuir", "tr0ll!");
assert(r.troll, "troll avec mot de passe créé");
assert(!("password" in r.troll), "le mot de passe en clair n'est pas stocké");
assert(r.troll.passHash && r.troll.passHash !== "tr0ll!", "hash stocké");
assert(mp.newTroll(w, "perco", "Skrim").error, "nom déjà pris (insensible à la casse)");
// login depuis « un autre appareil »
const l1 = mp.login(w, "Perco", "tr0ll!");
assert(l1.troll === r.troll, "login OK → même troll");
assert(mp.login(w, "Perco", "mauvais").error, "mauvais mot de passe refusé");
assert(mp.login(w, "Inconnu", "x").error, "troll inconnu refusé");
// troll sans mot de passe : pas de login possible
mp.newTroll(w, "SansPass", "Skrim");
assert(mp.login(w, "SansPass", "").error, "pas de login sans mot de passe");
// l'état ne fuit ni hash ni sel
const st = JSON.stringify(mp.stateFor(w, r.troll));
assert(!st.includes(r.troll.passHash) && !st.includes(r.troll.salt), "pas de fuite du hash/sel");
}
// Tuning admin : bestiaire appliqué aux nouveaux spawns
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
const tun1 = mp.adminSetTuning(w, { monsters: { "Gobelin": { att: 9, pv: 77, armorMag: 3 } } });
assert.strictEqual(tun1.monsters.Gobelin.att, 9, "écart ATT visible dans le tuning courant");
// spawn forcé jusqu'à obtenir un Gobelin
let gob = null;
for (let i = 0; i < 300 && !gob; i++) {
const m = mp.spawnMonster(w);
if (m && m.name.includes("Gobelin")) gob = m;
}
assert(gob, "un Gobelin a fini par apparaître");
// gabarit « Jeune/normal » à depth 1 : mult 0.7 ou 1.0 → att 6 ou 9, pv 54 ou 77
assert([6, 9].includes(gob.att), "ATT tunée (×gabarit) : " + gob.att);
assert([54, 77].includes(gob.pv), "PV tunés (×gabarit) : " + gob.pv);
assert.strictEqual(gob.armorMag, 3, "armure magique tunée (gabarit ne la multiplie pas)");
// bornage et noms inconnus ignorés
const tun2 = mp.adminSetTuning(w, { monsters: { "Dragon": { att: 5 }, "Gobelin": { att: 5000 } } });
assert(!tun2.monsters.Dragon, "type inconnu ignoré");
assert.strictEqual(tun2.monsters.Gobelin.att, 99, "ATT bornée à 99");
// retour au vanilla
const tun3 = mp.adminSetTuning(w, { monsters: {} });
assert.strictEqual(Object.keys(tun3.monsters).length, 0, "bestiaire d'origine restauré");
}
// Tuning admin : puissance des potions/parchemins et bonus d'équipement
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0 });
const tun = mp.adminSetTuning(w, {
potions: { guerison: [9, 9], nimporte: [1, 2] },
scrolls: { runeExplosive: [7, 3] }, // inversé : doit devenir [3, 7]
gear: { "arme/Gourdin": { deg: 12 }, "arme/Excalibur": { deg: 99 } },
});
assert(!tun.potions.nimporte, "potion inconnue ignorée");
assert.deepStrictEqual(tun.scrolls.runeExplosive, [3, 7], "fourchette remise dans l'ordre");
assert(!tun.gear["arme/Excalibur"], "objet inconnu ignoré");
// la puissance tirée respecte l'override
for (let i = 0; i < 100; i++) {
const it = mp.tunedRandomPotion();
if (it.potionId === "guerison") assert.strictEqual(it.power, 9, "Guérison forcée à X=9");
}
// l'équipement tuné garde ses autres mods
const gearLib = require("../js/gear.js");
const club = mp.applyGearTuning(gearLib.gearItemByName("arme", "Gourdin"));
assert.strictEqual(club.mods.deg, 12, "DEG du Gourdin tuné");
assert.strictEqual(club.mods.att, 2, "ATT du Gourdin inchangée");
// les défauts pour l'admin sont complets
const defs = mp.adminDefaults();
assert.strictEqual(defs.potions.length, 25, "25 potions");
assert.strictEqual(defs.scrolls.length, 7, "7 parchemins");
assert(defs.gear.length >= 50, "tout l'équipement listé");
assert.strictEqual(defs.monsters.length, 8, "7 types + boss");
}
// Tuning admin : saveurs magiques (attMag/degMag/armMag) et suppression de troll
{
const w = makeWorld({ monsterTarget: 0, itemTarget: 0, worldDepth: 1 });
const tunMag = mp.adminSetTuning(w, {
monsters: { "Gobelin": { attMag: 6, degMag: 4 } },
gear: { "arme/Bâton de mage": { attMag: 4, armMag: 2 } },
});
assert.strictEqual(tunMag.monsters.Gobelin.attMag, 6, "ATT mag du Gobelin tunée");
assert.strictEqual(tunMag.gear["arme/Bâton de mage"].armMag, 2, "Armure mag du bâton tunée");
let gob = null;
for (let i = 0; i < 300 && !gob; i++) {
const m = mp.spawnMonster(w);
if (m && m.name.includes("Gobelin")) gob = m;
}
assert(gob && gob.attMag > 0 && gob.degMag > 0, "le Gobelin spawné a une attaque magique");
const gearLib = require("../js/gear.js");
const baton = mp.applyGearTuning(gearLib.gearItemByName("arme", "Bâton de mage"));
assert.strictEqual(baton.mods.attMag, 4, "ATT mag du bâton droppé");
assert.strictEqual(baton.mods.mmPct, 15, "MM % vanilla conservé");
// suppression admin d'un troll
const r = mp.newTroll(w, "Banni", "Skrim");
assert(mp.adminKickTroll(w, "inconnu").error, "kick d'un id inconnu refusé");
assert(mp.adminKickTroll(w, r.troll.id).ok, "troll supprimé");
assert(!w.trolls[r.troll.id], "le troll n'existe plus");
assert(!mp.newTroll(w, "Banni", "Skrim").error, "son nom redevient libre");
}
// Sac : déséquiper, goinfrer, jeter
{
const gearLib = require("../js/gear.js");
const w = makeWorld({ monsterTarget: 0, itemTarget: 0 });
const { troll: t } = mp.newTroll(w, "Sakatrõll", "Durakuir");
t.pa = 6;
const armure = gearLib.gearItemByName("armure", "Armure de bois"); // arm +6, pv +15
t.bag.push(armure);
// équiper puis déséquiper : l'objet revient au sac, PV max restaurés
const basePvMax = t.pvMax;
mp.action(w, t, { type: "use", idx: 0 });
assert.strictEqual(t.pvMax, basePvMax + 15, "PV max d'équipement appliqués");
assert(t.equip.armure, "armure équipée");
let r = mp.action(w, t, { type: "unequip", slot: "armure" });
assert(r.ok, "déséquipement accepté");
assert(!t.equip.armure, "emplacement vidé");
assert.strictEqual(t.bag.length, 1, "l'objet est revenu dans le sac");
assert.strictEqual(t.pvMax, basePvMax, "PV max restaurés");
assert.strictEqual(t.gearMods.arm, 0, "modificateurs recalculés");
assert(mp.action(w, t, { type: "unequip", slot: "casque" }).error, "emplacement vide refusé");
// jeter : l'objet se retrouve au sol sur la case du troll
r = mp.action(w, t, { type: "drop", idx: 0 });
assert(r.ok, "jet accepté");
assert.strictEqual(t.bag.length, 0, "sac vidé");
const ground = w.items.find(i => i.x === t.x && i.y === t.y);
assert(ground && ground.name === "Armure de bois", "l'objet est à terre");
// case occupée : on ne peut pas jeter par-dessus
t.bag.push(gearLib.gearItemByName("arme", "Torche"));
assert(mp.action(w, t, { type: "drop", idx: 0 }).error, "case occupée refusée");
// on peut le re-ramasser
r = mp.action(w, t, { type: "pickup" });
assert(r.ok && t.bag.some(i => i.name === "Armure de bois"), "objet re-ramassé");
// goinfrer : objet détruit, un des trois effets appliqué
t.pa = 6;
t.pv = 1; // pour voir un éventuel soin MIAM
const before = t.bag.length;
const idxTorche = t.bag.findIndex(i => i.name === "Torche");
r = mp.action(w, t, { type: "eat", idx: idxTorche });
assert(r.ok, "goinfre accepté");
assert.strictEqual(t.bag.length, before - 1, "objet détruit");
const fed = t.pv > 1 || (t.potionEffects || []).some(e => e.name.includes("Goinfre"));
assert(fed, "un effet MIAM/CLONK/GRRROUAR s'est appliqué");
const lastLogs = t.privLog.slice(-2).map(l => l.msg).join(" ");
assert(/MIAM|CLONK|GRRROUAR/.test(lastLogs), "cri du goinfre dans le rapport");
// on ne goinfre pas une potion
t.bag.push(require("../js/potions.js").makePotionItem("guerison", 3));
assert(mp.action(w, t, { type: "eat", idx: t.bag.length - 1 }).error, "potion non goinfrable");
}
// Base de référence : les retouches survivent au redémarrage (seed non destructif)
{
const os = require("os");
const path = require("path");
const fs = require("fs");
const db = require("../db.js");
const file = path.join(os.tmpdir(), "mc-test-ref.db");
for (const f of [file, file + "-wal", file + "-shm"]) { try { fs.rmSync(f); } catch {} }
db.init(file);
db.setGear("arme", "Gourdin", { deg: 42 });
db.setMonster("Sorcière", { attMag: 7, degMag: 5 });
db.init(file); // « redémarrage » : INSERT OR IGNORE ne doit rien écraser
assert.strictEqual(db.gearRow("arme", "Gourdin").deg, 42, "retouche d'équipement conservée");
const sorciere = db.monsters().find(m => m.name === "Sorcière");
assert.strictEqual(sorciere.attMag, 7, "retouche du bestiaire conservée");
assert.strictEqual(db.gearRow("arme", "Torche").vue, 1, "valeurs vanilla seedées");
db.init(":memory:"); // base propre pour la suite des tests
}
// Migration < 2.3.0 : le tuning de world.json est déversé une fois dans la base
{
const os = require("os");
const path = require("path");
const w = makeWorld();
w.tuning = { monsters: { "Gobelin": { att: 42 } }, gear: {}, potions: {}, scrolls: {} };
const file = path.join(os.tmpdir(), "mc-world-migration.json");
mp.saveWorld(w, file);
const loaded = mp.loadWorld(file);
assert(loaded && !loaded.tuning, "tuning retiré du monde migré");
assert.strictEqual(mp.currentTuning().monsters.Gobelin.att, 42, "écart migré en base");
mp.adminSetTuning(loaded, { monsters: {} }); // nettoyage
}
// Persistance : save + load
{
const os = require("os");
const path = require("path");
const file = path.join(os.tmpdir(), "mc-world-test.json");
const w = makeWorld();
mp.newTroll(w, "Durable", "Darkling");
mp.saveWorld(w, file);
const w2 = mp.loadWorld(file);
assert(w2, "monde rechargé");
assert.strictEqual(Object.keys(w2.trolls).length, 1, "troll persisté");
assert.strictEqual(w2.monsters.length, w.monsters.length, "monstres persistés");
assert.strictEqual(w2.config.trollDlaSec, 60, "config persistée");
require("fs").unlinkSync(file);
}
console.log("✅ Tous les tests multijoueur passent.");