diff --git a/README.md b/README.md index 0971293..75dc339 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ lance directement une partie (utilisé pour les captures d'écran et les tests). | Combat | Somme ATT D6 vs somme ESQ D6, dégâts DEG D3 − armure, coup critique (attaque ≥ 2 × esquive) aux dégâts doublés | Identique, avec un panneau « Détail du combat » au format des rapports MH (jets, Seuil de Résistance, jet de Résistance, maîtrise, butin, PX) à droite du journal | | Talents | Compétence + sortilège réservés par race, 15 % de maîtrise initiale, jet D100 sous le %, progression à l'usage (+1D6 % < 50, +1D3 % < 75, +1 % ensuite ; sous 50 %, un échec donne quand même +1 %), plafonds 90 % compétences / 80 % sortilèges, échec = PA partiellement remboursés | Identique | | Magie | Seuil de Résistance basé sur MM vs RM, borné 10–90 % | Identique : jet de résistance des monstres → effets de moitié | +| Bonus/malus | Physiques (équipement) et magiques (potions, sortilèges…) distincts | Identique : équipement = physique (exceptions à venir), potions/parchemins = magique ; décomposition « P/M » affichée dans le profil | +| Armure | L'armure réduit les dégâts physiques ; seule sa composante magique réduit les dégâts magiques | Identique : armure physique (naturelle en D3 + équipement) + armure magique (potions/parchemins) — troll et monstres | | Progression | PX → PI, le N-ième achat coûte N × coût de base racial (table de Rules_3.php) | Identique : 16 PI de base (12 pour la caractéristique favorite), REG 30 (Darkling 22), Armure naturelle 30 pour tous, PV par tranche de 10 | | Niveaux | 10 × N PI pour atteindre le niveau N | Identique | | Races | 5 races, chacune avec sort/compétence propre | Les 5 races avec leur capacité signature (voir ci-dessous) | @@ -152,6 +154,17 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **1.9.0** (2026-06-12) — Bonus/malus **physiques** et **magiques** distincts, comme + à MountyHall : l'équipement donne des bonus physiques (exceptions à venir), les + potions et parchemins des bonus magiques. L'armure est scindée en **armure + physique** (base + naturelle en D3 + équipement) et **armure magique** (effets + de potions/parchemins, ex. Extrait du Glacier) : les dégâts physiques sont + réduits par l'armure totale, les dégâts magiques (Rafale Psychique, Vampirisme, + Projectile Magique, Rune Explosive) par la seule armure magique — le Siphon des + Âmes continue d'ignorer toute armure. Les monstres ont eux aussi une armure + physique et une armure magique distinctes (valeurs du bestiaire à venir). + Affichage : Armure phy./mag. séparées dans le profil, décomposition « P/M » des + bonus fixes, absorption d'armure détaillée dans les rapports de combat. - **1.8.0** (2026-06-12) — Habillage « pack parchemin » officiel de MountyHall (https://www.mountyhall.com/MH_Pack/packMH_parchemin/) : fond lin, tableaux crème à bordure noire (tableau1/2.jpg), liens rouge sombre #990000, boutons diff --git a/js/game.js b/js/game.js index d44459c..1d4c1c2 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "1.8.0"; +const APP_VERSION = "1.9.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; @@ -31,7 +31,10 @@ function rollDice(n, faces) { /* Jet d'attaque MountyHall : somme de ATT D6 contre somme de ESQ D6. * Si l'attaque dépasse l'esquive, dégâts = DEG D3 (+ bonus d'arme) − armure. - * (Les dégâts sont en D3 comme dans les profils officiels des races.) */ + * (Les dégâts sont en D3 comme dans les profils officiels des races.) + * Armure façon MH : les dégâts PHYSIQUES sont réduits par l'armure totale + * (physique + magique) ; les dégâts MAGIQUES (opts.magic) par la seule armure + * magique ; opts.ignoreArmor ignore tout (Siphon des Âmes). */ function resolveAttack(attacker, defender, opts = {}) { const att = rollDice(attacker.att, 6); const esq = rollDice(defender.esq, 6); @@ -50,9 +53,16 @@ function resolveAttack(attacker, defender, opts = {}) { const deg = rollDice(attacker.deg, 3); const degFlat = attacker.degFlat || 0; result.rawDamage = (deg.total + (attacker.degBonus || 0) + degFlat) * (result.critical ? 2 : 1); - // armure : réduction fixe (équipement) + armure naturelle en D3 (achetée en PI) - const armor = opts.ignoreArmor ? 0 - : (defender.armor || 0) + (defender.armorDice ? rollDice(defender.armorDice, 3).total : 0); + // armure physique : fixe (base + équipement) + naturelle en D3 (achetée en PI) + // armure magique : effets de potions/parchemins (armorMag, peut être négative) + let armor = 0; + if (!opts.ignoreArmor) { + const phys = (defender.armorPhys != null ? defender.armorPhys : defender.armor || 0) + + (defender.armorDice ? rollDice(defender.armorDice, 3).total : 0); + const mag = defender.armorMag || 0; + armor = Math.max(0, opts.magic ? mag : phys + mag); + } + result.armorReduction = armor; result.damage = Math.max(1, result.rawDamage - armor); } return result; @@ -126,21 +136,21 @@ const RACES = { desc: "Les tanks du Hall, durs au mal et infatigables.", stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 40, vue: 3 }, comp: { name: "Régénération Accrue", cost: 2, desc: "Soigne immédiatement 1D3 par tranche de 15 PV max." }, - sort: { name: "Rafale Psychique", cost: 4, desc: "Touche automatiquement (imparable) : 1D3 par dé de DEG, ignore l'armure (résistance : moitié)." }, + sort: { name: "Rafale Psychique", cost: 4, desc: "Touche automatiquement (imparable) : 1D3 par dé de DEG, ignore l'armure physique (résistance : moitié)." }, }, Kastar: { emoji: "🔴", favored: "deg", desc: "Les vampires du Hall : leurs coups les nourrissent.", stats: { att: 3, esq: 3, deg: 4, reg: 1, pvMax: 30, vue: 3 }, comp: { name: "Accélération du Métabolisme", cost: 2, desc: "Sacrifie des PV (1D3 + fatigue) pour regagner 4 PA. La fatigue monte à chaque usage." }, - sort: { name: "Vampirisme", cost: 4, desc: "Cible adjacente : 2D6 par tranche de 3 dés de DEG vs ESQ, dégâts 1D3 par dé de DEG, ignore l'armure, soigne 50 % (résistance : moitié)." }, + sort: { name: "Vampirisme", cost: 4, desc: "Cible adjacente : 2D6 par tranche de 3 dés de DEG vs ESQ, dégâts 1D3 par dé de DEG, ignore l'armure physique, soigne 50 % (résistance : moitié)." }, }, Tomawak: { emoji: "🟡", favored: "vue", desc: "Trõlls furtifs, chasseurs embusqués aux yeux perçants.", stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 30, vue: 4 }, comp: { name: "Camouflage", cost: 2, desc: "Invisible aux monstres tant que tu n'attaques pas ; à chaque pas, jet sous 75 % de la maîtrise pour rester caché." }, - sort: { name: "Projectile Magique", cost: 4, desc: "À distance (portée = Vue) : 1D6 par case de Vue + bonus de proximité, dégâts 1D3 par tranche de 2 cases de Vue, ignore l'armure (résistance : moitié)." }, + sort: { name: "Projectile Magique", cost: 4, desc: "À distance (portée = Vue) : 1D6 par case de Vue + bonus de proximité, dégâts 1D3 par tranche de 2 cases de Vue, ignore l'armure physique (résistance : moitié)." }, }, Darkling: { emoji: "🟣", favored: "reg", @@ -153,18 +163,21 @@ const RACES = { /* ================= Bestiaire ================= */ -/* Dégâts des monstres en D3, comme ceux des trolls. */ +/* Dégâts des monstres en D3, comme ceux des trolls. + * `armor` = armure physique, `armorMag` = armure magique (réduit seule les + * dégâts magiques). Valeurs provisoires à 0 — le bestiaire sera mis à jour + * avec les vraies valeurs une fois le système validé. */ const MONSTER_TYPES = [ - { name: "Gobelin", emoji: "👺", level: 1, att: 2, esq: 2, deg: 2, pv: 12, armor: 0, vue: 4 }, - { name: "Champignon Vénéneux", emoji: "🍄", level: 1, att: 3, esq: 1, deg: 3, pv: 10, armor: 0, vue: 1, static: true }, - { name: "Araignée Géante", emoji: "🕷️", level: 2, att: 3, esq: 3, deg: 3, pv: 16, armor: 0, vue: 5 }, - { name: "Gargouille", emoji: "🦇", level: 3, att: 3, esq: 3, deg: 3, pv: 22, armor: 2, vue: 4 }, - { name: "Momie", emoji: "🧟", level: 3, att: 4, esq: 2, deg: 4, pv: 26, armor: 1, vue: 3 }, - { name: "Sorcière", emoji: "🧙", level: 4, att: 4, esq: 3, deg: 5, pv: 24, armor: 0, vue: 6 }, - { name: "Golem de Pierre", emoji: "🗿", level: 5, att: 4, esq: 1, deg: 6, pv: 40, armor: 4, vue: 3 }, + { name: "Gobelin", emoji: "👺", level: 1, att: 2, esq: 2, deg: 2, pv: 12, armor: 0, armorMag: 0, vue: 4 }, + { name: "Champignon Vénéneux", emoji: "🍄", level: 1, att: 3, esq: 1, deg: 3, pv: 10, armor: 0, armorMag: 0, vue: 1, static: true }, + { name: "Araignée Géante", emoji: "🕷️", level: 2, att: 3, esq: 3, deg: 3, pv: 16, armor: 0, armorMag: 0, vue: 5 }, + { name: "Gargouille", emoji: "🦇", level: 3, att: 3, esq: 3, deg: 3, pv: 22, armor: 2, armorMag: 0, vue: 4 }, + { name: "Momie", emoji: "🧟", level: 3, att: 4, esq: 2, deg: 4, pv: 26, armor: 1, armorMag: 0, vue: 3 }, + { name: "Sorcière", emoji: "🧙", level: 4, att: 4, esq: 3, deg: 5, pv: 24, armor: 0, armorMag: 0, vue: 6 }, + { name: "Golem de Pierre", emoji: "🗿", level: 5, att: 4, esq: 1, deg: 6, pv: 40, armor: 4, armorMag: 0, vue: 3 }, ]; -const BOSS = { name: "Béhémoth", emoji: "👹", level: 9, att: 6, esq: 4, deg: 8, pv: 90, armor: 3, vue: 8, boss: true }; +const BOSS = { name: "Béhémoth", emoji: "👹", level: 9, att: 6, esq: 4, deg: 8, pv: 90, armor: 3, armorMag: 0, vue: 8, boss: true }; /* Gabarits d'âge façon MountyHall : plus on descend, plus les bêtes sont vieilles. */ const TEMPLATES = [ @@ -183,7 +196,7 @@ function applyTemplate(type, tpl, x, y) { esq: Math.max(1, Math.round(type.esq * tpl.mult)), deg: Math.max(1, Math.round(type.deg * tpl.mult)), pv: Math.round(type.pv * tpl.mult), pvMax: Math.round(type.pv * tpl.mult), - armor: type.armor, vue: type.vue, static: !!type.static, + armor: type.armor, armorMag: type.armorMag || 0, vue: type.vue, static: !!type.static, x, y, boss: false, }; } @@ -608,7 +621,7 @@ function attackMonster(m, opts = {}) { if (r.hit) { m.pv -= r.damage; gainPX(1); // +1 PX par attaque réussie (converti en PI) - cdLine(`Vous lui avez infligé ${r.damage} points de dégâts.`); + cdLine(`Vous lui avez infligé ${r.damage} points de dégâts${r.armorReduction ? ` (son armure a absorbé ${r.armorReduction} point(s))` : ""}.`); 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, "attack"); } else { @@ -813,12 +826,13 @@ 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(te.deg, 3).total; + const armMag = Math.max(0, effMonster(m).armorMag); + let dmg = Math.max(1, rollDice(te.deg, 3).total - armMag); const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); 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"); + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure physique ignorée${armMag ? `, armure magique −${armMag}` : ""}).`); + log(`Rafale Psychique : touche automatique, ${dmg} dégâts${res ? " (résisté : moitié)" : ""}, armure physique ignorée.`, "combat"); if (m.pv <= 0) killMonster(m, "sort"); } else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie const m = adjacentMonster(); @@ -829,7 +843,7 @@ function useSort() { att: Math.max(1, Math.floor(te.deg * 2 / 3)), deg: te.deg, degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat, }; - const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); + const r = resolveAttack(pseudo, effMonster(m), { magic: true }); cdAttackRolls(r); if (r.hit) { let dmg = r.damage; @@ -838,7 +852,7 @@ function useSort() { m.pv -= dmg; const heal = Math.ceil(dmg / 2); t.pv = Math.min(t.pvMax, t.pv + heal); - cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure physique ignorée${r.armorReduction ? `, armure magique −${r.armorReduction}` : ""}).`); 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, "sort"); @@ -853,14 +867,14 @@ function useSort() { 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: te.vue + proxBonus, deg: Math.max(1, Math.floor(te.vue / 2)), degBonus: 0 }; - const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); + const r = resolveAttack(pseudo, effMonster(m), { magic: true }); cdAttackRolls(r); if (r.hit) { let dmg = r.damage; const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); m.pv -= dmg; - cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure physique ignorée${r.armorReduction ? `, armure magique −${r.armorReduction}` : ""}).`); log(`Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${res ? " (résisté)" : ""}.`, "combat"); if (m.pv <= 0) killMonster(m, "sort"); } else { @@ -907,6 +921,8 @@ function effMonster(m) { att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)), esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq, vue: Math.max(1, m.vue - (m.vueMalusTurns > 0 ? m.vueMalus || 0 : 0)), + armorPhys: m.armor || 0, + armorMag: m.armorMag || 0, }; } @@ -1006,9 +1022,12 @@ function applyScrollZone(zone) { if (zone.type === "damage") { cdStart("💥 Effet de zone"); for (const m of targets) { - m.pv -= zone.total; - cdLine(`${m.emoji} ${m.name} subit ${zone.total} points de dégâts (${zone.label}).`); - log(`💥 ${m.name} est pris dans l'explosion : −${zone.total} PV.`, "combat"); + // dégâts magiques : seule l'armure magique du monstre les réduit + const armMag = Math.max(0, m.armorMag || 0); + const dmg = Math.max(1, zone.total - armMag); + m.pv -= dmg; + cdLine(`${m.emoji} ${m.name} subit ${dmg} points de dégâts (${zone.label}${armMag ? `, armure magique −${armMag}` : ""}).`); + log(`💥 ${m.name} est pris dans l'explosion : −${dmg} PV.`, "combat"); if (m.pv <= 0) killMonster(m, "scroll"); } cdFlush(); @@ -1077,7 +1096,7 @@ function passDLA() { cdLine(`Votre jet d'Esquive est de : ${r.esqRoll} (${r.esqDice}D6)`); if (r.hit) { t.pv -= r.damage; - cdLine(`Il vous a TOUCHÉ${r.critical ? ` d'un coup critique` : ""} et vous a infligé ${r.damage} points de dégâts.`); + cdLine(`Il vous a TOUCHÉ${r.critical ? ` d'un coup critique` : ""} et vous a infligé ${r.damage} points de dégâts${r.armorReduction ? ` (votre armure a absorbé ${r.armorReduction} point(s))` : ""}.`); log(`${m.emoji} ${m.name} t'attaque : ${r.attRoll} vs ${r.esqRoll} → ${r.damage} dégâts !`, "bad"); if (t.pv <= 0) { cdLine(`Il vous a TERRASSÉ…`); cdFlush(); die(m); return; } } else { @@ -1254,11 +1273,12 @@ function renderPanels() {
Six emplacements : ${Object.values(GEAR_SLOTS).map(s => s.label).join(", ")}. - Tous les bonus d'équipement sont fixes : ATT/ESQ/DEG/REG s'ajoutent aux jets - (jamais en dés), Armure/VUE/PV aussi, RM/MM en %. + Tous les bonus d'équipement sont fixes et physiques : ATT/ESQ/DEG/REG + s'ajoutent aux jets (jamais en dés), Armure physique/VUE/PV aussi, RM/MM en %. + L'armure d'équipement réduit les dégâts physiques mais pas les dégâts magiques. Une arme à 2 mains est incompatible avec un bouclier. S'équiper coûte 2 PA. Valeurs de base de la Mountypedia (sans templates) ; les objets puissants ne se trouvent qu'en profondeur.
`; diff --git a/test/smoke.js b/test/smoke.js index 66a92bb..44323b4 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -76,6 +76,61 @@ assert.strictEqual(g.improveCost("reg", 0, "Skrim"), 30, "REG non favorisée = 3 assert.strictEqual(g.improveCost("armor", 0, "Durakuir"), 30, "Armure = 30 PI pour tous"); assert.strictEqual(g.improveCost("esq", 1, "Tomawak"), 32, "2e dé d'ESQ = 32 PI"); +// Armure physique vs magique : les dégâts physiques sont réduits par l'armure +// totale ; les dégâts magiques (opts.magic) par la seule armure magique ; +// opts.ignoreArmor ignore tout (Siphon des Âmes). +{ + const fort = { att: 50, deg: 10 }; // touche toujours, dégâts élevés + for (let i = 0; i < 200; i++) { + const cible = { esq: 1, armorPhys: 5, armorMag: 3 }; + const phy = g.resolveAttack(fort, cible); + assert.strictEqual(phy.armorReduction, 8, "attaque physique : armure totale 5+3"); + const mag = g.resolveAttack(fort, cible, { magic: true }); + assert.strictEqual(mag.armorReduction, 3, "attaque magique : armure magique seule"); + const tout = g.resolveAttack(fort, cible, { ignoreArmor: true }); + assert.strictEqual(tout.armorReduction, 0, "ignoreArmor : aucune réduction"); + } + // armure magique négative (Métomol) : réduit l'armure totale, jamais sous 0 + const r1 = g.resolveAttack(fort, { esq: 1, armorPhys: 2, armorMag: -5 }); + assert.strictEqual(r1.armorReduction, 0, "armure totale plancher 0"); + const r2 = g.resolveAttack(fort, { esq: 1, armorPhys: 0, armorMag: -4 }, { magic: true }); + assert.strictEqual(r2.armorReduction, 0, "armure magique plancher 0"); + // rétrocompat : un défenseur avec seulement `armor` = armure physique + const r3 = g.resolveAttack(fort, { esq: 1, armor: 4 }); + assert.strictEqual(r3.armorReduction, 4, "fallback armor = physique"); +} + +// effTroll : équipement → armure physique, potions → armure magique +{ + 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: 20, + 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 cuir")); // arm +4 (physique) + p.drinkPotion(t, p.makePotionItem("glacier", 5), g.rollDice, () => {}); // Armure mag. +5 + const e = p.effTroll(t); + assert.strictEqual(e.armorPhys, 4, "Armure de cuir : armure physique +4"); + assert.strictEqual(e.armorMag, 5, "Extrait du Glacier : armure magique +5"); + assert.strictEqual(e.armor, 9, "armure totale = physique + magique"); + // décomposition des bonus fixes : équipement = physique, potion = magique + g.equipGear(t, gearLib.gearItemByName("arme", "Gourdin")); // att +2 (physique) + p.drinkPotion(t, p.makePotionItem("bonneBouffe", 5), g.rollDice, () => {}); // DEG +5 (magique) + const e2 = p.effTroll(t); + assert.strictEqual(e2.attFlatPhys, 2, "Gourdin : ATT +2 physique"); + assert.strictEqual(e2.attFlatMag, 0, "aucun bonus magique d'ATT"); + assert.strictEqual(e2.degFlatPhys, 5, "Gourdin : DEG +5 physique"); + assert.strictEqual(e2.degFlatMag, 5, "Bonne Bouffe : DEG +5 magique"); + assert.strictEqual(e2.degFlat, 10, "total = physique + magique"); +} + +// Monstres : armure physique et magique distinctes +{ + const m = g.makeMonster(3, 1, 1); + assert(typeof m.armorMag === "number", "monstre : armorMag présent"); + const boss = g.monsterFromSpec({ x: 1, y: 1, boss: true }); + assert(typeof boss.armorMag === "number", "Béhémoth : armorMag présent"); +} + // L'armure naturelle en D3 réduit bien les dégâts { let withArmor = 0, without = 0;