diff --git a/README.md b/README.md index 6cbd969..e278260 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ L'état vivant du monde (trolls, monstres actifs, objets au sol) reste dans son troll depuis un autre appareil) - `GET /api/mp/state?id=&secret=` → état visible (vue du troll) - `POST /api/mp/action` `{id, secret, action}` — `move`, `attack`, `comp`, - `sort`, `pickup`, `use`, `train` + `sort`, `pickup`, `use`, `unequip`, `eat`, `drop`, `train` - `GET /api/mp/info` → compteurs publics (trolls en ligne…) - `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `PUT /api/mp/admin/tuning` · `POST /api/mp/admin/reset` (header `X-Admin-Token`) @@ -234,6 +234,19 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **2.4.0** (2026-06-12) — Gestion du sac, en solo comme en multi : + **déséquiper** (↩️, 1 PA : la pièce revient au sac, PV max et bonus recalculés), + **jeter à terre** (🗑️, 1 PA : l'objet tombe sur la case du troll et peut être + ramassé — une seule chose à terre par case), et **goinfrer** l'équipement + (🍴, 1 PA : l'objet est dévoré et détruit contre un petit bonus aléatoire, + X = 1 à 3 — « MIAM » +XD3 PV immédiats, « CLONK » +X en armure pendant + X tours, « GRRROUAR » +X en dégâts pendant X tours, avec les textes + d'ambiance qui vont bien). Le sac est désormais **classé par type** + (potions, parchemins, puis l'équipement par emplacement) et chaque objet + affiche **ses effets** : formule du trésor avec son niveau X et sa durée + pour les potions/parchemins, bonus complets pour l'équipement. + Actions API : `unequip`, `eat`, `drop`. Les boutons du monde partagé + reprennent le style olive du pack parchemin. - **2.3.0** (2026-06-12) — Les valeurs de référence du monde partagé passent dans une **base SQLite** (`db.js`, module natif `node:sqlite` — toujours zéro dépendance npm) : tables `monsters` (bestiaire), `gear` (55 objets), diff --git a/css/style.css b/css/style.css index d2fcf61..92acb0f 100644 --- a/css/style.css +++ b/css/style.css @@ -238,7 +238,8 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; } .hp-bar.high { background: #008040; } .hp-bar.mid { background: #CC9900; } -#improve button, #inventory button, #actions button { +#improve button, #inventory button, #actions button, +#mp-improve button, #mp-inventory button, #mp-actions button { display: block; width: 100%; margin: 4px 0; @@ -258,11 +259,18 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; } } #improve button:hover:not(:disabled), #inventory button:hover:not(:disabled), -#actions button:hover:not(:disabled) { background: #7a7a3d; } +#actions button:hover:not(:disabled), +#mp-improve button:hover:not(:disabled), +#mp-inventory button:hover:not(:disabled), +#mp-actions button:hover:not(:disabled) { background: #7a7a3d; } #improve button:active:not(:disabled), #inventory button:active:not(:disabled), -#actions button:active:not(:disabled) { border-style: inset; } -#improve button:disabled, #inventory button:disabled, #actions button:disabled { +#actions button:active:not(:disabled), +#mp-improve button:active:not(:disabled), +#mp-inventory button:active:not(:disabled), +#mp-actions button:active:not(:disabled) { border-style: inset; } +#improve button:disabled, #inventory button:disabled, #actions button:disabled, +#mp-improve button:disabled, #mp-inventory button:disabled, #mp-actions button:disabled { opacity: .45; cursor: default; } @@ -284,7 +292,7 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; } gap: 6px; justify-content: center; } -#actions button { width: auto; display: inline-block; } +#actions button, #mp-actions button { width: auto; display: inline-block; } .help p { font-size: .8em; color: #666633; margin: 3px 0; } @@ -589,6 +597,35 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; } .tz-good { color: #008040; } .tz-bad { color: #990000; } +/* ---------- Sac groupé par type + actions par objet ---------- */ +.bag-group { + font-weight: bold; + color: #333300; + border-bottom: 1px solid #666633; + margin: 8px 0 3px; + font-size: .85em; +} +.bag-item { padding: 3px 0 5px; border-bottom: 1px dotted #99996a; } +.bag-name { font-size: .9em; color: #000000; } +.bag-item .eq-mods { margin-left: 0; } +.bag-actions { display: flex; gap: 4px; margin-top: 3px; } +#inventory .bag-actions button, #mp-inventory .bag-actions button { + display: inline-block; + width: auto; + flex: 1 1 auto; + margin: 0; + padding: 3px 6px; + font-size: .78em; +} +.unequip-btn { + background: none; + border: none; + cursor: pointer; + font-size: .85em; + padding: 0 2px; +} +.unequip-btn:disabled { opacity: .35; cursor: default; } + /* ---------- Équipement 6 emplacements ---------- */ .eq-line { margin-bottom: 6px; line-height: 1.3; } .eq-slot { diff --git a/js/game.js b/js/game.js index 4f6ffc0..38c4063 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "2.3.0"; +const APP_VERSION = "2.4.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; @@ -334,7 +334,7 @@ function makeItem(depth) { const MAX_DEPTH = 5; const PA_PER_TURN = 6; -const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1, scroll: 1 }; +const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1, scroll: 1, unequip: 1, eat: 1, drop: 1 }; let G = null; // état global de la partie @@ -987,17 +987,112 @@ function useBagItem(idx) { afterAction(); } +/* Déséquipe une pièce : elle revient dans le sac. */ +function unequipSlot(slot) { + const t = G.troll; + if (!t.equip[slot]) return; + if (!spendPA(COSTS.unequip)) return; + unequipToBag(t, slot); + t.gearMods = gearMods(t.equip); + updateFov(); + afterAction(); +} + +/* « Goinfrer » une pièce d'équipement du sac : détruite, petit bonus aléatoire. */ +function eatBagItem(idx) { + const t = G.troll; + const item = t.bag[idx]; + if (!item || item.kind !== "gear") return; + if (!spendPA(COSTS.eat)) return; + t.bag.splice(idx, 1); + const r = goinfreItem(t, rollDice); + cdStart(`🍴 Goinfre : ${item.emoji} ${item.name}`); + cdLine(`« ${r.cry} » ${r.effect}.`); + cdLine(`${r.flavor}`); + cdFlush(); + log(`🍴 Tu goinfres ${item.name} : « ${r.cry} » ${r.effect}.`, "good"); + if (countActiveEffects(t) > 0) switchLeftTab("effects"); + afterAction(); +} + +/* Jette un objet du sac à terre (sur la case du troll). */ +function dropBagItem(idx) { + const t = G.troll; + const item = t.bag[idx]; + if (!item) return; + if (G.items.some(i => i.x === t.x && i.y === t.y)) { + log("Il y a déjà quelque chose à terre ici.", "info"); + return; + } + if (!spendPA(COSTS.drop)) return; + t.bag.splice(idx, 1); + G.items.push({ ...item, x: t.x, y: t.y }); + log(`Tu jettes ${item.emoji} ${item.name} à terre.`, "info"); + afterAction(); +} + +/* ---------- Sac : tri par type et effets des objets ---------- */ + +/* Groupes d'affichage du sac, dans l'ordre : potions, parchemins, puis + * l'équipement par emplacement. Retourne [{label, entries: [{item, idx}]}]. */ +function bagGroups(bag) { + const groups = [ + { key: "potion", label: "🧪 Potions", match: i => i.kind === "potion" }, + { key: "scroll", label: "📜 Parchemins", match: i => i.kind === "scroll" }, + ...Object.entries(GEAR_SLOTS).map(([slot, info]) => ({ + key: slot, + label: `${info.emoji} ${info.label.endsWith("s") ? info.label : info.label + "s"}`, + match: i => i.kind === "gear" && i.slot === slot, + })), + ]; + const out = []; + for (const grp of groups) { + const entries = bag.map((item, idx) => ({ item, idx })).filter(e => grp.match(e.item)); + if (entries.length) out.push({ label: grp.label, entries }); + } + // sécurité : tout objet d'un type inattendu reste visible + const known = new Set(out.flatMap(grp => grp.entries.map(e => e.idx))); + const rest = bag.map((item, idx) => ({ item, idx })).filter(e => !known.has(e.idx)); + if (rest.length) out.push({ label: "❓ Divers", entries: rest }); + return out; +} + +/* Lignes d'effets d'un objet du sac (X remplacé par le niveau du trésor). */ +function itemEffectLines(item) { + const fromTable = (table, idField) => { + const row = (table || []).find(r => r[0] === item[idField]); + if (!row) return []; + const [, , duration, lines] = row; + return [lines.map(l => l.replace(/\bX\b/g, item.power)).join(" · ") + ` (${duration})`]; + }; + if (item.kind === "potion") return fromTable(typeof TREASURE_POTIONS !== "undefined" ? TREASURE_POTIONS : null, "potionId"); + if (item.kind === "scroll") return fromTable(typeof TREASURE_SCROLLS !== "undefined" ? TREASURE_SCROLLS : null, "scrollId"); + if (item.kind === "gear") { + const fx = formatGearMods(item.mods); + return [(fx || "aucun bonus") + (item.twoHanded ? " · 2 mains" : "")]; + } + return []; +} + +/* Range une pièce équipée dans le sac (retire son bonus de PV max). + * Ne recalcule PAS gearMods : l'appelant s'en charge. */ +function unequipToBag(t, slot, logFn = log) { + const old = t.equip[slot]; + if (!old) return null; + t.equip[slot] = null; + t.bag.push(old); + if (old.mods.pv) { + t.pvMax = Math.max(1, t.pvMax - old.mods.pv); + t.pv = Math.max(1, Math.min(t.pv, t.pvMax)); + } + logFn(`Tu ranges ${old.emoji} ${old.name} dans ton sac.`, "info"); + return old; +} + /* Équipe une pièce : range l'ancienne au sac, gère l'exclusion arme à 2 mains / * bouclier, applique le bonus de PV max et recalcule les modificateurs. */ function equipGear(t, item) { - const unequipTo = (slot) => { - const old = t.equip[slot]; - if (!old) return; - t.equip[slot] = null; - t.bag.push(old); - if (old.mods.pv) t.pvMax = Math.max(1, t.pvMax - old.mods.pv); - log(`Tu ranges ${old.emoji} ${old.name} dans ton sac.`, "info"); - }; + const unequipTo = (slot) => unequipToBag(t, slot); if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) { unequipTo("bouclier"); log("Une arme à deux mains ne laisse pas de place au bouclier.", "info"); @@ -1345,30 +1440,68 @@ function renderPanels() { if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true); addBtn("⏳ Passer la DLA", passDLA, true); - $("equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => { + const equipEl = $("equipment"); + equipEl.innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => { const it = t.equip[slot]; if (!it) return `
${info.label}
`; const fx = formatGearMods(it.mods); - return `
${info.label} ${it.emoji} ${it.name}` + + return `
${info.label} ${it.emoji} ${esc(it.name)}` + `${it.twoHanded ? " (2 mains)" : ""}` + + ` ` + `${fx ? `
${fx}
` : ""}
`; }).join(""); + for (const b of equipEl.querySelectorAll(".unequip-btn")) { + b.disabled = G.over || t.pa < COSTS.unequip; + b.onclick = () => unequipSlot(b.dataset.slot); + } - const inv = $("inventory"); - inv.innerHTML = ""; - if (t.bag.length === 0) inv.innerHTML = '
— vide —
'; - t.bag.forEach((item, idx) => { - const b = document.createElement("button"); - const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` - : item.kind === "scroll" ? `lire (${COSTS.scroll} PA)` : `équiper (${COSTS.equip} PA)`; - b.textContent = `${item.emoji} ${item.name} — ${action}`; - if (item.kind === "gear") b.title = formatGearMods(item.mods) + (item.twoHanded ? " · 2 mains" : ""); - b.disabled = G.over; - b.onclick = () => useBagItem(idx); - inv.appendChild(b); + renderBag($("inventory"), t.bag, { + disabled: G.over, + pa: t.pa, + onUse: useBagItem, onEat: eatBagItem, onDrop: dropBagItem, }); } +/* Rendu partagé du sac (solo et multi) : groupé par type, effets visibles, + * actions utiliser / goinfrer / jeter. */ +function renderBag(inv, bag, opts) { + inv.innerHTML = ""; + if (!bag.length) { inv.innerHTML = '
— vide —
'; return; } + for (const grp of bagGroups(bag)) { + const head = document.createElement("div"); + head.className = "bag-group"; + head.textContent = grp.label; + inv.appendChild(head); + for (const { item, idx } of grp.entries) { + const row = document.createElement("div"); + row.className = "bag-item"; + const fx = itemEffectLines(item).join(" · "); + const useLabel = item.kind === "potion" ? `🥤 boire (${COSTS.potion} PA)` + : item.kind === "scroll" ? `👁️ lire (${COSTS.scroll} PA)` : `🛡️ équiper (${COSTS.equip} PA)`; + row.innerHTML = `
${item.emoji} ${esc(item.name)}
` + + (fx ? `
${esc(fx)}
` : "") + + `
`; + const actions = row.querySelector(".bag-actions"); + const addBtn = (label, title, fn, enabled) => { + const b = document.createElement("button"); + b.textContent = label; + b.title = title; + b.disabled = opts.disabled || !enabled; + b.onclick = fn; + actions.appendChild(b); + }; + addBtn(useLabel, "", () => opts.onUse(idx), opts.pa >= (item.kind === "gear" ? COSTS.equip : 1)); + if (item.kind === "gear") { + addBtn(`🍴 goinfrer (${COSTS.eat} PA)`, "Dévorer l'objet (détruit) : petit bonus aléatoire MIAM / CLONK / GRRROUAR", + () => opts.onEat(idx), opts.pa >= COSTS.eat); + } + addBtn(`🗑️ jeter (${COSTS.drop} PA)`, "Jeter l'objet à terre sur ta case", + () => opts.onDrop(idx), opts.pa >= COSTS.drop); + inv.appendChild(row); + } + } +} + function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } @@ -1491,6 +1624,20 @@ if (typeof document !== "undefined") { if (G.monsters.includes(m)) useSort(); } } + // ?demo=sac : remplit le sac et équipe deux pièces (captures du sac trié) + if (params.get("demo") === "sac") { + const t = G.troll; + equipGear(t, gearItemByName("arme", "Gourdin")); + equipGear(t, gearItemByName("armure", "Armure de cuir")); + t.bag.push( + makePotionItem("guerison", 3), makePotionItem("feu", 5), + formatScrollItem("runeExplosive", 2), + gearItemByName("arme", "Épée Courte"), gearItemByName("casque", "Casque à Cornes"), + gearItemByName("bottes", GEAR.bottes[0].name), + ); + render(); + renderPanels(); + } } $("btn-restart").onclick = () => { $("screen-end").classList.add("hidden"); @@ -1509,7 +1656,7 @@ if (typeof document !== "undefined") { if (typeof module !== "undefined" && module.exports) { module.exports = { APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, - RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear, + RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear, unequipToBag, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT, diff --git a/js/mp-client.js b/js/mp-client.js index 9445dc5..d24c2e7 100644 --- a/js/mp-client.js +++ b/js/mp-client.js @@ -316,25 +316,27 @@ function mpRenderPanels(st) { const onItem = st.items.some(i => i.x === you.x && i.y === you.y); addBtn(`🖐️ Ramasser (1 PA)`, () => mpAction({ type: "pickup" }), onItem && you.pa >= 1); - document.getElementById("mp-equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => { + const equipEl = document.getElementById("mp-equipment"); + equipEl.innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => { const it = you.equip[slot]; if (!it) return `
${info.label}
`; const fxs = formatGearMods(it.mods); - return `
${info.label} ${it.emoji} ${it.name}` + - `${it.twoHanded ? " (2 mains)" : ""}${fxs ? `
${fxs}
` : ""}
`; + return `
${info.label} ${it.emoji} ${esc(it.name)}` + + `${it.twoHanded ? " (2 mains)" : ""}` + + ` ` + + `${fxs ? `
${fxs}
` : ""}
`; }).join(""); + for (const b of equipEl.querySelectorAll(".unequip-btn")) { + b.disabled = you.dead || you.pa < COSTS.unequip; + b.onclick = () => mpAction({ type: "unequip", slot: b.dataset.slot }); + } - const inv = document.getElementById("mp-inventory"); - inv.innerHTML = ""; - if (!you.bag.length) inv.innerHTML = '
— vide —
'; - you.bag.forEach((item, idx) => { - const b = document.createElement("button"); - const action = item.kind === "potion" ? "boire (1 PA)" : item.kind === "scroll" ? "lire (1 PA)" : "équiper (2 PA)"; - b.textContent = `${item.emoji} ${item.name} — ${action}`; - if (item.kind === "gear") b.title = formatGearMods(item.mods) + (item.twoHanded ? " · 2 mains" : ""); - b.disabled = you.dead; - b.onclick = () => mpAction({ type: "use", idx }); - inv.appendChild(b); + renderBag(document.getElementById("mp-inventory"), you.bag, { + disabled: you.dead, + pa: you.pa, + onUse: idx => mpAction({ type: "use", idx }), + onEat: idx => mpAction({ type: "eat", idx }), + onDrop: idx => mpAction({ type: "drop", idx }), }); } diff --git a/js/potions.js b/js/potions.js index e5a5e23..085f6d5 100644 --- a/js/potions.js +++ b/js/potions.js @@ -488,6 +488,37 @@ function makePotionItem(potionId, power) { return formatPotionItem(potionId, power != null ? power : def.rollPower()); } +/* « Goinfrer » : le troll dévore une pièce d'équipement (détruite) et en tire + * un petit bénéfice aléatoire, X = 1 à 3 : + * « MIAM » +X D3 PV immédiats + * « CLONK » +X en armure pendant X tours + * « GRRROUAR » +X en dégâts pendant X tours */ +function goinfreItem(troll, rollDice) { + const x = 1 + Math.floor(Math.random() * 3); + const r = Math.random(); + troll.potionEffects = troll.potionEffects || []; + if (r < 1 / 3) { + const heal = rollDice(x, 3).total; + troll.pv = Math.min(troll.pvMax, troll.pv + heal); + return { + cry: "MIAM", effect: `+${x}D3 points de vie (+${heal} PV)`, + flavor: "Excellent ce petit en-cas. Foi de Troll, il y avait longtemps que je n'avais aussi bien mangé.", + }; + } + if (r < 2 / 3) { + troll.potionEffects.push(makeEffect("Goinfre « CLONK »", "🦷", x, { armor: x }, [`Armure +${x}`])); + return { + cry: "CLONK", effect: `+${x} en armure pendant ${x} tour(s)`, + flavor: "Un peu dur mais cela renforce les dents. Ce qui ne nous tue pas nous rend plus fort.", + }; + } + troll.potionEffects.push(makeEffect("Goinfre « GRRROUAR »", "🍴", x, { degFlat: x }, [`DEG +${x}`])); + return { + cry: "GRRROUAR", effect: `+${x} en dégâts pendant ${x} tour(s)`, + flavor: "Il faut que je mange encore quelque chose, ce petit en-cas m'a mis en appétit.", + }; +} + function drinkPotion(troll, item, rollDice, logFn) { const def = POTION_DEFS[item.potionId]; if (!def) { @@ -638,7 +669,7 @@ function talentPctWithPotions(troll, talent, cap) { if (typeof module !== "undefined" && module.exports) { module.exports = { POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem, - makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns, + makeRandomPotion, makePotionItem, drinkPotion, goinfreItem, tickPotionTurns, describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods, sharedMod, makeEffect, fmtStatLine, talentPctWithPotions, corruptionYZ, }; diff --git a/mp.js b/mp.js index d077a6a..7ebe715 100644 --- a/mp.js +++ b/mp.js @@ -716,6 +716,36 @@ function action(world, t, act) { return { ok: true }; } + if (act.type === "unequip") { + if (!t.equip[act.slot]) return { error: "rien d'équipé à cet emplacement" }; + if (!spendPA(t, g.COSTS.unequip)) return { error: "pas assez de PA" }; + g.unequipToBag(t, act.slot, (msg, cls) => privLog(t, msg, cls)); + t.gearMods = gearLib.gearMods(t.equip); + return { ok: true }; + } + + if (act.type === "eat") { + const item = t.bag[act.idx]; + if (!item || item.kind !== "gear") return { error: "seul l'équipement se goinfre" }; + if (!spendPA(t, g.COSTS.eat)) return { error: "pas assez de PA" }; + t.bag.splice(act.idx, 1); + const r = p.goinfreItem(t, g.rollDice); + privLog(t, `🍴 Tu goinfres ${item.emoji} ${item.name} : « ${r.cry} » ${r.effect}.`, "good"); + privLog(t, r.flavor, "info"); + return { ok: true }; + } + + if (act.type === "drop") { + const item = t.bag[act.idx]; + if (!item) return { error: "objet introuvable dans le sac" }; + if (world.items.some(i => i.x === t.x && i.y === t.y)) return { error: "il y a déjà quelque chose à terre ici" }; + if (!spendPA(t, g.COSTS.drop)) return { error: "pas assez de PA" }; + t.bag.splice(act.idx, 1); + world.items.push({ ...item, x: t.x, y: t.y }); + privLog(t, `Tu jettes ${item.emoji} ${item.name} à terre.`, "info"); + 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" }; diff --git a/test/mp.js b/test/mp.js index 73ffd92..ab53857 100644 --- a/test/mp.js +++ b/test/mp.js @@ -319,6 +319,55 @@ function makeWorld(over = {}) { 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"); diff --git a/test/smoke.js b/test/smoke.js index 56bf88e..a12de26 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -400,6 +400,43 @@ assert.strictEqual(g.itemFromSpec({ x: 1, y: 1, kind: "armor", idx: 0 }).name, " } } +// Goinfre : les trois issues existent, X borné 1-3, effets temporaires posés +{ + const counts = { MIAM: 0, CLONK: 0, GRRROUAR: 0 }; + for (let i = 0; i < 300; i++) { + const t = { pv: 10, pvMax: 100, potionEffects: [] }; + const r = p.goinfreItem(t, g.rollDice); + counts[r.cry]++; + if (r.cry === "MIAM") { + assert(t.pv > 10 && t.pv <= 10 + 9, "MIAM soigne entre 1 et 3D3"); + } else { + const fx = t.potionEffects[0]; + assert(fx && fx.turnsLeft >= 1 && fx.turnsLeft <= 3, "effet de 1 à 3 tours"); + if (r.cry === "CLONK") assert(fx.armor >= 1 && fx.armor <= 3, "CLONK : armure +1 à +3"); + if (r.cry === "GRRROUAR") assert(fx.degFlat >= 1 && fx.degFlat <= 3, "GRRROUAR : DEG +1 à +3"); + } + } + assert(counts.MIAM && counts.CLONK && counts.GRRROUAR, "les trois cris sortent : " + JSON.stringify(counts)); +} + +// Déséquiper : retour au sac, PV max et modificateurs recalculés +{ + const gearLib = require("../js/gear.js"); + const t = { att: 3, esq: 3, deg: 3, reg: 1, vue: 3, armor: 0, armorDice: 0, degBonus: 0, pvMax: 30, pv: 30, + potionEffects: [], blockCamoTurns: 0, tour: 1, bag: [], + equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null }, gearMods: null }; + g.equipGear(t, gearLib.gearItemByName("armure", "Armure de bois")); // arm +6, pv +15 + assert.strictEqual(t.pvMax, 45, "PV max +15"); + t.pv = 45; + const old = g.unequipToBag(t, "armure", () => {}); + t.gearMods = gearLib.gearMods(t.equip); + assert.strictEqual(old.name, "Armure de bois", "pièce retirée"); + assert.strictEqual(t.bag.length, 1, "revenue au sac"); + assert.strictEqual(t.pvMax, 30, "PV max restaurés"); + assert.strictEqual(t.pv, 30, "PV bornés au nouveau max"); + assert.strictEqual(t.gearMods.arm, 0, "modificateurs recalculés"); +} + // Validation serveur des niveaux const srv = require("../server.js"); const goodGrid = [];