diff --git a/README.md b/README.md index c8f7db1..9ab8202 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,13 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **1.7.0** (2026-06-11) — Équipement Mountypedia complet : 6 emplacements (Arme, + Armure, Casque, Bouclier, Talisman, Bottes) et 55 objets dropables aux valeurs + de base officielles (ATT/ESQ/DEG/REG en dés, Armure/VUE/PV fixes, RM/MM en %). + Armes à 2 mains incompatibles avec un bouclier, PV max d'équipement, tiers de + drop par profondeur, pinceaux d'éditeur par emplacement, rétrocompatibilité des + anciens niveaux publiés (specs weapon/armor mappées). Section « Équipement » + dans la page Trésors. - **1.6.0** (2026-06-11) — Parchemins 📜 : les 7 parchemins standards de Mountypedia (Rune des Cyclopes, Traité de Clairvoyance, Idées Confuses, Rune Explosive, Plan Génial, Yeu'Ki'Pic, Rune des Foins) en trésors à trouver, butin de monstre diff --git a/css/style.css b/css/style.css index 648cc9b..330047b 100644 --- a/css/style.css +++ b/css/style.css @@ -520,3 +520,14 @@ header h1 { font-size: 1.3em; color: #8fbf5a; } } .tz-good { color: #8fbf5a; } .tz-bad { color: #d88080; } + +/* ---------- Équipement 6 emplacements ---------- */ +.eq-line { margin-bottom: 6px; line-height: 1.3; } +.eq-slot { + display: inline-block; + min-width: 72px; + color: #a89568; + font-size: .85em; +} +.eq-mods { color: #8a7a55; font-size: .78em; margin-left: 72px; } +.tz-subsection { color: #d8a040; margin: 16px 0 8px; font-size: 1.05em; } diff --git a/index.html b/index.html index 1eed649..e8c70bf 100644 --- a/index.html +++ b/index.html @@ -166,6 +166,7 @@ + diff --git a/js/editor.js b/js/editor.js index 65339d9..4d989ed 100644 --- a/js/editor.js +++ b/js/editor.js @@ -124,8 +124,9 @@ function edBuildPalette() { edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" }); edAddBrushBtn(itemsDiv, "📜 Parchemin (aléatoire)", { mode: "item", kind: "scroll" }); 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 })); + for (const [slot, info] of Object.entries(GEAR_SLOTS)) { + edAddBrushBtn(itemsDiv, `${info.emoji} ${info.label} (aléatoire)`, { mode: "item", kind: "gear", slot }); + } const doorsDiv = document.getElementById("ed-doors"); doorsDiv.innerHTML = ""; @@ -240,6 +241,7 @@ function edApply(x, y) { ED.items = ED.items.filter(i => i.x !== x || i.y !== y); const spec = { x, y, kind: b.kind }; if (b.idx !== undefined) spec.idx = b.idx; + if (b.slot !== undefined) spec.slot = b.slot; if (b.kind === "gold") spec.gold = 60; ED.items.push(spec); } @@ -279,7 +281,8 @@ function edRender() { : i.kind === "scroll" ? "#d8c890" : "#7a8db0"); ctx.fillStyle = "#1a140e"; const emoji = i.kind === "potion" ? "🧪" : i.kind === "scroll" ? "📜" : i.kind === "gold" ? "💰" - : i.kind === "weapon" ? WEAPONS[i.idx || 0].emoji : ARMORS[i.idx || 0].emoji; + : i.kind === "gear" ? (GEAR_SLOTS[i.slot] || GEAR_SLOTS.arme).emoji + : i.kind === "weapon" ? "🗡️" : "🦺"; ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); } for (const d of ED.doors) { diff --git a/js/game.js b/js/game.js index 85e0ea3..f4c9621 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "1.6.0"; +const APP_VERSION = "1.7.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; @@ -17,6 +17,9 @@ if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion = if (typeof module !== "undefined" && module.exports && typeof makeRandomScroll === "undefined") { Object.assign(globalThis, require("./scrolls.js")); } +if (typeof module !== "undefined" && module.exports && typeof makeRandomGear === "undefined") { + Object.assign(globalThis, require("./gear.js")); +} /* ================= Dés & règles de base ================= */ @@ -201,7 +204,9 @@ function monsterFromSpec(spec) { return applyTemplate(type, tpl, spec.x, spec.y); } -/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, idx?, gold?} */ +/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, slot?, idx?, gold?}. + * Les specs « weapon »/« armor » à idx des anciens niveaux publiés sont mappées + * vers l'équipement Mountypedia équivalent. */ function itemFromSpec(spec) { const base = { x: spec.x, y: spec.y }; if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) }; @@ -210,8 +215,13 @@ function itemFromSpec(spec) { const gold = spec.gold || 60; return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } - const list = spec.kind === "weapon" ? WEAPONS : ARMORS; - return { ...base, kind: "gear", ...list[(spec.idx || 0) % list.length] }; + if (spec.kind === "gear") return { ...base, ...makeRandomGear(5, spec.slot) }; + if (spec.kind === "weapon") { + const name = LEGACY_WEAPON_NAMES[(spec.idx || 0) % LEGACY_WEAPON_NAMES.length]; + return { ...base, ...gearItemByName("arme", name) }; + } + const name = LEGACY_ARMOR_NAMES[(spec.idx || 0) % LEGACY_ARMOR_NAMES.length]; + return { ...base, ...gearItemByName("armure", name) }; } /* ================= Génération du Monde Souterrain ================= */ @@ -286,30 +296,11 @@ function randomFloor(grid, taken) { /* ================= Objets ================= */ -const WEAPONS = [ - { name: "Dague Rouillée", emoji: "🗡️", slot: "weapon", bonus: 1 }, - { name: "Hache de Jet", emoji: "🪓", slot: "weapon", bonus: 2 }, - { name: "Épée Longue", emoji: "⚔️", slot: "weapon", bonus: 3 }, - { name: "Masse d'Armes", emoji: "🔨", slot: "weapon", bonus: 4 }, -]; -const ARMORS = [ - { name: "Armure de Cuir", emoji: "🦺", slot: "armor", bonus: 1 }, - { name: "Cotte de Mailles", emoji: "🥋", slot: "armor", bonus: 2 }, - { name: "Armure de Plates", emoji: "🛡️", slot: "armor", bonus: 3 }, -]; - function makeItem(depth) { const r = Math.random(); if (r < 0.3) return makeRandomPotion(); if (r < 0.42) return makeRandomScroll(); - if (r < 0.58) { - const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))]; - return { kind: "gear", ...w }; - } - if (r < 0.7) { - const a = ARMORS[Math.min(ARMORS.length - 1, Math.floor(Math.random() * depth))]; - return { kind: "gear", ...a }; - } + if (r < 0.7) return makeRandomGear(depth); const gold = rollDice(depth, 6).total * 10; return { kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } @@ -334,7 +325,8 @@ function newGame(name, race, customLevel = null) { bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 }, comp: { pct: START_COMP_PCT }, sort: { pct: START_SORT_PCT }, fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false, - weapon: null, armorItem: null, + equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null }, + gearMods: null, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0, bag: [], camo: false, kills: 0, dla: 1, tour: 1, potionEffects: [], blockCamoTurns: 0, @@ -610,7 +602,7 @@ function attackMonster(m, opts = {}) { const cost = opts.cost ?? COSTS.attack; 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"}.`); + cdLine(`Vous avez attaqué ${m.name} avec votre ${G.troll.equip.arme ? G.troll.equip.arme.name : "Grosse Patte de Trõll"}.`); const r = resolveAttack(effTroll(G.troll), effMonster(m)); cdAttackRolls(r); if (r.hit) { @@ -967,19 +959,43 @@ function useBagItem(idx) { updateFov(); } else if (item.kind === "gear") { if (!spendPA(COSTS.equip)) return; - if (item.slot === "weapon") { - if (t.weapon) t.bag.push(t.weapon); - t.weapon = item; t.degBonus = item.bonus; - } else { - if (t.armorItem) t.bag.push(t.armorItem); - t.armorItem = item; t.armor = item.bonus; - } t.bag.splice(t.bag.indexOf(item), 1); - log(`Tu t'équipes : ${item.emoji} ${item.name} (+${item.bonus}).`, "good"); + equipGear(t, item); } afterAction(); } +/* Équipe une pièce : range l'ancienne au sac, gère l'exclusion arme à 2 mains / + * bouclier, applique le bonus de PV max et recalcule les modificateurs. */ +function equipGear(t, item) { + const unequipTo = (slot) => { + const old = t.equip[slot]; + if (!old) return; + t.equip[slot] = null; + t.bag.push(old); + if (old.mods.pv) t.pvMax = Math.max(1, t.pvMax - old.mods.pv); + log(`Tu ranges ${old.emoji} ${old.name} dans ton sac.`, "info"); + }; + if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) { + unequipTo("bouclier"); + log("Une arme à deux mains ne laisse pas de place au bouclier.", "info"); + } + if (item.slot === "bouclier" && t.equip.arme && t.equip.arme.twoHanded) { + unequipTo("arme"); + log("Impossible de tenir un bouclier avec une arme à deux mains : tu la ranges.", "info"); + } + unequipTo(item.slot); + t.equip[item.slot] = item; + if (item.mods.pv) { + t.pvMax = Math.max(1, t.pvMax + item.mods.pv); + t.pv = Math.max(1, Math.min(t.pv, t.pvMax)); + } + t.gearMods = gearMods(t.equip); + const fx = formatGearMods(item.mods); + log(`Tu t'équipes : ${item.emoji} ${item.name}${fx ? ` (${fx})` : ""}.`, "good"); + if (G) updateFov(); +} + /* Effet de zone d'un parchemin : touche tous les monstres à SCROLL_ZONE_RADIUS * cases ou moins du troll (le lecteur a déjà subi sa part dans readScroll). */ function applyScrollZone(zone) { @@ -1301,9 +1317,14 @@ function renderPanels() { if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true); addBtn("⏳ Passer la DLA", passDLA, true); - $("equipment").innerHTML = - `
Arme : ${t.weapon ? t.weapon.emoji + " " + t.weapon.name + " (+" + t.weapon.bonus + ")" : "—"}
` + - `
Armure : ${t.armorItem ? t.armorItem.emoji + " " + t.armorItem.name + " (+" + t.armorItem.bonus + ")" : "—"}
`; + $("equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => { + const it = t.equip[slot]; + if (!it) return `
${info.label}
`; + const fx = formatGearMods(it.mods); + return `
${info.label} ${it.emoji} ${it.name}` + + `${it.twoHanded ? " (2 mains)" : ""}` + + `${fx ? `
${fx}
` : ""}
`; + }).join(""); const inv = $("inventory"); inv.innerHTML = ""; @@ -1313,6 +1334,7 @@ function renderPanels() { const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` : item.kind === "scroll" ? `lire (${COSTS.scroll} PA)` : `équiper (${COSTS.equip} PA)`; b.textContent = `${item.emoji} ${item.name} — ${action}`; + if (item.kind === "gear") b.title = formatGearMods(item.mods) + (item.twoHanded ? " · 2 mains" : ""); b.disabled = G.over; b.onclick = () => useBagItem(idx); inv.appendChild(b); @@ -1459,7 +1481,7 @@ if (typeof document !== "undefined") { if (typeof module !== "undefined" && module.exports) { module.exports = { APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, - RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, + RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, equipGear, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, newGame, refreshFov, inSightAt, diff --git a/js/gear.js b/js/gear.js new file mode 100644 index 0000000..6ad5d56 --- /dev/null +++ b/js/gear.js @@ -0,0 +1,149 @@ +/* Équipement MountyHall — 6 emplacements, objets dropables actuels de la Mountypedia : + * https://mountypedia.mountyhall.com/Mountyhall/{Arme,Armure,Casque,Bouclier,Talisman,Bottes} + * Valeurs de base (sans templates) : ATT/ESQ/DEG/REG modifient le nombre de dés, + * ARM/VUE/PV sont fixes, RM/MM en %. `tier` (1-5) borne la profondeur de drop. + * Les armes à deux mains (twoHanded) sont incompatibles avec un bouclier. */ + +"use strict"; + +const GEAR_SLOTS = { + arme: { label: "Arme", emoji: "🗡️" }, + armure: { label: "Armure", emoji: "🦺" }, + casque: { label: "Casque", emoji: "🪖" }, + bouclier: { label: "Bouclier", emoji: "🛡️" }, + talisman: { label: "Talisman", emoji: "📿" }, + bottes: { label: "Bottes", emoji: "🥾" }, +}; + +const GEAR = { + arme: [ + { name: "Torche", emoji: "🔥", tier: 1, mods: { vue: 1 } }, + { name: "Lame en os", emoji: "🦴", tier: 1, mods: { mmPct: 5 } }, + { name: "Épée Courte", emoji: "🗡️", tier: 1, mods: { deg: 2, esq: 2 } }, + { name: "Gantelet", emoji: "🥊", tier: 1, mods: { att: -1, esq: 1, deg: 1, arm: 2 } }, + { name: "Lame en pierre", emoji: "🪨", tier: 2, mods: { att: -1, deg: 4, reg: 2 } }, + { name: "Épée Longue", emoji: "⚔️", tier: 2, mods: { att: -1, deg: 4, esq: 1 } }, + { name: "Gourdin", emoji: "🏏", tier: 2, mods: { att: 2, esq: -2, deg: 5 } }, + { name: "Boulet et chaîne", emoji: "⛓️", tier: 2, mods: { att: -1, deg: 5 } }, + { name: "Grosse racine", emoji: "🌿", tier: 2, mods: { esq: -1, arm: 2, pv: 5, rmPct: 5 } }, + { name: "Coutelas d'obsidienne", emoji: "🔪", tier: 3, mods: { att: 2, deg: 3, esq: 2, reg: -2, rmPct: -5, mmPct: -15 } }, + { name: "Fouet", emoji: "🪢", tier: 4, mods: { att: 7, deg: -1 } }, + { name: "Lame d'obsidienne", emoji: "🖤", tier: 4, mods: { att: 3, deg: 7, reg: -3, rmPct: -30, mmPct: -10 } }, + { name: "Bâton de mage", emoji: "🪄", tier: 3, twoHanded: true, mods: { mmPct: 15 } }, + { name: "Hallebarde", emoji: "🪓", tier: 4, twoHanded: true, mods: { att: -5, deg: 12 } }, + { name: "Hache à deux mains d'Obsidienne", emoji: "⚒️", tier: 5, twoHanded: true, mods: { att: -8, deg: 16, reg: -4, rmPct: -45, mmPct: -15 } }, + ], + armure: [ + { name: "Culotte en Cuir", emoji: "🩲", tier: 1, mods: { esq: 5, rmPct: 60 } }, + { name: "Pagne en Cuir", emoji: "🧵", tier: 1, mods: { esq: 4, arm: 2, rmPct: 50 } }, + { name: "Armure de cuir", emoji: "🦺", tier: 2, mods: { esq: 3, arm: 4, rmPct: 40 } }, + { name: "Robe de mage", emoji: "🥻", tier: 2, mods: { esq: 2, arm: 1, rmPct: 10, mmPct: 10 } }, + { name: "Fourrures", emoji: "🐻", tier: 2, mods: { esq: 2, arm: 6, rmPct: 30 } }, + { name: "Cuir bouilli", emoji: "🟤", tier: 3, mods: { esq: 1, arm: 8, rmPct: 20 } }, + { name: "Armure de peaux", emoji: "🐗", tier: 3, mods: { esq: -3, arm: 9, rmPct: 70 } }, + { name: "Armure de bois", emoji: "🪵", tier: 3, mods: { esq: -3, arm: 6, pv: 15, rmPct: 50 } }, + { name: "Cuirasse d'Ossements", emoji: "💀", tier: 4, mods: { esq: -3, arm: 6, rmPct: 50, mmPct: 20 } }, + { name: "Armure d'Anneaux", emoji: "⭕", tier: 4, mods: { esq: -4, arm: 8, rmPct: 90 } }, + { name: "Haubert de mailles", emoji: "🥋", tier: 4, mods: { esq: -3, arm: 9, rmPct: 70 } }, + { name: "Armure de plates", emoji: "🛡️", tier: 5, mods: { esq: -2, arm: 10, rmPct: 50 } }, + { name: "Armure de pierre", emoji: "🗿", tier: 5, mods: { esq: -6, arm: 12, reg: 3, rmPct: 75 } }, + ], + casque: [ + { name: "Chapeau pointu", emoji: "🎩", tier: 1, mods: { esq: 3, mmPct: 5 } }, + { name: "Lorgnons", emoji: "👓", tier: 1, mods: { vue: 1, mmPct: 5 } }, + { name: "Couronne de ronces", emoji: "🌹", tier: 2, mods: { deg: 3, pv: -5 } }, + { name: "Couronne d'obsidienne", emoji: "👑", tier: 2, mods: { reg: -1, arm: 1, rmPct: 5 } }, + { name: "Casque à Cornes", emoji: "🐮", tier: 3, mods: { deg: 1, arm: 3, vue: -1, rmPct: 5 } }, + { name: "Casque à Pointes", emoji: "🦔", tier: 3, mods: { att: 1, deg: 1, arm: 3, vue: -1 } }, + { name: "Couronne de Cristal", emoji: "💎", tier: 3, mods: { vue: 3, mmPct: 5 } }, + { name: "Heaume", emoji: "🪖", tier: 4, mods: { att: -1, arm: 4, vue: -2, rmPct: 10 } }, + { name: "Turban", emoji: "👳", tier: 4, mods: { rmPct: 15, mmPct: 15 } }, + ], + bouclier: [ + { name: "Targe", emoji: "🎯", tier: 1, mods: { esq: 3, att: 1 } }, + { name: "Grimoire", emoji: "📖", tier: 2, mods: { mmPct: 5 }, note: "DLA +15 mn (non simulé)" }, + { name: "Rondache en Bois", emoji: "🪵", tier: 2, mods: { esq: 1, arm: 3, pv: 5 } }, + { name: "Bouclier à Pointes", emoji: "🛡️", tier: 3, mods: { att: 1, deg: 1, arm: 4 } }, + { name: "Gros'Porte", emoji: "🚪", tier: 4, mods: { arm: 5, rmPct: 10 } }, + ], + talisman: [ + { name: "Collier à Pointes", emoji: "📿", tier: 1, mods: { deg: 2, arm: 1 } }, + { name: "Talisman de pierre", emoji: "🪨", tier: 2, mods: { reg: 2, rmPct: 10, mmPct: 10 } }, + { name: "Gorgeron en métal", emoji: "⚙️", tier: 2, mods: { reg: -1, arm: 3, rmPct: 10 } }, + { name: "Œil de sang", emoji: "🩸", tier: 3, mods: { pv: -5, mmPct: 10 } }, + { name: "Pendentif Incandescent", emoji: "🔥", tier: 3, mods: { esq: 3, rmPct: 10, mmPct: 10 } }, + { name: "Torque en pierre", emoji: "🌀", tier: 4, mods: { reg: -2, rmPct: 20, mmPct: 20 } }, + { name: "Talisman d'Obsidienne", emoji: "🖤", tier: 5, mods: { att: 1, deg: 2, reg: -4, rmPct: 22, mmPct: 22 } }, + ], + bottes: [ + { name: "Bottes", emoji: "🥾", tier: 1, mods: { esq: 3 } }, + { name: "Jambière en cuir", emoji: "🦵", tier: 2, mods: { esq: 1, arm: 2, rmPct: 5 } }, + { name: "Jambière en os", emoji: "🦴", tier: 2, mods: { esq: -1, arm: 2, rmPct: 5, mmPct: 5 } }, + { name: "Souliers dorés", emoji: "👞", tier: 3, mods: { esq: -1, arm: 1 } }, + { name: "Jambière en mailles", emoji: "⛓️", tier: 3, mods: { arm: 3, rmPct: 5 } }, + { name: "Jambière en métal", emoji: "🦿", tier: 4, mods: { esq: -1, arm: 4, rmPct: 5 } }, + ], +}; + +/* Anciennes listes (v1 « bonus simple ») : mapping pour les niveaux déjà publiés. */ +const LEGACY_WEAPON_NAMES = ["Épée Courte", "Gourdin", "Épée Longue", "Lame d'obsidienne"]; +const LEGACY_ARMOR_NAMES = ["Armure de cuir", "Haubert de mailles", "Armure de plates"]; + +const GEAR_MOD_LABELS = [ + ["att", "ATT", " dé(s)"], ["esq", "ESQ", " dé(s)"], ["deg", "DEG", " dé(s)"], + ["reg", "REG", " dé(s)"], ["arm", "Armure", ""], ["vue", "VUE", ""], ["pv", "PV", ""], + ["rmPct", "RM", " %"], ["mmPct", "MM", " %"], +]; + +function formatGearMods(mods, sep = " · ") { + const parts = []; + for (const [key, label, unit] of GEAR_MOD_LABELS) { + const v = mods[key]; + if (!v) continue; + parts.push(`${label} ${v > 0 ? "+" : "−"}${Math.abs(v)}${unit}`); + } + return parts.join(sep); +} + +function makeGearItem(slot, idx) { + const list = GEAR[slot]; + if (!list) return null; + const def = list[((idx % list.length) + list.length) % list.length]; + return { + kind: "gear", slot, gearIdx: list.indexOf(def), + name: def.name, emoji: def.emoji, + mods: { ...def.mods }, twoHanded: !!def.twoHanded, note: def.note, + }; +} + +function gearItemByName(slot, name) { + const idx = (GEAR[slot] || []).findIndex(g => g.name === name); + return idx >= 0 ? makeGearItem(slot, idx) : null; +} + +/* Tire une pièce d'équipement adaptée à la profondeur (tier ≤ depth + 1). */ +function makeRandomGear(depth, slot = null) { + const slots = Object.keys(GEAR); + const s = slot && GEAR[slot] ? slot : slots[Math.floor(Math.random() * slots.length)]; + const pool = GEAR[s].filter(g => g.tier <= (depth || 1) + 1); + const list = pool.length ? pool : GEAR[s]; + const def = list[Math.floor(Math.random() * list.length)]; + return makeGearItem(s, GEAR[s].indexOf(def)); +} + +/* Somme des modificateurs de l'équipement porté ({ arme: item|null, … }). */ +function gearMods(equip) { + const total = { att: 0, esq: 0, deg: 0, reg: 0, arm: 0, vue: 0, pv: 0, rmPct: 0, mmPct: 0 }; + for (const item of Object.values(equip || {})) { + if (!item || !item.mods) continue; + for (const k of Object.keys(total)) total[k] += item.mods[k] || 0; + } + return total; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + GEAR, GEAR_SLOTS, GEAR_MOD_LABELS, LEGACY_WEAPON_NAMES, LEGACY_ARMOR_NAMES, + makeGearItem, gearItemByName, makeRandomGear, gearMods, formatGearMods, + }; +} diff --git a/js/potions.js b/js/potions.js index db09df9..b2f5bae 100644 --- a/js/potions.js +++ b/js/potions.js @@ -413,24 +413,27 @@ function fmtStatLine(base, eff, faces, flat = 0, extra = 0) { return main; } +/* Caractéristiques effectives : base + effets magiques (potions, parchemins) + * + équipement (troll.gearMods, recalculé par game.js à chaque équipement). */ function effTroll(troll) { const m = sumPotionMods(troll.potionEffects); + const g = troll.gearMods || {}; return { - att: Math.max(1, troll.att + m.att), + att: Math.max(1, troll.att + m.att + (g.att || 0)), attFlat: m.attFlat, - esq: Math.max(1, troll.esq + m.esq), + esq: Math.max(1, troll.esq + m.esq + (g.esq || 0)), esqFlat: m.esqFlat, - deg: Math.max(1, troll.deg + m.deg), + deg: Math.max(1, troll.deg + m.deg + (g.deg || 0)), degFlat: m.degFlat, - reg: Math.max(1, troll.reg + m.reg), + reg: Math.max(1, troll.reg + m.reg + (g.reg || 0)), regFlat: m.regFlat, - vue: Math.max(1, troll.vue + m.vue), - armor: Math.max(0, troll.armor + m.armor), + vue: Math.max(1, troll.vue + m.vue + (g.vue || 0)), + armor: Math.max(0, troll.armor + m.armor + (g.arm || 0)), armorDice: troll.armorDice, degBonus: troll.degBonus || 0, pvMax: troll.pvMax, - mmPct: m.mmPct, - rmPct: m.rmPct, + mmPct: m.mmPct + (g.mmPct || 0), + rmPct: m.rmPct + (g.rmPct || 0), concentrationPct: m.concentrationPct, dlaBonusPA: m.dlaBonusPA, }; diff --git a/js/treasures.js b/js/treasures.js index 00c83d9..0ce638b 100644 --- a/js/treasures.js +++ b/js/treasures.js @@ -80,6 +80,35 @@ function treasuresRender() { } html += ""; + const gearCount = Object.values(GEAR).reduce((n, list) => n + list.length, 0); + html += `

⚔️ Équipement (${gearCount})

+

Six emplacements : ${Object.values(GEAR_SLOTS).map(s => s.label).join(", ")}. + Les bonus ATT/ESQ/DEG/REG s'ajoutent en dés, Armure/VUE/PV sont fixes, RM/MM en %. + 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.

`; + for (const [slot, info] of Object.entries(GEAR_SLOTS)) { + html += `

${info.emoji} ${info.label}s

`; + for (const def of GEAR[slot]) { + const fx = []; + for (const [key, label, unit] of GEAR_MOD_LABELS) { + const v = def.mods[key]; + if (!v) continue; + const cls = v > 0 ? "tz-fx tz-good" : "tz-fx tz-bad"; + fx.push(`${label} ${v > 0 ? "+" : "−"}${Math.abs(v)}${unit}`); + } + if (def.twoHanded) fx.push('2 mains'); + if (def.note) fx.push(`${def.note}`); + html += ` +
+
${def.emoji} ${def.name}
+
Profondeur : −${Math.max(1, def.tier - 1)} et au-delà
+
${fx.join("")}
+
`; + } + html += "
"; + } + html += `

Effet de Zone : touche le lecteur et tous les monstres à 3 cases ou moins. « PV −2D3 ou −4D3 » (Rune des Foins) : la Mountypedia note « -2/4 D3 », interprété ici comme un tirage au sort entre 2D3 et 4D3.

`; diff --git a/server.js b/server.js index 3e34486..6c55323 100644 --- a/server.js +++ b/server.js @@ -57,7 +57,7 @@ function validateLevel(l) { if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)"; for (const i of l.items) { if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol"; - if (!["potion", "scroll", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide"; + if (!["potion", "scroll", "gear", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide"; } if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides"; const doors = l.doors || []; diff --git a/test/smoke.js b/test/smoke.js index 1642bba..692428f 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -260,9 +260,47 @@ assert.strictEqual(p.corruptionYZ(3).y, 0); assert(r.attRoll > 12, "attRoll = somme des D6 + bonus potion"); assert(r.attRoll <= 12 + 18, "attRoll borné (3D6 max 18 + 12)"); } +// Équipement Mountypedia (6 emplacements) + rétrocompat des anciens specs +const gear = require("../js/gear.js"); const sword = g.itemFromSpec({ x: 1, y: 1, kind: "weapon", idx: 2 }); assert.strictEqual(sword.kind, "gear"); -assert(sword.bonus > 0); +assert.strictEqual(sword.slot, "arme"); +assert.strictEqual(sword.name, "Épée Longue", "ancien idx 2 → Épée Longue"); +assert.strictEqual(g.itemFromSpec({ x: 1, y: 1, kind: "armor", idx: 0 }).name, "Armure de cuir"); +{ + 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, gear.gearItemByName("arme", "Gourdin")); // att +2, esq −2, deg +5 + g.equipGear(t, gear.gearItemByName("armure", "Armure de cuir")); // esq +3, arm +4, rm +40 % + let e = p.effTroll(t); + assert.strictEqual(e.att, 5, "Gourdin : ATT 3 + 2 dés"); + assert.strictEqual(e.deg, 8, "Gourdin : DEG 3 + 5 dés"); + assert.strictEqual(e.esq, 4, "ESQ 3 − 2 + 3"); + assert.strictEqual(e.armor, 4, "Armure de cuir : armure +4"); + assert.strictEqual(e.rmPct, 40, "Armure de cuir : RM +40 %"); + // PV max d'équipement + g.equipGear(t, gear.gearItemByName("bouclier", "Rondache en Bois")); // pv +5 + assert.strictEqual(t.pvMax, 35, "Rondache : PV max +5"); + // arme à 2 mains : le bouclier retourne au sac + g.equipGear(t, gear.gearItemByName("arme", "Hallebarde")); + assert.strictEqual(t.equip.bouclier, null, "Hallebarde : bouclier déséquipé"); + assert(t.bag.some(i => i.name === "Rondache en Bois"), "le bouclier est dans le sac"); + assert.strictEqual(t.pvMax, 30, "PV max retombé après déséquipement de la Rondache"); + assert(t.bag.some(i => i.name === "Gourdin"), "l'ancienne arme est dans le sac"); + e = p.effTroll(t); + assert.strictEqual(e.att, 1, "Hallebarde : ATT 3 − 5 → min 1"); + assert.strictEqual(e.deg, 15, "Hallebarde : DEG 3 + 12"); + // rééquiper un bouclier range l'arme à 2 mains + g.equipGear(t, gear.gearItemByName("bouclier", "Targe")); + assert.strictEqual(t.equip.arme, null, "Targe : la Hallebarde est rangée"); + // tirage par profondeur : tier ≤ depth + 1 + for (let i = 0; i < 100; i++) { + const it = gear.makeRandomGear(1); + const def = gear.GEAR[it.slot][it.gearIdx]; + assert(def.tier <= 2, "profondeur 1 : tier ≤ 2 (reçu " + def.tier + ")"); + } +} // Validation serveur des niveaux const srv = require("../server.js");