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);