2.4.0 : gestion du sac — déséquiper, goinfrer (MIAM/CLONK/GRRROUAR), jeter à terre, sac classé par type avec effets visibles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 23:23:01 +02:00
co-authored by Claude Fable 5
parent 031d64830c
commit 0ac2971f03
8 changed files with 392 additions and 46 deletions
+14 -1
View File
@@ -175,7 +175,7 @@ L'état vivant du monde (trolls, monstres actifs, objets au sol) reste dans
son troll depuis un autre appareil) son troll depuis un autre appareil)
- `GET /api/mp/state?id=&secret=` → état visible (vue du troll) - `GET /api/mp/state?id=&secret=` → état visible (vue du troll)
- `POST /api/mp/action` `{id, secret, action}``move`, `attack`, `comp`, - `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/info` → compteurs publics (trolls en ligne…)
- `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `PUT /api/mp/admin/tuning` - `GET /api/mp/admin` · `PUT /api/mp/admin/config` · `PUT /api/mp/admin/tuning`
· `POST /api/mp/admin/reset` (header `X-Admin-Token`) · `POST /api/mp/admin/reset` (header `X-Admin-Token`)
@@ -234,6 +234,19 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## 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 - **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 une **base SQLite** (`db.js`, module natif `node:sqlite` — toujours zéro
dépendance npm) : tables `monsters` (bestiaire), `gear` (55 objets), dépendance npm) : tables `monsters` (bestiaire), `gear` (55 objets),
+42 -5
View File
@@ -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.high { background: #008040; }
.hp-bar.mid { background: #CC9900; } .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; display: block;
width: 100%; width: 100%;
margin: 4px 0; 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), #improve button:hover:not(:disabled),
#inventory 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), #improve button:active:not(:disabled),
#inventory button:active:not(:disabled), #inventory button:active:not(:disabled),
#actions button:active:not(:disabled) { border-style: inset; } #actions button:active:not(:disabled),
#improve button:disabled, #inventory button:disabled, #actions button: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; opacity: .45; cursor: default;
} }
@@ -284,7 +292,7 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; }
gap: 6px; gap: 6px;
justify-content: center; 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; } .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-good { color: #008040; }
.tz-bad { color: #990000; } .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 ---------- */ /* ---------- Équipement 6 emplacements ---------- */
.eq-line { margin-bottom: 6px; line-height: 1.3; } .eq-line { margin-bottom: 6px; line-height: 1.3; }
.eq-slot { .eq-slot {
+172 -25
View File
@@ -4,7 +4,7 @@
"use strict"; "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. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; const START_COMP_PCT = 90;
@@ -334,7 +334,7 @@ function makeItem(depth) {
const MAX_DEPTH = 5; const MAX_DEPTH = 5;
const PA_PER_TURN = 6; 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 let G = null; // état global de la partie
@@ -987,17 +987,112 @@ function useBagItem(idx) {
afterAction(); 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(`<b>« ${r.cry} »</b> ${r.effect}.`);
cdLine(`<i>${r.flavor}</i>`);
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 / /* É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. */ * bouclier, applique le bonus de PV max et recalcule les modificateurs. */
function equipGear(t, item) { function equipGear(t, item) {
const unequipTo = (slot) => { const unequipTo = (slot) => unequipToBag(t, 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");
};
if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) { if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) {
unequipTo("bouclier"); unequipTo("bouclier");
log("Une arme à deux mains ne laisse pas de place au bouclier.", "info"); 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); if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true);
addBtn("⏳ Passer la DLA", passDLA, 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]; const it = t.equip[slot];
if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`; if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fx = formatGearMods(it.mods); const fx = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${it.name}` + return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${esc(it.name)}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}` + `${it.twoHanded ? " <small>(2 mains)</small>" : ""}` +
` <button class="unequip-btn" data-slot="${slot}" title="Déséquiper (${COSTS.unequip} PA) : revient dans le sac">↩️</button>` +
`${fx ? `<div class="eq-mods">${fx}</div>` : ""}</div>`; `${fx ? `<div class="eq-mods">${fx}</div>` : ""}</div>`;
}).join(""); }).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"); renderBag($("inventory"), t.bag, {
inv.innerHTML = ""; disabled: G.over,
if (t.bag.length === 0) inv.innerHTML = '<div class="empty">— vide —</div>'; pa: t.pa,
t.bag.forEach((item, idx) => { onUse: useBagItem, onEat: eatBagItem, onDrop: dropBagItem,
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);
}); });
} }
/* 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 = '<div class="empty">— vide —</div>'; 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 = `<div class="bag-name">${item.emoji} ${esc(item.name)}</div>` +
(fx ? `<div class="eq-mods">${esc(fx)}</div>` : "") +
`<div class="bag-actions"></div>`;
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) { function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
} }
@@ -1491,6 +1624,20 @@ if (typeof document !== "undefined") {
if (G.monsters.includes(m)) useSort(); 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 = () => { $("btn-restart").onclick = () => {
$("screen-end").classList.add("hidden"); $("screen-end").classList.add("hidden");
@@ -1509,7 +1656,7 @@ if (typeof document !== "undefined") {
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, 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, generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT, COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT,
+16 -14
View File
@@ -316,25 +316,27 @@ function mpRenderPanels(st) {
const onItem = st.items.some(i => i.x === you.x && i.y === you.y); 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); 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]; const it = you.equip[slot];
if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`; if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fxs = formatGearMods(it.mods); const fxs = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${it.name}` + return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${esc(it.name)}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}${fxs ? `<div class="eq-mods">${fxs}</div>` : ""}</div>`; `${it.twoHanded ? " <small>(2 mains)</small>" : ""}` +
` <button class="unequip-btn" data-slot="${slot}" title="Déséquiper (${COSTS.unequip} PA) : revient dans le sac">↩️</button>` +
`${fxs ? `<div class="eq-mods">${fxs}</div>` : ""}</div>`;
}).join(""); }).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"); renderBag(document.getElementById("mp-inventory"), you.bag, {
inv.innerHTML = ""; disabled: you.dead,
if (!you.bag.length) inv.innerHTML = '<div class="empty">— vide —</div>'; pa: you.pa,
you.bag.forEach((item, idx) => { onUse: idx => mpAction({ type: "use", idx }),
const b = document.createElement("button"); onEat: idx => mpAction({ type: "eat", idx }),
const action = item.kind === "potion" ? "boire (1 PA)" : item.kind === "scroll" ? "lire (1 PA)" : "équiper (2 PA)"; onDrop: idx => mpAction({ type: "drop", idx }),
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);
}); });
} }
+32 -1
View File
@@ -488,6 +488,37 @@ function makePotionItem(potionId, power) {
return formatPotionItem(potionId, power != null ? power : def.rollPower()); 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) { function drinkPotion(troll, item, rollDice, logFn) {
const def = POTION_DEFS[item.potionId]; const def = POTION_DEFS[item.potionId];
if (!def) { if (!def) {
@@ -638,7 +669,7 @@ function talentPctWithPotions(troll, talent, cap) {
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem, POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem,
makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns, makeRandomPotion, makePotionItem, drinkPotion, goinfreItem, tickPotionTurns,
describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods, describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods,
sharedMod, makeEffect, fmtStatLine, talentPctWithPotions, corruptionYZ, sharedMod, makeEffect, fmtStatLine, talentPctWithPotions, corruptionYZ,
}; };
+30
View File
@@ -716,6 +716,36 @@ function action(world, t, act) {
return { ok: true }; 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") { if (act.type === "train") {
const stat = act.stat; const stat = act.stat;
if (!["att", "esq", "deg", "reg", "pv", "vue", "armor"].includes(stat)) return { error: "caractéristique inconnue" }; if (!["att", "esq", "deg", "reg", "pv", "vue", "armor"].includes(stat)) return { error: "caractéristique inconnue" };
+49
View File
@@ -319,6 +319,55 @@ function makeWorld(over = {}) {
assert(!mp.newTroll(w, "Banni", "Skrim").error, "son nom redevient libre"); 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) // Base de référence : les retouches survivent au redémarrage (seed non destructif)
{ {
const os = require("os"); const os = require("os");
+37
View File
@@ -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 // Validation serveur des niveaux
const srv = require("../server.js"); const srv = require("../server.js");
const goodGrid = []; const goodGrid = [];