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:
@@ -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
@@ -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
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user