From fa3ff297098da58cf41efc7524526b497f994ad3 Mon Sep 17 00:00:00 2001 From: Cedric Date: Thu, 11 Jun 2026 16:53:26 +0200 Subject: [PATCH] 0.1.5 : modification calcul PX --- js/game.js | 82 ++++++++++++++++++++++++++++++++++++--------------- test/smoke.js | 6 ++++ 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/js/game.js b/js/game.js index 5e7b21d..25769e0 100644 --- a/js/game.js +++ b/js/game.js @@ -80,6 +80,11 @@ function levelFromTotalPI(totalPI) { return level; } +/* PX de mise à mort : 10 + 2 × (niveau cible − niveau troll) + niveau cible. */ +function killPX(trollLevel, targetLevel) { + return 10 + 2 * (targetLevel - trollLevel) + targetLevel; +} + /* ================= Les 5 races ================= */ /* Profils de base officiels (mountyhall.com/MH_Rules/Races_*.php) : @@ -305,6 +310,7 @@ function newGame(name, race, customLevel = null) { degBonus: 0, armor: 0, armorDice: 0, bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 }, comp: { pct: 15 }, sort: { pct: 15 }, fatigue: 0, compUsed: false, + 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, @@ -465,6 +471,14 @@ function cdFlush() { /* Gain de PX (convertis en PI) comptabilisé dans l'action en cours. */ function gainPX(n) { + if (!n) return; + G.troll.pi += n; + G.troll.totalPI += n; + if (G.actionPX !== undefined) G.actionPX += n; +} + +function adjustPX(n) { + if (!n) return; G.troll.pi += n; G.troll.totalPI += n; if (G.actionPX !== undefined) G.actionPX += n; @@ -545,7 +559,7 @@ function attackMonster(m, opts = {}) { gainPX(1); // +1 PX par attaque réussie (converti en PI) cdLine(`Vous lui avez infligé ${r.damage} points de dégâts.`); log(`Tu attaques ${m.name} : ATT ${r.attRoll} vs ESQ ${r.esqRoll} → ${r.critical ? "CRITIQUE ! " : "touché ! "}${r.damage} dégâts.`, "combat"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "attack"); } else { log(`Tu attaques ${m.name} : ATT ${r.attRoll} vs ESQ ${r.esqRoll} → esquivé !`, "combat"); } @@ -559,12 +573,28 @@ function attackMonster(m, opts = {}) { return true; } -function killMonster(m) { - const px = m.level * 2; - gainPX(px); +function killMonster(m, source = "attack") { + const trollLvl = levelFromTotalPI(G.troll.totalPI); + const rawKillPx = killPX(trollLvl, m.level); + let killGain = 0; + if (rawKillPx < 0) { + if (source === "attack") adjustPX(-1); // annule le +1 PX d'attaque réussie + // comp/sort : on conserve uniquement le PX de talent déjà gagné + } else { + killGain = rawKillPx; + gainPX(killGain); + } G.troll.kills++; cdLine(`Vous l'avez TUÉ et vous avez débarrassé le Monde Souterrain de sa présence maléfique.`); - log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good"); + if (killGain > 0) { + log(`💀 ${m.name} est terrassé ! +${killGain} PX de mise à mort (convertis en PI à l'entraînement).`, "good"); + } else if (rawKillPx < 0 && source !== "attack") { + log(`💀 ${m.name} est terrassé ! Pas de bonus de mise à mort (adversaire trop faible).`, "good"); + } else if (rawKillPx < 0) { + log(`💀 ${m.name} est terrassé ! Pas de PX (adversaire trop faible).`, "good"); + } else { + log(`💀 ${m.name} est terrassé !`, "good"); + } // 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 @@ -598,7 +628,7 @@ function cdClose() { /* Tente le talent : dépense les PA (moitié remboursée en cas d'échec, comme à MH), * jette la maîtrise, journalise et ouvre le bloc de détail du combat. * Retourne true si l'effet s'applique ; le bloc reste à flusher par l'appelant. */ -function tryTalent(talent, cap, cost, label) { +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 @@ -615,7 +645,11 @@ function tryTalent(talent, cap, cost, label) { log(`${label} : échec (jet ${r.roll}). La moitié des PA est remboursée.${learn}`, "combat"); return false; } - gainPX(1); + const pxFlag = kind === "comp" ? "compPXTurn" : "sortPXTurn"; + if (!G.troll[pxFlag]) { + gainPX(1); + G.troll[pxFlag] = true; + } if (r.gain && talent.pct > before) { cdLine(`Vous avez augmenté votre Maîtrise de ${talent.pct - before} point(s) (→ ${talent.pct} %).`); log(`${label} : réussite (jet ${r.roll}) — maîtrise +${talent.pct - before} % → ${talent.pct} %.`, "good"); @@ -632,7 +666,7 @@ function useComp() { if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; } const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète")) { cdFlush(); afterAction(); return; } + 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 }; @@ -642,19 +676,19 @@ function useComp() { m.pv -= r.damage; cdLine(`Vous lui avez infligé ${r.damage} points de dégâts.`); log(`Botte Secrète : ${r.attRoll} vs ${r.esqRoll} → ${r.damage} dégâts !`, "combat"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "comp"); } else { log(`Botte Secrète esquivée (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } else if (t.race === "Durakuir") { // Régénération Accrue : 1D3 par tranche de 15 PV max - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Régénération Accrue")) { cdFlush(); afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Régénération Accrue", "comp")) { cdFlush(); afterAction(); return; } const dice = Math.max(1, Math.floor(t.pvMax / 15)); const heal = rollDice(dice, 3).total; t.pv = Math.min(t.pvMax, t.pv + heal); cdLine(`Votre chair se referme : vous récupérez ${heal} points de Vie (${dice}D3).`); log(`Régénération Accrue : ${dice}D3 → +${heal} PV.`, "good"); } else if (t.race === "Kastar") { // Accélération du Métabolisme : PV → PA, fatigue croissante - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Accélération du Métabolisme")) { cdFlush(); afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Accélération du Métabolisme", "comp")) { cdFlush(); afterAction(); return; } const cost = rollDice(1, 3).total + t.fatigue; t.fatigue += 1; t.pv -= cost; @@ -664,7 +698,7 @@ function useComp() { 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.camo) { log("Tu es déjà camouflé.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage")) { cdFlush(); afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage", "comp")) { cdFlush(); afterAction(); return; } t.camo = true; cdLine(`Vous vous fondez dans les ombres : invisible tant que vous n'attaquez pas.`); log("🌫️ Camouflage : les monstres ne te voient plus. Chaque pas risque de te dévoiler (75 % de la maîtrise).", "good"); @@ -672,7 +706,7 @@ function useComp() { if (t.compUsed) { log("Balayage déjà utilisé cette DLA.", "info"); return; } const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage")) { cdFlush(); afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage", "comp")) { cdFlush(); afterAction(); return; } t.compUsed = true; const destab = rollDice(t.att, 6).total; const stab = rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total; @@ -700,7 +734,7 @@ function useSort() { if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme")) { cdFlush(); afterAction(); return; } + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme", "sort")) { cdFlush(); afterAction(); return; } cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); const res = resistInfo(m); m.esqHalfTurns = (m.esqHalfTurns || 0) + (res ? 1 : 2); @@ -714,7 +748,7 @@ function useSort() { } else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique")) { cdFlush(); afterAction(); return; } + 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; @@ -723,11 +757,11 @@ function useSort() { m.pv -= dmg; cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); log(`Rafale Psychique : touche automatique, ${dmg} dégâts${res ? " (résisté : moitié)" : ""}, armure ignorée.`, "combat"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "sort"); } else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme")) { cdFlush(); afterAction(); 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 r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); @@ -742,14 +776,14 @@ function useSort() { cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); cdLine(`Vous avez également gagné ${heal} points de Vie grâce au Vampirisme.`); log(`Vampirisme : ${dmg} dégâts${res ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "sort"); } else { log(`Vampirisme esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } else if (t.race === "Tomawak") { // Projectile Magique : à distance, portée = Vue const m = nearestVisibleMonster(); if (!m) { log("Aucun monstre en vue.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Projectile Magique")) { cdFlush(); afterAction(); 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); cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège (distance ${dist}, bonus de proximité +${proxBonus}D6).`); @@ -763,7 +797,7 @@ function useSort() { m.pv -= dmg; cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); log(`Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${res ? " (résisté)" : ""}.`, "combat"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "sort"); } else { log(`Projectile Magique esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } @@ -776,7 +810,7 @@ function useSort() { } else if (t.race === "Darkling") { // Siphon des Âmes : dégâts + nécrose (−ATT) const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes")) { cdFlush(); afterAction(); 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 r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); @@ -792,7 +826,7 @@ function useSort() { cdLine(`Vous lui avez infligé ${dmg} points de dégâts (toute armure ignorée).`); cdLine(`La nécrose lui fait perdre ${necrose} dé(s) d'Attaque pendant 2 tours.`); log(`Siphon des Âmes : ${dmg} dégâts${res ? " (résisté)" : ""}, nécrose −${necrose} dé(s) d'ATT pendant 2 tours.`, "combat"); - if (m.pv <= 0) killMonster(m); + if (m.pv <= 0) killMonster(m, "sort"); } else { log(`Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } @@ -950,6 +984,8 @@ function passDLA() { t.pa = PA_PER_TURN; t.compUsed = false; + t.compPXTurn = false; + t.sortPXTurn = false; t.fatigue = Math.floor(t.fatigue / 1.25); // la fatigue du Kastar retombe à chaque DLA afterAction(); } @@ -1272,7 +1308,7 @@ if (typeof document !== "undefined") { /* Export pour les tests node */ if (typeof module !== "undefined" && module.exports) { module.exports = { - rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, + rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, diff --git a/test/smoke.js b/test/smoke.js index b79a90f..5f208ee 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -89,6 +89,12 @@ assert.strictEqual(g.levelFromTotalPI(20), 2); assert.strictEqual(g.levelFromTotalPI(49), 2); assert.strictEqual(g.levelFromTotalPI(50), 3); +// PX de mise à mort : 10 + 2 × (niveau cible − niveau troll) + niveau cible +assert.strictEqual(g.killPX(1, 5), 23, "troll niv 1 vs cible niv 5"); +assert.strictEqual(g.killPX(5, 5), 15, "même niveau"); +assert.strictEqual(g.killPX(60, 1), -107, "troll niv 60 vs cible niv 1 → négatif"); +assert.strictEqual(g.killPX(3, 1), 7, "troll niv 3 vs cible niv 1"); + // Les 5 races existent avec stats officielles, compétence et sortilège réservés assert.strictEqual(Object.keys(g.RACES).length, 5); for (const r of Object.values(g.RACES)) {