diff --git a/css/style.css b/css/style.css
index 241e225..c479d2d 100644
--- a/css/style.css
+++ b/css/style.css
@@ -131,6 +131,9 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
#stats div { display: flex; justify-content: space-between; padding: 2px 0; }
#stats .stat-val { font-family: monospace; color: #f0e6c0; }
+#stats .stat-val small { color: #8a7a55; font-size: 0.85em; }
+.potion-fx { margin-top: 8px; color: #d8a040; font-size: 0.9em; border-top: 1px solid #4a3a20; padding-top: 6px; }
+.potion-fx-line { font-size: 0.82em; color: #a89568; padding: 1px 0 1px 8px; }
.hp-bar-wrap { background: #3a2a1a; border-radius: 4px; height: 14px; margin: 6px 0; overflow: hidden; }
.hp-bar { background: #b03030; height: 100%; transition: width .2s; }
diff --git a/index.html b/index.html
index ae5a681..dc22870 100644
--- a/index.html
+++ b/index.html
@@ -144,6 +144,7 @@
+
diff --git a/js/editor.js b/js/editor.js
index 20d3f91..325c9de 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -121,7 +121,7 @@ function edBuildPalette() {
const itemsDiv = document.getElementById("ed-items");
itemsDiv.innerHTML = "";
- edAddBrushBtn(itemsDiv, "🧪 Potion de Vie", { mode: "item", kind: "potion" });
+ edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" });
edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" });
WEAPONS.forEach((w, i) => edAddBrushBtn(itemsDiv, `${w.emoji} ${w.name} (+${w.bonus})`, { mode: "item", kind: "weapon", idx: i }));
ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i }));
diff --git a/js/game.js b/js/game.js
index 2c31b97..a6393d4 100644
--- a/js/game.js
+++ b/js/game.js
@@ -6,6 +6,11 @@
const APP_VERSION = "0.1.5";
+/* potions.js est chargé avant ce fichier dans le navigateur ; en node, require explicite. */
+if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion === "undefined") {
+ Object.assign(globalThis, require("./potions.js"));
+}
+
/* ================= Dés & règles de base ================= */
function rollDice(n, faces) {
@@ -42,9 +47,10 @@ function resolveAttack(attacker, defender, opts = {}) {
* +1D6 % jusqu'à 50 %, +1D3 % jusqu'à 75 %, +1 % ensuite.
* Sous 50 %, un échec fait quand même progresser de 1 % (on apprend de ses ratés).
* Plafond `cap` : 90 % pour les compétences, 80 % pour les sortilèges. */
-function masteryRoll(talent, cap) {
+function masteryRoll(talent, cap, threshold) {
+ const pct = threshold != null ? threshold : talent.pct;
const roll = 1 + Math.floor(Math.random() * 100);
- const success = roll <= talent.pct;
+ const success = roll <= pct;
let gain = 0;
if (success) {
gain = talent.pct < 50 ? rollDice(1, 6).total : talent.pct < 75 ? rollDice(1, 3).total : 1;
@@ -185,7 +191,7 @@ function monsterFromSpec(spec) {
/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, idx?, gold?} */
function itemFromSpec(spec) {
const base = { x: spec.x, y: spec.y };
- if (spec.kind === "potion") return { ...base, kind: "potion", name: "Potion de Vie", emoji: "🧪" };
+ if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) };
if (spec.kind === "gold") {
const gold = spec.gold || 60;
return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold };
@@ -280,7 +286,7 @@ const ARMORS = [
function makeItem(depth) {
const r = Math.random();
- if (r < 0.35) return { kind: "potion", name: "Potion de Vie", emoji: "🧪" };
+ if (r < 0.35) return makeRandomPotion();
if (r < 0.55) {
const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))];
return { kind: "gear", ...w };
@@ -315,7 +321,8 @@ function newGame(name, race, customLevel = null) {
compPXTurn: false, sortPXTurn: false,
weapon: null, armorItem: null,
pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
- bag: [], camo: false, kills: 0, dla: 1,
+ bag: [], camo: false, kills: 0, dla: 1, tour: 1,
+ potionEffects: [], blockCamoTurns: 0,
},
depth: 1, grid: null, monsters: [], items: [], doors: [], stairs: null,
seen: new Set(), over: false,
@@ -420,7 +427,7 @@ function buildLevel() {
function updateFov() {
const { x, y } = G.troll;
- const r = G.troll.vue;
+ const r = effTroll(G.troll).vue;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const tx = x + dx, ty = y + dy;
@@ -432,7 +439,18 @@ function updateFov() {
function inSight(e) {
const dx = e.x - G.troll.x, dy = e.y - G.troll.y;
- return dx * dx + dy * dy <= G.troll.vue * G.troll.vue;
+ const vue = effTroll(G.troll).vue;
+ return dx * dx + dy * dy <= vue * vue;
+}
+
+function spellMM() {
+ const eff = effTroll(G.troll);
+ return trollMM() + Math.round(eff.mmPct / 10);
+}
+
+function spellRM(m) {
+ const eff = effTroll(G.troll);
+ return monsterRM(m) + Math.round(eff.rmPct / 10);
}
/* ================= Actions du troll ================= */
@@ -501,7 +519,7 @@ function cdAttackRolls(r) {
/* Jet de résistance magique au format MH. */
function resistInfo(m) {
- const r = resolveSpell(trollMM(), monsterRM(m));
+ const r = resolveSpell(spellMM(), spellRM(m));
cdLine(`Seuil de Résistance de la cible : ${r.sr} %`);
cdLine(`Jet de Résistance : ${r.roll}`);
cdLine(r.success
@@ -554,7 +572,7 @@ function attackMonster(m, opts = {}) {
if (!spendPA(cost)) return false;
cdStart(`⚔️ Attaque sur ${m.name}`);
cdLine(`Vous avez attaqué ${m.name} avec votre ${G.troll.weapon ? G.troll.weapon.name : "Grosse Patte de Trõll"}.`);
- const r = resolveAttack(G.troll, effMonster(m));
+ const r = resolveAttack(effTroll(G.troll), effMonster(m));
cdAttackRolls(r);
if (r.hit) {
m.pv -= r.damage;
@@ -600,7 +618,7 @@ function killMonster(m, source = "attack") {
// butin : les monstres lâchent parfois un trésor en mourant
if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) {
const drop = Math.random() < 0.5
- ? { kind: "potion", name: "Potion de Vie", emoji: "🧪", x: m.x, y: m.y }
+ ? { ...makeRandomPotion(), x: m.x, y: m.y }
: { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y };
G.items.push(drop);
cdLine(`Il a de plus laissé tomber ${drop.emoji} ${drop.name}.`);
@@ -633,12 +651,14 @@ function cdClose() {
function tryTalent(talent, cap, cost, label, kind) {
if (!spendPA(cost)) return false;
cdStart(label);
- const before = talent.pct; // le jet se compare à la maîtrise d'avant progression
- const r = masteryRoll(talent, cap);
+ const before = talent.pct; // le jet se compare à la maîtrise d'avant progression (+ potions)
+ const effPct = talentPctWithPotions(G.troll, talent, cap);
+ const r = masteryRoll(talent, cap, effPct);
talent.tries = (talent.tries || 0) + 1;
if (r.success) talent.successes = (talent.successes || 0) + 1;
talent.lastUse = Date.now();
- cdLine(`Jet de maîtrise : ${r.roll} (il fallait ${before} % ou moins)`);
+ const pctLabel = effPct !== before ? `${effPct} % (base ${before} %)` : `${before} %`;
+ cdLine(`Jet de maîtrise : ${r.roll} (il fallait ${pctLabel} ou moins)`);
if (!r.success) {
G.troll.pa += Math.floor(cost / 2);
if (r.gain) cdLine(`Vous avez augmenté votre Maîtrise de 1 point en apprenant de votre raté (→ ${talent.pct} %).`);
@@ -662,6 +682,7 @@ function tryTalent(talent, cap, cost, label, kind) {
function useComp() {
if (G.over) return;
const t = G.troll;
+ const te = effTroll(t);
const comp = RACES[t.race].comp;
if (t.race === "Skrim") { // Botte Secrète : attaque bonus, 1 fois par DLA
@@ -671,7 +692,7 @@ function useComp() {
if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète", "comp")) { cdFlush(); afterAction(); return; }
t.compUsed = true;
cdLine(`Vous portez une Botte Secrète à ${m.name}.`);
- const pseudo = { att: Math.max(1, Math.floor(t.att * 2 / 3)), deg: Math.max(1, Math.floor(t.att / 2)), degBonus: 0 };
+ const pseudo = { att: Math.max(1, Math.floor(te.att * 2 / 3)), deg: Math.max(1, Math.floor(te.att / 2)), degBonus: te.degBonus };
const r = resolveAttack(pseudo, effMonster(m));
cdAttackRolls(r);
if (r.hit) {
@@ -699,6 +720,7 @@ function useComp() {
log(`Accélération du Métabolisme : −${cost} PV, +4 PA (fatigue ${t.fatigue}).`, "good");
if (t.pv <= 0) { cdClose(); die({ name: "son propre métabolisme" }); return; }
} else if (t.race === "Tomawak") { // Camouflage : invisible tant qu'on n'attaque pas
+ if (t.blockCamoTurns > 0) { log("La Pàïntûré t'empêche de te camoufler.", "info"); return; }
if (t.camo) { log("Tu es déjà camouflé.", "info"); return; }
if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage", "comp")) { cdFlush(); afterAction(); return; }
t.camo = true;
@@ -710,7 +732,7 @@ function useComp() {
if (!m) { log("Aucun monstre adjacent.", "info"); return; }
if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage", "comp")) { cdFlush(); afterAction(); return; }
t.compUsed = true;
- const destab = rollDice(t.att, 6).total;
+ const destab = rollDice(te.att, 6).total;
const stab = rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total;
cdLine(`Vous balayez ${m.name}.`);
cdLine(`Votre jet de Déstabilisation est de : ${destab}`);
@@ -731,6 +753,7 @@ function useComp() {
function useSort() {
if (G.over) return;
const t = G.troll;
+ const te = effTroll(t);
const sort = RACES[t.race].sort;
if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour
@@ -753,7 +776,7 @@ function useSort() {
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
cdLine(`Votre jet d'Attaque est : automatiquement réussi (imparable).`);
- let dmg = rollDice(t.deg, 3).total;
+ let dmg = rollDice(te.deg, 3).total;
const res = resistInfo(m);
if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
@@ -765,7 +788,7 @@ function useSort() {
if (!m) { log("Aucun monstre adjacent.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
- const pseudo = { att: Math.max(1, Math.floor(t.deg * 2 / 3)), deg: t.deg, degBonus: 0 };
+ const pseudo = { att: Math.max(1, Math.floor(te.deg * 2 / 3)), deg: te.deg, degBonus: te.degBonus };
const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) {
@@ -787,9 +810,9 @@ function useSort() {
if (!m) { log("Aucun monstre en vue.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Projectile Magique", "sort")) { cdFlush(); afterAction(); return; }
const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y));
- const proxBonus = Math.max(0, t.vue - dist);
+ const proxBonus = Math.max(0, te.vue - dist);
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège (distance ${dist}, bonus de proximité +${proxBonus}D6).`);
- const pseudo = { att: t.vue + proxBonus, deg: Math.max(1, Math.floor(t.vue / 2)), degBonus: 0 };
+ const pseudo = { att: te.vue + proxBonus, deg: Math.max(1, Math.floor(te.vue / 2)), degBonus: 0 };
const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) {
@@ -814,7 +837,7 @@ function useSort() {
if (!m) { log("Aucun monstre adjacent.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
- const pseudo = { att: t.att, deg: t.reg, degBonus: 0 };
+ const pseudo = { att: te.att, deg: te.reg, degBonus: 0 };
const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) {
@@ -822,7 +845,7 @@ function useSort() {
const res = resistInfo(m);
if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg;
- const necrose = res ? Math.max(1, Math.floor(t.reg / 2)) : t.reg;
+ const necrose = res ? Math.max(1, Math.floor(te.reg / 2)) : te.reg;
m.attDownDice = (m.attDownDice || 0) + necrose;
m.attDownTurns = 2;
cdLine(`Vous lui avez infligé ${dmg} points de dégâts (toute armure ignorée).`);
@@ -881,10 +904,9 @@ function useBagItem(idx) {
if (!item) return;
if (item.kind === "potion") {
if (!spendPA(COSTS.potion)) return;
- const heal = rollDice(2, 6).total + 3;
- t.pv = Math.min(t.pvMax, t.pv + heal);
+ if (!drinkPotion(t, item, rollDice, log)) return;
t.bag.splice(idx, 1);
- log(`🧪 Glou glou : +${heal} PV.`, "good");
+ updateFov();
} else if (item.kind === "gear") {
if (!spendPA(COSTS.equip)) return;
if (item.slot === "weapon") {
@@ -936,6 +958,7 @@ function passDLA() {
if (G.over) return;
const t = G.troll;
t.dla++;
+ const dlaBonusPA = tickPotionTurns(t, log);
// Tour des monstres
for (const m of G.monsters) {
@@ -949,7 +972,7 @@ function passDLA() {
const seesTroll = !t.camo && dist <= m.vue;
if (dist <= 1 && !t.camo) {
const eff = effMonster(m);
- const r = resolveAttack(eff, t);
+ const r = resolveAttack(eff, effTroll(t));
cdStart(`${m.emoji} ${m.name} vous attaque`);
cdLine(`Son jet d'Attaque est de : ${r.attRoll} (${r.attDice}D6)`);
cdLine(`Votre jet d'Esquive est de : ${r.esqRoll} (${r.esqDice}D6)`);
@@ -975,16 +998,17 @@ function passDLA() {
if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0;
}
- // Régénération (REG D3, comme à MountyHall)
+ // Régénération (REG D3, comme à MountyHall) — bonus potions pris en compte
+ const te = effTroll(t);
if (t.pv < t.pvMax && t.pv > 0) {
- const r = rollDice(t.reg, 3);
+ const r = rollDice(te.reg, 3);
t.pv = Math.min(t.pvMax, t.pv + r.total);
- log(`💤 Nouvelle DLA n°${t.dla} : tu régénères ${r.total} PV (${t.reg}D3).`, "info");
+ log(`💤 Tour n°${t.tour} (DLA n°${t.dla}) : tu régénères ${r.total} PV (${te.reg}D3).`, "info");
} else {
- log(`💤 Nouvelle DLA n°${t.dla}.`, "info");
+ log(`💤 Tour n°${t.tour} (DLA n°${t.dla}).`, "info");
}
- t.pa = PA_PER_TURN;
+ t.pa = Math.min(PA_PER_TURN + 3, PA_PER_TURN + dlaBonusPA);
t.compUsed = false;
t.compPXTurn = false;
t.sortPXTurn = false;
@@ -1061,7 +1085,8 @@ function render() {
const key = y * MAP_W + x;
const px = x * TILE, py = y * TILE;
if (!G.seen.has(key)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, TILE, TILE); continue; }
- const visible = (x - G.troll.x) ** 2 + (y - G.troll.y) ** 2 <= G.troll.vue ** 2;
+ const vue = effTroll(G.troll).vue;
+ const visible = (x - G.troll.x) ** 2 + (y - G.troll.y) ** 2 <= vue ** vue;
const t = G.grid[y][x];
if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315";
else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522";
@@ -1082,7 +1107,7 @@ function render() {
};
for (const i of G.items) {
if (!G.seen.has(i.y * MAP_W + i.x)) continue;
- disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? "#5d8535" : "#7a8db0");
+ disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? (i.color || "#5d8535") : "#7a8db0");
ctx.fillStyle = "#1a140e";
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
}
@@ -1118,17 +1143,25 @@ function renderPanels() {
: `Profondeur −${G.depth} · DLA n°${t.dla}`;
$("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`;
+ const te = effTroll(t);
+ const statFmt = (base, eff, suffix) => {
+ const d = eff - base;
+ return d ? `${eff}${suffix} (${base}${d > 0 ? "+" : ""}${d})` : `${base}${suffix}`;
+ };
const pct = Math.max(0, t.pv / t.pvMax);
const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : "";
+ const potionLines = describeActiveEffects(t);
$("stats").innerHTML = `
+
Tour${t.tour}
PV${t.pv} / ${t.pvMax}
- Attaque${t.att}D6
- Esquive${t.esq}D6
- Dégâts${t.deg}D3${t.degBonus ? "+" + t.degBonus : ""}
- Régénération${t.reg}D3
- Armure${t.armor}${t.armorDice ? "+" + t.armorDice + "D3" : ""}
- Vue${t.vue}
+ Attaque${statFmt(t.att, te.att, "D6")}
+ Esquive${statFmt(t.esq, te.esq, "D6")}
+ Dégâts${statFmt(t.deg, te.deg, "D3")}${te.degBonus ? "+" + te.degBonus : ""}
+ Régénération${statFmt(t.reg, te.reg, "D3")}
+ Armure${statFmt(t.armor, te.armor, "")}${t.armorDice ? "+" + t.armorDice + "D3" : ""}
+ Vue${statFmt(t.vue, te.vue, "")}
+ ${potionLines.length ? `🧪 Effets
${potionLines.map(l => `${l}
`).join("")}` : ""}
${RACES[t.race].comp.name}${t.comp.pct} %
${RACES[t.race].sort.name}${t.sort.pct} %
${t.race === "Kastar" ? `Fatigue${t.fatigue}
` : ""}
diff --git a/js/potions.js b/js/potions.js
new file mode 100644
index 0000000..b19ca5f
--- /dev/null
+++ b/js/potions.js
@@ -0,0 +1,448 @@
+/* Potions MountyHall — effets et durée en tours (1 tour = 1 DLA passée).
+ * Source : https://mountypedia.mountyhall.com/Mountyhall/Potion */
+
+"use strict";
+
+const POTION_IDS = [
+ "biskot", "doverPowa", "bonneBouffe", "corruption", "fertilite", "feu", "longueVue",
+ "kouleMann", "djhinTonik", "glacier", "calvok", "rhume", "grippe", "pneumonie",
+ "cervelle", "chronometre", "metomol", "guerison", "painture", "pufPuff", "sangToh",
+ "sinneKhole", "toxine", "voiputrin", "zetCrak",
+];
+
+function potRand(min, max) {
+ return min + Math.floor(Math.random() * (max - min + 1));
+}
+
+function potPick(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function corruptionYZ(x) {
+ if (x <= 3) return { y: 0, z: 0 };
+ if (x <= 5) return { y: potRand(5, 10), z: potRand(5, 10) };
+ if (x === 6) return { y: potRand(11, 20), z: potRand(11, 20) };
+ return { y: potRand(16, 30), z: potRand(16, 30) };
+}
+
+/* Fabrique un effet actif (durée en tours restants). */
+function makeEffect(name, emoji, turnsLeft, mods = {}) {
+ return { name, emoji, turnsLeft, ...mods };
+}
+
+const POTION_DEFS = {
+ biskot: {
+ name: "Biskot", emoji: "🍪", color: "#c4a574",
+ duration: 2,
+ rollPower: () => 0,
+ build(troll, _p, rollDice) {
+ const heal = rollDice(2, 3).total;
+ troll.pv = Math.min(troll.pvMax, troll.pv + heal);
+ return {
+ log: `Biskot : +2 REG et +${heal} PV (${heal} = 2D3) pendant 2 tours.`,
+ effects: [makeEffect("Biskot", "🍪", 2, { reg: 2, biskot: true })],
+ };
+ },
+ },
+ doverPowa: {
+ name: "Dover Powa", emoji: "⚡", color: "#6eb5ff",
+ duration: 2,
+ rollPower: () => potRand(11, 100),
+ build(troll, p) {
+ const yy = potRand(11, 100);
+ return {
+ log: `Dover Powa : MM +${yy} %, RM −${p} % pendant 2 tours.`,
+ effects: [makeEffect("Dover Powa", "⚡", 2, { mmPct: yy, rmPct: -p })],
+ };
+ },
+ },
+ bonneBouffe: {
+ name: "Elixir de Bonne Bouffe", emoji: "🍖", color: "#d47848",
+ duration: 5,
+ rollPower: () => potRand(3, 7),
+ build(_t, p) {
+ return {
+ log: `Elixir de Bonne Bouffe : DEG +${p}, REG +${p} pendant 5 tours.`,
+ effects: [makeEffect("Elixir de Bonne Bouffe", "🍖", 5, { deg: p, reg: p })],
+ };
+ },
+ },
+ corruption: {
+ name: "Elixir de Corruption", emoji: "☠️", color: "#4a2858",
+ duration: 5,
+ rollPower: () => potRand(3, 7),
+ build(_t, p) {
+ const { y, z } = corruptionYZ(p);
+ return {
+ log: `Elixir de Corruption : malus multiples (X=${p}, RM −${y} %, MM −${z} %) pendant 5 tours.`,
+ effects: [makeEffect("Elixir de Corruption", "☠️", 5, {
+ att: p, esq: -p, deg: p, reg: -p, vue: -p, armor: p, rmPct: -y, mmPct: -z,
+ })],
+ };
+ },
+ },
+ fertilite: {
+ name: "Elixir de Fertilité", emoji: "🌱", color: "#5d9a48",
+ duration: 5,
+ rollPower: () => potRand(3, 7),
+ build(_t, p) {
+ return {
+ log: `Elixir de Fertilité : ATT +${p}D6, DEG +${p} pendant 5 tours.`,
+ effects: [makeEffect("Elixir de Fertilité", "🌱", 5, { att: p, deg: p })],
+ };
+ },
+ },
+ feu: {
+ name: "Elixir de Feu", emoji: "🔥", color: "#e85830",
+ duration: 5,
+ rollPower: () => potRand(3, 7),
+ build(_t, p) {
+ return {
+ log: `Elixir de Feu : ESQ +${p}D6, VUE +${p} pendant 5 tours.`,
+ effects: [makeEffect("Elixir de Feu", "🔥", 5, { esq: p, vue: p })],
+ };
+ },
+ },
+ longueVue: {
+ name: "Elixir de Longue-Vue", emoji: "🔭", color: "#88a8c8",
+ duration: 3,
+ rollPower: () => potPick([1, 2, 3, 5, 8]),
+ build(_t, p) {
+ return {
+ log: `Elixir de Longue-Vue : VUE +${p} pendant 3 tours.`,
+ effects: [makeEffect("Elixir de Longue-Vue", "🔭", 3, { vue: p })],
+ };
+ },
+ },
+ kouleMann: {
+ name: "Essence de KouleMann", emoji: "🎵", color: "#9a78c8",
+ duration: 4,
+ rollPower: () => potRand(1, 5),
+ build(troll, p, rollDice) {
+ const dice = Math.max(1, Math.floor(p / 2));
+ const heal = rollDice(dice, 3).total;
+ troll.pv = Math.min(troll.pvMax, troll.pv + heal);
+ return {
+ log: `Essence de KouleMann : REG +${p}, VUE +${p}, +${heal} PV (${dice}D3) pendant 4 tours.`,
+ effects: [makeEffect("Essence de KouleMann", "🎵", 4, { reg: p, vue: p })],
+ };
+ },
+ },
+ djhinTonik: {
+ name: "Extrait de DjhinTonik", emoji: "🧞", color: "#48a8a8",
+ duration: 4,
+ rollPower: () => potRand(1, 5),
+ build(troll, p, rollDice) {
+ const heal = rollDice(2, 3).total;
+ troll.pv = Math.min(troll.pvMax, troll.pv + heal);
+ return {
+ log: `Extrait de DjhinTonik : DEG +${p}, REG +${p}, +${heal} PV (2D3) pendant 4 tours.`,
+ effects: [makeEffect("Extrait de DjhinTonik", "🧞", 4, { deg: p, reg: p })],
+ };
+ },
+ },
+ glacier: {
+ name: "Extrait du Glacier", emoji: "🧊", color: "#a8d8f0",
+ duration: 5,
+ rollPower: () => potRand(3, 7),
+ build(_t, p) {
+ return {
+ log: `Extrait du Glacier : REG +${p}, Armure +${p} pendant 5 tours.`,
+ effects: [makeEffect("Extrait du Glacier", "🧊", 5, { reg: p, armor: p })],
+ };
+ },
+ },
+ calvok: {
+ name: "Fiole de Calvok", emoji: "🥃", color: "#d8c090",
+ duration: 1,
+ rollPower: () => potRand(2, 6),
+ build(_t, p) {
+ const malus = p * 5;
+ return {
+ log: `Fiole de Calvok : Concentration −${malus} % pendant 1 tour.`,
+ effects: [makeEffect("Fiole de Calvok", "🥃", 1, { concentrationPct: -malus })],
+ };
+ },
+ },
+ rhume: {
+ name: "Rhume en Conserve", emoji: "🤧", color: "#c8d8a8",
+ duration: 3,
+ rollPower: () => potRand(1, 2),
+ build(_t, p) {
+ return {
+ log: `Rhume en Conserve : ATT/ESQ/DEG/REG −${p} pendant 3 tours.`,
+ effects: [makeEffect("Rhume en Conserve", "🤧", 3, { att: -p, esq: -p, deg: -p, reg: -p })],
+ };
+ },
+ },
+ grippe: {
+ name: "Grippe en Conserve", emoji: "🤒", color: "#d8a848",
+ duration: 3,
+ rollPower: () => potRand(3, 4),
+ build(_t, p) {
+ return {
+ log: `Grippe en Conserve : ATT/ESQ/DEG/REG −${p} pendant 3 tours.`,
+ effects: [makeEffect("Grippe en Conserve", "🤒", 3, { att: -p, esq: -p, deg: -p, reg: -p })],
+ };
+ },
+ },
+ pneumonie: {
+ name: "Pneumonie en Conserve", emoji: "😷", color: "#a87878",
+ duration: 3,
+ rollPower: () => 5,
+ build(_t, _p) {
+ return {
+ log: "Pneumonie en Conserve : ATT/ESQ/DEG/REG −5 pendant 3 tours.",
+ effects: [makeEffect("Pneumonie en Conserve", "😷", 3, { att: -5, esq: -5, deg: -5, reg: -5 })],
+ };
+ },
+ },
+ cervelle: {
+ name: "Jus de Cervelle", emoji: "🧠", color: "#e8a8c8",
+ duration: 1,
+ rollPower: () => potRand(2, 6),
+ build(_t, p) {
+ const bonus = p * 5;
+ return {
+ log: `Jus de Cervelle : Concentration +${bonus} % pendant 1 tour.`,
+ effects: [makeEffect("Jus de Cervelle", "🧠", 1, { concentrationPct: bonus })],
+ };
+ },
+ },
+ chronometre: {
+ name: "Jus de Chronomètre", emoji: "⏱️", color: "#c8c848",
+ duration: 3,
+ rollPower: () => potRand(1, 5),
+ build(_t, p) {
+ return {
+ log: `Jus de Chronomètre : DLA accélérée (+${p} PA en début de tour) pendant 3 tours.`,
+ effects: [makeEffect("Jus de Chronomètre", "⏱️", 3, { dlaBonusPA: p })],
+ };
+ },
+ },
+ metomol: {
+ name: "Métomol", emoji: "💊", color: "#a8a8d8",
+ duration: 2,
+ rollPower: () => potRand(1, 5),
+ build(_t, p) {
+ return {
+ log: `Métomol : DLA accélérée (+${p} PA) et Armure −${2 * p} pendant 2 tours.`,
+ effects: [makeEffect("Métomol", "💊", 2, { dlaBonusPA: p, armor: -2 * p })],
+ };
+ },
+ },
+ guerison: {
+ name: "Potion de Guérison", emoji: "🧪", color: "#5d8535",
+ duration: 0,
+ rollPower: () => potRand(1, 5),
+ build(troll, p, rollDice) {
+ const heal = rollDice(2 * p, 3).total;
+ troll.pv = Math.min(troll.pvMax, troll.pv + heal);
+ return { log: `Potion de Guérison : +${heal} PV (${2 * p}D3).`, effects: [] };
+ },
+ },
+ painture: {
+ name: "Potion de Pàïntûré", emoji: "🎨", color: "#e878a8",
+ duration: 0,
+ rollPower: () => potRand(1, 5),
+ build(troll, p) {
+ troll.camo = false;
+ troll.blockCamoTurns = Math.max(troll.blockCamoTurns || 0, p);
+ return {
+ log: `Potion de Pàïntûré : visible et impossible de se camoufler pendant ${p} tour(s).`,
+ effects: [makeEffect("Potion de Pàïntûré", "🎨", p, { blockCamo: true })],
+ };
+ },
+ },
+ pufPuff: {
+ name: "PufPuff", emoji: "💨", color: "#b8b8b8",
+ duration: 3,
+ rollPower: () => potRand(0, 2),
+ build(troll, p, rollDice) {
+ const y = p >= 2 ? rollDice(2, 3).total : 0;
+ if (y > 0) troll.pv = Math.max(1, troll.pv - y);
+ return {
+ log: `PufPuff : ATT/ESQ −${p}D6, VUE −${p + 1}${y ? `, −${y} PV` : ""} pendant 3 tours.`,
+ effects: [makeEffect("PufPuff", "💨", 3, { att: -p, esq: -p, vue: -(p + 1) })],
+ };
+ },
+ },
+ sangToh: {
+ name: "Sang de Toh Réroh", emoji: "🩸", color: "#a83030",
+ duration: 4,
+ rollPower: () => potRand(1, 5),
+ build(_t, p) {
+ return {
+ log: `Sang de Toh Réroh : ATT +${p}D6, ESQ +${p}D6, VUE +${p} pendant 4 tours.`,
+ effects: [makeEffect("Sang de Toh Réroh", "🩸", 4, { att: p, esq: p, vue: p })],
+ };
+ },
+ },
+ sinneKhole: {
+ name: "Sinne Khole", emoji: "🕳️", color: "#383838",
+ duration: 2,
+ rollPower: () => potRand(11, 100),
+ build(_t, p) {
+ const yy = potRand(11, 100);
+ return {
+ log: `Sinne Khole : RM +${p} %, MM −${yy} % pendant 2 tours.`,
+ effects: [makeEffect("Sinne Khole", "🕳️", 2, { rmPct: p, mmPct: -yy })],
+ };
+ },
+ },
+ toxine: {
+ name: "Toxine Violente", emoji: "☣️", color: "#78c848",
+ duration: 0,
+ rollPower: () => potRand(1, 5),
+ build(troll, p, rollDice) {
+ const dmg = rollDice(2 * p, 3).total;
+ troll.pv = Math.max(1, troll.pv - dmg);
+ return { log: `Toxine Violente : −${dmg} PV (${2 * p}D3).`, effects: [] };
+ },
+ },
+ voiputrin: {
+ name: "Voï'Pu'Rin", emoji: "🌫️", color: "#686868",
+ duration: 2,
+ rollPower: () => potRand(1, 5),
+ build(_t, p) {
+ const malus = 10 * p;
+ return {
+ log: `Voï'Pu'Rin : VUE −${malus} pendant 2 tours.`,
+ effects: [makeEffect("Voï'Pu'Rin", "🌫️", 2, { vue: -malus })],
+ };
+ },
+ },
+ zetCrak: {
+ name: "Zet Crakdedand", emoji: "🦷", color: "#d8d0a0",
+ duration: 3,
+ rollPower: () => potRand(1, 5),
+ build(troll, p, rollDice) {
+ const heal = rollDice(p, 3).total;
+ troll.pv = Math.min(troll.pvMax, troll.pv + heal);
+ return {
+ log: `Zet Crakdedand : ATT/ESQ/VUE −${p}, +${heal} PV (${p}D3) pendant 3 tours.`,
+ effects: [makeEffect("Zet Crakdedand", "🦷", 3, { att: -p, esq: -p, vue: -p })],
+ };
+ },
+ },
+};
+
+function sumPotionMods(effects) {
+ const m = {
+ att: 0, esq: 0, deg: 0, reg: 0, vue: 0, armor: 0,
+ mmPct: 0, rmPct: 0, concentrationPct: 0, dlaBonusPA: 0,
+ };
+ for (const e of effects || []) {
+ for (const k of Object.keys(m)) {
+ if (e[k]) m[k] += e[k];
+ }
+ }
+ return m;
+}
+
+function effTroll(troll) {
+ const m = sumPotionMods(troll.potionEffects);
+ return {
+ att: Math.max(1, troll.att + m.att),
+ esq: Math.max(1, troll.esq + m.esq),
+ deg: Math.max(1, troll.deg + m.deg),
+ reg: Math.max(1, troll.reg + m.reg),
+ vue: Math.max(1, troll.vue + m.vue),
+ armor: Math.max(0, troll.armor + m.armor),
+ armorDice: troll.armorDice,
+ degBonus: troll.degBonus,
+ pvMax: troll.pvMax,
+ mmPct: m.mmPct,
+ rmPct: m.rmPct,
+ concentrationPct: m.concentrationPct,
+ dlaBonusPA: m.dlaBonusPA,
+ };
+}
+
+function formatPotionItem(potionId, power) {
+ const def = POTION_DEFS[potionId];
+ if (!def) return null;
+ const suffix = def.duration === 0 && (potionId === "guerison" || potionId === "toxine")
+ ? ` (${2 * power}D3)` : ` (niv. ${power})`;
+ return {
+ kind: "potion",
+ potionId,
+ power,
+ name: def.name + suffix,
+ emoji: def.emoji,
+ color: def.color,
+ };
+}
+
+function makeRandomPotion() {
+ const id = POTION_IDS[Math.floor(Math.random() * POTION_IDS.length)];
+ const def = POTION_DEFS[id];
+ return formatPotionItem(id, def.rollPower());
+}
+
+function makePotionItem(potionId, power) {
+ const def = POTION_DEFS[potionId];
+ if (!def) return makeRandomPotion();
+ return formatPotionItem(potionId, power != null ? power : def.rollPower());
+}
+
+function drinkPotion(troll, item, rollDice, logFn) {
+ const def = POTION_DEFS[item.potionId];
+ if (!def) {
+ logFn("Cette fiole est vide ou inconnue.", "info");
+ return false;
+ }
+ const p = item.power != null ? item.power : def.rollPower();
+ const result = def.build(troll, p, rollDice);
+ if (result.effects.length) {
+ troll.potionEffects = troll.potionEffects || [];
+ troll.potionEffects.push(...result.effects);
+ }
+ logFn(result.log, result.effects.length ? "good" : "good");
+ return true;
+}
+
+/* Fin de tour (DLA passée) : décrémente les effets, retire les expirés. */
+function tickPotionTurns(troll, logFn) {
+ troll.tour = (troll.tour || 1) + 1;
+ if (troll.blockCamoTurns > 0) troll.blockCamoTurns--;
+ const expired = [];
+ troll.potionEffects = (troll.potionEffects || []).filter(e => {
+ if (e.blockCamo) return troll.blockCamoTurns > 0;
+ e.turnsLeft -= 1;
+ if (e.turnsLeft <= 0) {
+ expired.push(e.name);
+ return false;
+ }
+ return true;
+ });
+ for (const name of expired) {
+ logFn(`L'effet de ${name} s'est dissipé.`, "info");
+ }
+ return sumPotionMods(troll.potionEffects).dlaBonusPA;
+}
+
+function describeActiveEffects(troll) {
+ const lines = [];
+ if (troll.blockCamoTurns > 0) {
+ lines.push(`🎨 Pàïntûré (${troll.blockCamoTurns} tour(s))`);
+ }
+ for (const e of troll.potionEffects || []) {
+ if (e.blockCamo) continue;
+ lines.push(`${e.emoji} ${e.name} (${e.turnsLeft} tour(s))`);
+ }
+ return lines;
+}
+
+function talentPctWithPotions(troll, talent, cap) {
+ const eff = effTroll(troll);
+ return Math.max(0, Math.min(cap, talent.pct + eff.concentrationPct));
+}
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = {
+ POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem,
+ makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns,
+ describeActiveEffects, talentPctWithPotions, corruptionYZ,
+ };
+}
diff --git a/test/smoke.js b/test/smoke.js
index 5f208ee..3443eac 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -2,7 +2,9 @@
"use strict";
const assert = require("assert");
+require("../js/potions.js");
const g = require("../js/game.js");
+const p = require("../js/potions.js");
// Dés
for (let i = 0; i < 200; i++) {
@@ -154,6 +156,23 @@ assert.strictEqual(boss.name, "Béhémoth");
assert(boss.pv === boss.pvMax && boss.pv > 0);
const pot = g.itemFromSpec({ x: 1, y: 1, kind: "potion" });
assert.strictEqual(pot.kind, "potion");
+assert(p.POTION_IDS.length === 25, "25 potions droppables");
+assert.strictEqual(p.POTION_DEFS.guerison.duration, 0);
+assert.strictEqual(p.corruptionYZ(3).y, 0);
+{ const { y } = p.corruptionYZ(6); assert(y >= 11 && y <= 20, "Y pour X=6 : " + y); }
+{
+ const troll = { att: 3, esq: 3, deg: 3, reg: 1, vue: 3, armor: 0, armorDice: 0, degBonus: 0, pvMax: 30, pv: 20, potionEffects: [], blockCamoTurns: 0, tour: 1 };
+ const item = p.makePotionItem("bonneBouffe", 5);
+ p.drinkPotion(troll, item, g.rollDice, () => {});
+ assert.strictEqual(troll.potionEffects.length, 1);
+ assert.strictEqual(p.effTroll(troll).deg, 8);
+ p.tickPotionTurns(troll, () => {});
+ assert.strictEqual(troll.tour, 2);
+ troll.potionEffects[0].turnsLeft = 0;
+ p.tickPotionTurns(troll, () => {});
+ assert.strictEqual(troll.potionEffects.length, 0);
+ assert.strictEqual(p.effTroll(troll).deg, 3);
+}
const sword = g.itemFromSpec({ x: 1, y: 1, kind: "weapon", idx: 2 });
assert.strictEqual(sword.kind, "gear");
assert(sword.bonus > 0);