1.7.0 : équipement Mountypedia — 6 emplacements, 55 objets

- js/gear.js : Arme (12 + 3 à deux mains), Armure (13), Casque (9),
  Bouclier (5), Talisman (7), Bottes (6), valeurs de base officielles
- ATT/ESQ/DEG/REG en dés, Armure/VUE/PV fixes, RM/MM en % (spellMM/spellRM)
- equipGear : échange par emplacement, exclusion 2 mains/bouclier,
  PV max dynamique, gearMods intégré à effTroll
- Drops par tier ≤ profondeur + 1, butin éditeur par emplacement,
  rétrocompat des specs weapon/armor des niveaux publiés
- Page Trésors : section Équipement (6 sous-sections)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 23:56:30 +02:00
co-authored by Claude Fable 5
parent 6039497e72
commit 2f21f867a5
10 changed files with 314 additions and 51 deletions
+7
View File
@@ -152,6 +152,13 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## 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 - **1.6.0** (2026-06-11) — Parchemins 📜 : les 7 parchemins standards de Mountypedia
(Rune des Cyclopes, Traité de Clairvoyance, Idées Confuses, Rune Explosive, (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 Plan Génial, Yeu'Ki'Pic, Rune des Foins) en trésors à trouver, butin de monstre
+11
View File
@@ -520,3 +520,14 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
} }
.tz-good { color: #8fbf5a; } .tz-good { color: #8fbf5a; }
.tz-bad { color: #d88080; } .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; }
+1
View File
@@ -166,6 +166,7 @@
<script src="js/potions.js"></script> <script src="js/potions.js"></script>
<script src="js/scrolls.js"></script> <script src="js/scrolls.js"></script>
<script src="js/gear.js"></script>
<script src="js/game.js"></script> <script src="js/game.js"></script>
<script src="js/editor.js"></script> <script src="js/editor.js"></script>
<script src="js/rules.js"></script> <script src="js/rules.js"></script>
+6 -3
View File
@@ -124,8 +124,9 @@ function edBuildPalette() {
edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" }); edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" });
edAddBrushBtn(itemsDiv, "📜 Parchemin (aléatoire)", { mode: "item", kind: "scroll" }); edAddBrushBtn(itemsDiv, "📜 Parchemin (aléatoire)", { mode: "item", kind: "scroll" });
edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" }); 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 })); for (const [slot, info] of Object.entries(GEAR_SLOTS)) {
ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i })); edAddBrushBtn(itemsDiv, `${info.emoji} ${info.label} (aléatoire)`, { mode: "item", kind: "gear", slot });
}
const doorsDiv = document.getElementById("ed-doors"); const doorsDiv = document.getElementById("ed-doors");
doorsDiv.innerHTML = ""; doorsDiv.innerHTML = "";
@@ -240,6 +241,7 @@ function edApply(x, y) {
ED.items = ED.items.filter(i => i.x !== x || i.y !== y); ED.items = ED.items.filter(i => i.x !== x || i.y !== y);
const spec = { x, y, kind: b.kind }; const spec = { x, y, kind: b.kind };
if (b.idx !== undefined) spec.idx = b.idx; if (b.idx !== undefined) spec.idx = b.idx;
if (b.slot !== undefined) spec.slot = b.slot;
if (b.kind === "gold") spec.gold = 60; if (b.kind === "gold") spec.gold = 60;
ED.items.push(spec); ED.items.push(spec);
} }
@@ -279,7 +281,8 @@ function edRender() {
: i.kind === "scroll" ? "#d8c890" : "#7a8db0"); : i.kind === "scroll" ? "#d8c890" : "#7a8db0");
ctx.fillStyle = "#1a140e"; ctx.fillStyle = "#1a140e";
const emoji = i.kind === "potion" ? "🧪" : i.kind === "scroll" ? "📜" : i.kind === "gold" ? "💰" 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); ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
} }
for (const d of ED.doors) { for (const d of ED.doors) {
+60 -38
View File
@@ -4,7 +4,7 @@
"use strict"; "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. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; 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") { if (typeof module !== "undefined" && module.exports && typeof makeRandomScroll === "undefined") {
Object.assign(globalThis, require("./scrolls.js")); 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 ================= */ /* ================= Dés & règles de base ================= */
@@ -201,7 +204,9 @@ function monsterFromSpec(spec) {
return applyTemplate(type, tpl, spec.x, spec.y); 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) { function itemFromSpec(spec) {
const base = { x: spec.x, y: spec.y }; const base = { x: spec.x, y: spec.y };
if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) }; if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) };
@@ -210,8 +215,13 @@ function itemFromSpec(spec) {
const gold = spec.gold || 60; const gold = spec.gold || 60;
return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold };
} }
const list = spec.kind === "weapon" ? WEAPONS : ARMORS; if (spec.kind === "gear") return { ...base, ...makeRandomGear(5, spec.slot) };
return { ...base, kind: "gear", ...list[(spec.idx || 0) % list.length] }; 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 ================= */ /* ================= Génération du Monde Souterrain ================= */
@@ -286,30 +296,11 @@ function randomFloor(grid, taken) {
/* ================= Objets ================= */ /* ================= 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) { function makeItem(depth) {
const r = Math.random(); const r = Math.random();
if (r < 0.3) return makeRandomPotion(); if (r < 0.3) return makeRandomPotion();
if (r < 0.42) return makeRandomScroll(); if (r < 0.42) return makeRandomScroll();
if (r < 0.58) { if (r < 0.7) return makeRandomGear(depth);
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 };
}
const gold = rollDice(depth, 6).total * 10; const gold = rollDice(depth, 6).total * 10;
return { kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; 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 }, 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, comp: { pct: START_COMP_PCT }, sort: { pct: START_SORT_PCT }, fatigue: 0, compUsed: false,
compPXTurn: false, sortPXTurn: 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, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
bag: [], camo: false, kills: 0, dla: 1, tour: 1, bag: [], camo: false, kills: 0, dla: 1, tour: 1,
potionEffects: [], blockCamoTurns: 0, potionEffects: [], blockCamoTurns: 0,
@@ -610,7 +602,7 @@ function attackMonster(m, opts = {}) {
const cost = opts.cost ?? COSTS.attack; const cost = opts.cost ?? COSTS.attack;
if (!spendPA(cost)) return false; if (!spendPA(cost)) return false;
cdStart(`⚔️ Attaque sur ${m.name}`); cdStart(`⚔️ Attaque sur ${m.name}`);
cdLine(`Vous avez attaqué <b>${m.name}</b> avec votre ${G.troll.weapon ? G.troll.weapon.name : "Grosse Patte de Trõll"}.`); cdLine(`Vous avez attaqué <b>${m.name}</b> avec votre ${G.troll.equip.arme ? G.troll.equip.arme.name : "Grosse Patte de Trõll"}.`);
const r = resolveAttack(effTroll(G.troll), effMonster(m)); const r = resolveAttack(effTroll(G.troll), effMonster(m));
cdAttackRolls(r); cdAttackRolls(r);
if (r.hit) { if (r.hit) {
@@ -967,19 +959,43 @@ function useBagItem(idx) {
updateFov(); updateFov();
} else if (item.kind === "gear") { } else if (item.kind === "gear") {
if (!spendPA(COSTS.equip)) return; 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); t.bag.splice(t.bag.indexOf(item), 1);
log(`Tu t'équipes : ${item.emoji} ${item.name} (+${item.bonus}).`, "good"); equipGear(t, item);
} }
afterAction(); 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 /* 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). */ * cases ou moins du troll (le lecteur a déjà subi sa part dans readScroll). */
function applyScrollZone(zone) { function applyScrollZone(zone) {
@@ -1301,9 +1317,14 @@ function renderPanels() {
if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true); if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true);
addBtn("⏳ Passer la DLA", passDLA, true); addBtn("⏳ Passer la DLA", passDLA, true);
$("equipment").innerHTML = $("equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => {
`<div>Arme : ${t.weapon ? t.weapon.emoji + " " + t.weapon.name + " (+" + t.weapon.bonus + ")" : "—"}</div>` + const it = t.equip[slot];
`<div>Armure : ${t.armorItem ? t.armorItem.emoji + " " + t.armorItem.name + " (+" + t.armorItem.bonus + ")" : "—"}</div>`; if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fx = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${it.name}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}` +
`${fx ? `<div class="eq-mods">${fx}</div>` : ""}</div>`;
}).join("");
const inv = $("inventory"); const inv = $("inventory");
inv.innerHTML = ""; inv.innerHTML = "";
@@ -1313,6 +1334,7 @@ function renderPanels() {
const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)`
: item.kind === "scroll" ? `lire (${COSTS.scroll} PA)` : `équiper (${COSTS.equip} PA)`; : item.kind === "scroll" ? `lire (${COSTS.scroll} PA)` : `équiper (${COSTS.equip} PA)`;
b.textContent = `${item.emoji} ${item.name}${action}`; 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.disabled = G.over;
b.onclick = () => useBagItem(idx); b.onclick = () => useBagItem(idx);
inv.appendChild(b); inv.appendChild(b);
@@ -1459,7 +1481,7 @@ if (typeof document !== "undefined") {
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, 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, generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
newGame, refreshFov, inSightAt, newGame, refreshFov, inSightAt,
+149
View File
@@ -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,
};
}
+11 -8
View File
@@ -413,24 +413,27 @@ function fmtStatLine(base, eff, faces, flat = 0, extra = 0) {
return main; return main;
} }
/* Caractéristiques effectives : base + effets magiques (potions, parchemins)
* + équipement (troll.gearMods, recalculé par game.js à chaque équipement). */
function effTroll(troll) { function effTroll(troll) {
const m = sumPotionMods(troll.potionEffects); const m = sumPotionMods(troll.potionEffects);
const g = troll.gearMods || {};
return { return {
att: Math.max(1, troll.att + m.att), att: Math.max(1, troll.att + m.att + (g.att || 0)),
attFlat: m.attFlat, 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, 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, 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, regFlat: m.regFlat,
vue: Math.max(1, troll.vue + m.vue), vue: Math.max(1, troll.vue + m.vue + (g.vue || 0)),
armor: Math.max(0, troll.armor + m.armor), armor: Math.max(0, troll.armor + m.armor + (g.arm || 0)),
armorDice: troll.armorDice, armorDice: troll.armorDice,
degBonus: troll.degBonus || 0, degBonus: troll.degBonus || 0,
pvMax: troll.pvMax, pvMax: troll.pvMax,
mmPct: m.mmPct, mmPct: m.mmPct + (g.mmPct || 0),
rmPct: m.rmPct, rmPct: m.rmPct + (g.rmPct || 0),
concentrationPct: m.concentrationPct, concentrationPct: m.concentrationPct,
dlaBonusPA: m.dlaBonusPA, dlaBonusPA: m.dlaBonusPA,
}; };
+29
View File
@@ -80,6 +80,35 @@ function treasuresRender() {
} }
html += "</div>"; html += "</div>";
const gearCount = Object.values(GEAR).reduce((n, list) => n + list.length, 0);
html += `<h2 class="tz-section">⚔️ Équipement (${gearCount})</h2>
<p class="tz-intro">Six emplacements : ${Object.values(GEAR_SLOTS).map(s => s.label).join(", ")}.
Les bonus ATT/ESQ/DEG/REG s'ajoutent en <b>dés</b>, Armure/VUE/PV sont fixes, RM/MM en %.
Une arme <b>à 2 mains</b> est incompatible avec un bouclier. S'équiper coûte <b>2 PA</b>.
Valeurs de base de la Mountypedia (sans templates) ; les objets puissants ne se trouvent
qu'en profondeur.</p>`;
for (const [slot, info] of Object.entries(GEAR_SLOTS)) {
html += `<h3 class="tz-subsection">${info.emoji} ${info.label}s</h3><div class="tz-grid">`;
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(`<span class="${cls}">${label} ${v > 0 ? "+" : ""}${Math.abs(v)}${unit}</span>`);
}
if (def.twoHanded) fx.push('<span class="tz-fx">2 mains</span>');
if (def.note) fx.push(`<span class="tz-fx">${def.note}</span>`);
html += `
<div class="tz-card" style="--tz-color:#9a8a65">
<div class="tz-head"><span class="tz-name">${def.emoji} ${def.name}</span></div>
<div class="tz-meta">Profondeur : <b>${Math.max(1, def.tier - 1)} et au-delà</b></div>
<div class="tz-fx-list">${fx.join("")}</div>
</div>`;
}
html += "</div>";
}
html += `<p class="tz-note">Effet de Zone : touche le lecteur et tous les monstres à 3 cases html += `<p class="tz-note">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é 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.</p>`; ici comme un tirage au sort entre 2D3 et 4D3.</p>`;
+1 -1
View File
@@ -57,7 +57,7 @@ function validateLevel(l) {
if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)"; if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
for (const i of l.items) { for (const i of l.items) {
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol"; 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"; if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides";
const doors = l.doors || []; const doors = l.doors || [];
+39 -1
View File
@@ -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, "attRoll = somme des D6 + bonus potion");
assert(r.attRoll <= 12 + 18, "attRoll borné (3D6 max 18 + 12)"); 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 }); const sword = g.itemFromSpec({ x: 1, y: 1, kind: "weapon", idx: 2 });
assert.strictEqual(sword.kind, "gear"); 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 // Validation serveur des niveaux
const srv = require("../server.js"); const srv = require("../server.js");