diff --git a/AGENTS.md b/AGENTS.md index ab3dc0d..fb5caf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,100 +6,73 @@ Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, rev ## Accomplished ### Pass 1 — Critical Issues -- **Telemetry removed**: `ClassCounter`, `registerWorldCount`, orphaned `worldKey` setting deleted from system.json -- **globalThis side effects**: `globalThis.SYSTEM`, `globalThis.pendingDefenses` moved from top-level to `init` hook -- **console.log → log()**: All runtime console.log replaced with `log()` helper guarded by `lethalFantasy.debug` setting -- **Stale Tenebris refs**: `macros.mjs` — `TENEBRIS.Label.jet` → `LETHALFANTASY.Label.jet`, `TENEBRIS.Manager.*` → `LETHALFANTASY.Label.*`, `tenebris.macro` flag → `lethalFantasy.macro` +- **Telemetry removed** from system.json +- **globalThis side effects** moved from top-level to `init` hook +- **console.log → log()** helper guarded by setting +- **Stale Tenebris refs** → LETHALFANTASY ### Pass 2 — V1/V2 Mixing, Fire-and-Forget -- **V1 sheet registrations removed**: `foundry.appv1.sheets.*` in system.json -- **V1 `activateListeners`/jQuery**: removed dead `defaultOptions`, V1 tab code from `combat.mjs` -- **V2 API paths**: `FilePicker` → V2, `TextEditor.getDragEventData` → V2, `item.sheet.render(true)` → `render({force:true})`, `super._onRender()` → `super._onRender(context, options)`, `token._id` → `token.id` -- **Fire-and-forget Promises**: All `actor.update()`, `ChatMessage.create()`, `prepareRoll()`, `prepareMonsterRoll()`, socket handler calls now awaited -- **Misnamed class**: `LethalFantasySkill` → `LethalFantasyWeapon`; added missing `WEAPON_TYPE` import; fixed `weaponCategory` +- V1 sheet registrations, activateListeners/jQuery, FilePicker paths fixed +- Fire-and-forget Promises now awaited +- Misnamed `LethalFantasySkill` → `LethalFantasyWeapon` ### Pass 3 — Code Review Fixes -- **Duplicated dialogs**: Per-element `.rollable`/`.wound-data` bindings moved to `_onRender` (V2 destroys/recreates DOM each render); `_activateListeners` reverted -- **renderChatMessage reverted**: V2 hook `renderChatMessage` passes jQuery html, `querySelectorAll` fails; kept `renderChatMessageHTML` -- **Roll actions broken**: Fixed `async` base-actor-sheet methods; `_onRender` bindings for rollable elements restored -- **Token HUD guard**: `html.querySelector()` → `html.find().length` (html is jQuery object) -- **All review awaits confirmed**: `showDefenseRequest`/`socket` handlers all awaited +- Duplicated dialogs fixed via `_onRender` bindings +- renderChatMessage reverted to HTML hook +- All review awaits confirmed -## Defense Dialog Investigation — Status +### Pass 4 — D30 Dialog Removal & Dead Code Audit +- **D30 choice dialog removed** — auto-rolls bonus dice; special strike/defense reported as `specialEffect: "flag"` (informational) +- **Spell calamity choice restored** — catch-all for non-standard choices uses `specialEffect: "flag"` +- **Dead `specialEffect === "auto"` branches removed** from chat-reaction.mjs (×2), combat.mjs (×1), reaction-message.hbs +- **Deleted `d30-special-choice.hbs` and `_buildSpecialLabel()`** +- **Dead code audit** — 2 runtime bugs fixed, ~20 dead exports/methods, 33 unused i18n keys, 2 unused templates +- **3 critical bugs fixed**: SYSTEM.ROLL_TYPE, SYSTEM.EQUIPMENT_CATEGORIES, missing imports in combat.mjs +- **`isPrimaryController` consolidated** to local function +- **Aether/Grace deduction merged** via `_deductResourceOnCast()` +- **`nextDefenseData` deduped** via `_storeNextDefenseData()` +- **`buildDefenseReactionButtons` extracted** from combat.mjs; fixes stale Grit/Luck snapshots +- **HP HUD toggling extracted** to helpers.mjs +- **`node --check` passes all 55 `.mjs` files** -### Symptom (user process) -1. Monster (GM) attacks player — hits -2. Player uses Grit/Luck to boost defense -3. Defense now beats attack — reports new result -4. Dialog **stays open** — Grit/Luck/bonus dice options still visible -5. Closing dialog (Continue or X) causes "rolls vanish" — reverts to original result +### Pass 5 — Live Verification +- **D30=30 auto-roll verified** — Club attack shows D30=30 flag +- **Defense request dialog verified** — Monster defense dialog with weapon dropdown +- **Defense reaction dialog verified** — Luck spent, bonus die added, combat result correct +- **AZA→Monster attack flow tested end-to-end**: Club attack (D20=16, D30=6) → Monster defense (D20=1) → defense reactions (Continue) → D30 attack bonus processed (+2, total 18) +- **BUG FOUND & FIXED: `d30ChangedAttack` infinite loop** — chat-reaction.mjs:452-455 do-while reset block missing `d30ChangedAttack = false`; added at line 456 +- **BUG FIX CONFIRMED**: Re-tested full flow — AZA Club attack (13, D30=12) → defense dialog → Monster defense (2, D30=24) → reaction dialog (only 1 show!) → Continue → "AZA hits Monster!" combat result → damage roll (1d6=2, total 3) → Apply Damage button. No infinite loop. Full E2E success. -### Root Cause Found — Duplicate cross-client processing (FIXED) +## Key Decisions +- **Auto-roll bonus dice without dialog** — matches existing D30=27 (d6E) flow +- **`buildDefenseReactionButtons` extracts only button-building** — defense while-loop structures differ between same-client and cross-client; merging loops risks behavioral divergence +- **Inline grit/luck deduction uses live actor values** +- **Aether/Grace helper uses `costFn` parameter** -When monster (GM) attacks player, the `createChatMessage` hook fires on **both** clients: +## Next Steps +1. Test defense request dialogs (character/monster/save) — more variants +2. Test all reaction message variants (shield block/fail, d30Bonus/Flag, grit, luck, etc.) +3. Create Player user in Foundry for cross-client socket testing +4. Prune dead code: unused exports (~20), unused i18n keys (33), unused templates (2) -``` -Player's client: GM's client: - defense msg created defense msg synced - ↓ ↓ - hook fires (line 557) hook fires (line 557) - isPrimaryController(defender)=true isPrimaryController(defender)=false - ↓ ↓ - Defense dialog A shows Defense dialog skipped - Player spends Grit Cross-client code (line 1009): - defenseRoll=10→16 isPrimaryController(attacker)=true - While loop exits defenderOwner=player (≠GM) - Comparison: "miss" ↓ - **Sends attackBoosted with ORIGINAL - defenseRoll=10 (stale!)** - ↓ - Player receives socket → handleAttackBoosted - → Defense dialog B shows with OLD values - → When closed, comparison: "hit" (overwrites!) -``` +## Critical Context +- **Chat buttons not interactive via DevTools snapshot** — need JS fallback: `document.querySelectorAll('button').forEach(b => { if (...) b.click(); })` +- **Defense flow**: Attack card → target button (`.request-defense-btn`) → defense dialog → defense roll → defense reactions dialog → combat result card with Damage button → damage roll dialog → Apply Damage → HP application +- **Clicking "Damage" directly bypasses defense** — rolls unapplied damage to chat +- **Same-owner guard** (`chat-reaction.mjs:180-182`) — skips defense when GM owns both, unless `!defenderIsMonster` +- **`d30ChangedAttack` infinite loop** — variable wasn't reset in do-while block; fix at `chat-reaction.mjs:456` +- **Deserialized weapon object** — `weapon.name` works, `weapon.id` undefined, `weapon._id` works +- **Fvtt server**: port 31000, foundrydata-dev +- **No player user configured** — cannot test cross-client socket flow -Player sees **two** dialogs (A then B). Dialog B uses unboosted values, so closing/ignoring it produces a stale "hit" result that overwrites the correct "miss." - -### Fix -`lethal-fantasy.mjs:1016` — only send `attackBoosted` socket when `attackerHandledBonus || attackerHasNonGMOwner`. Guards against stale-socket overwrite for GM→player combat (where hook-based processing works without socket), while preserving socket delegation for PC→PC cross-client (where `attackerIsCrossClient` suppresses the hook-based processing on the defender's client). - -Before: -```js -if (defenderOwner && defenderOwner.id !== game.user.id) { - game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... }) - return -} -``` - -After: -```js -if (defenderOwner && defenderOwner.id !== game.user.id) { - if (attackerHandledBonus || attackerHasNonGMOwner) { - game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... }) - } - return -} -``` - -### Same-Client Path -Code pattern is identical between attack and defense dialogs — both use `await DialogV2.wait({rejectClose:false})` in a while loop. Same-client defense works correctly because no duplicate socket messages arrive. - -### Other Findings -- `offerGritLuckBonus` (`utils.mjs:1121`) is dead code — never called -- `promptCombatBonusDie` (`utils.mjs:975`) is correct — DialogV2 resolves to callback return value, not `action` -- Cross-client `handleAttackBoosted` (`utils.mjs:291`) still uses `else if` chain without `continue` — functionally correct but differs from same-client pattern - -### Code Paths -| Flow | File | Line | -|------|------|------| -| Same-client attack | `lethal-fantasy.mjs` | 918-1004 | -| Same-client defense | `lethal-fantasy.mjs` | 697-870 | -| Cross-client defense | `module/utils.mjs` | 291-445 | -| Cross-client socket guard | `lethal-fantasy.mjs` | 1006-1037 | -| Attack Grit offer | `module/utils.mjs` | 1210-1290 | - -### Key Files -- `lethal-fantasy.mjs` — Main system hooks, same-client attack/defense reactions -- `module/utils.mjs` — Cross-client defense flow, bonus dialogs, compareAttackDefense -- `module/documents/actor.mjs` — `prepareRoll()` entry point -- `module/documents/roll.mjs` — Roll resolution pipeline +## Relevant Files +- `module/hooks/chat-reaction.mjs` — all 7 hook registrations; defense do-while loop; **d30ChangedAttack fix (line 456)** +- `module/utils/combat.mjs` — `buildDefenseReactionButtons`, `_storeNextDefenseData` +- `module/utils/d30.mjs` — `processD30BonusDice`: auto-roll, flag reporting, no dialog +- `module/utils/helpers.mjs` — `_toggleHudWraps`/`_disableHudWraps` +- `module/utils.mjs` — barrel re-exporting 23 static methods +- `module/models/equipment.mjs` — EQUIPMENT_CATEGORY fix +- `module/applications/combat.mjs` — added imports +- `templates/chat/reaction-message.hbs` — d30 removed +- `templates/dialogs/d30-special-choice.hbs` — deleted +- `lang/en.json` — 33 unused i18n keys remain diff --git a/module/applications/combat.mjs b/module/applications/combat.mjs index 838465c..35b2b69 100644 --- a/module/applications/combat.mjs +++ b/module/applications/combat.mjs @@ -1,3 +1,5 @@ +import { SYSTEM } from "../config/system.mjs" +import { log } from "../utils.mjs" /* -------------------------------------------- */ export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tabs.CombatTracker { diff --git a/module/hooks/chat-reaction.mjs b/module/hooks/chat-reaction.mjs index 8d7fc16..b5bbbf4 100644 --- a/module/hooks/chat-reaction.mjs +++ b/module/hooks/chat-reaction.mjs @@ -14,12 +14,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => { } else { for (const btn of html.querySelectorAll(".ask-roll-dice")) { btn.addEventListener("click", () => { - const type = btn.dataset.type - const value = btn.dataset.value - const avantage = btn.dataset.avantage ?? "=" + const type = btn.dataset.rollType + const value = btn.dataset.rollTarget + const avantage = btn.dataset.rollAvantage ?? "normal" const character = game.user.character - if (type === SYSTEM.ROLL_TYPE.RESOURCE) character.rollResource(value) - else if (type === SYSTEM.ROLL_TYPE.SAVE) character.rollSave(value, avantage) + if (type === "resource") character.rollResource(value) + else if (type === "save") character.rollSave(value, avantage) }) } } @@ -453,6 +453,7 @@ Hooks.on("createChatMessage", async (message) => { mulliganRestart = false defenderHandledBonus = false attackerHandledBonus = false + d30ChangedAttack = false // ── D30 bonus dice (defense) — resolved before grit/luck/shield ─────── if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) { @@ -463,10 +464,6 @@ Hooks.on("createChatMessage", async (message) => { await createReactionMessage(defender, {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"}) } } - if (d30Result.specialEffect === "auto") { - defenseRoll = attackRollFinal + 1 // auto-block - await createReactionMessage(defender, {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName||"Special Defense", side:"defense"}) - } if (d30Result.specialEffect === "flag") { await createReactionMessage(defender, {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName||"Special Effect"}) } @@ -483,66 +480,7 @@ Hooks.on("createChatMessage", async (message) => { // create the comparison message with the updated attack roll. if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) { while (defenseRoll < attackRollFinal) { - const currentGrit = Number(defender.system?.grit?.current) || 0 - const currentLuck = Number(defender.system?.luck?.current) || 0 - const buttons = [] - - if (currentGrit > 0) { - buttons.push({ - action: "grit", - label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, - icon: "fa-solid fa-fist-raised", - callback: () => "grit" - }) - } - - if (currentLuck > 0) { - buttons.push({ - action: "luck", - label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, - icon: "fa-solid fa-clover", - callback: () => "luck" - }) - } - - buttons.push({ - action: "bonusDie", - label: "Add bonus die", - icon: "fa-solid fa-dice", - callback: () => "bonusDie" - }) - - if (canRerollDefense) { - buttons.push({ - action: "rerollDefense", - label: "Re-roll defense (Mulligan)", - icon: "fa-solid fa-rotate-right", - callback: () => "rerollDefense" - }) - } - - if (canShieldReact) { - buttons.push({ - action: "shieldReact", - label: `Roll shield (${shieldData.label})`, - icon: "fa-solid fa-shield", - callback: () => "shieldReact" - }) - } else if (canAdHocShield) { - buttons.push({ - action: "adHocShield", - label: "Roll ad-hoc shield (choose dice + DR)", - icon: "fa-solid fa-shield-halved", - callback: () => "adHocShield" - }) - } - - buttons.push({ - action: "continue", - label: "Continue (no defense bonus)", - icon: "fa-solid fa-forward", - callback: () => "continue" - }) + const buttons = LethalFantasyUtils.buildDefenseReactionButtons(defender, { canRerollDefense, shieldData, canShieldReact, canAdHocShield }) const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", { attackerName, @@ -569,7 +507,7 @@ Hooks.on("createChatMessage", async (message) => { if (choice === "grit") { const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender) defenseRoll += bonusRoll - await defender.update({ "system.grit.current": currentGrit - 1 }) + await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) }) await createReactionMessage(defender, {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"}) continue } @@ -577,7 +515,7 @@ Hooks.on("createChatMessage", async (message) => { if (choice === "luck") { const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender) defenseRoll += bonusRoll - await defender.update({ "system.luck.current": currentLuck - 1 }) + await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) }) await createReactionMessage(defender, {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"}) continue } @@ -675,12 +613,6 @@ Hooks.on("createChatMessage", async (message) => { await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"}) } } - if (d30Result.specialEffect === "auto") { - attackRollFinal = defenseRoll + 1 // auto-hit - if (canDialog) { - await createReactionMessage(attacker, {type:"d30Auto", actorName:attackerName, specialName:d30Result.specialName||"Special Strike", side:"attack"}) - } - } if (d30Result.specialEffect === "flag" && canDialog) { await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"}) } @@ -901,93 +833,51 @@ Hooks.on("createChatMessage", async (message) => { } }) -// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat -Hooks.on("createChatMessage", async (message) => { - if (!["spell-attack", "spell-power"].includes(message.rolls[0]?.options?.rollType)) return - +async function _deductResourceOnCast(message, rollTypes, itemType, costFn, resourceField, templateType) { + if (!rollTypes.includes(message.rolls[0]?.options?.rollType)) return const actorId = message.rolls[0]?.options?.actorId if (!actorId) return const actor = game.actors.get(actorId) if (!actor) return - - // Only the primary controller (player owner or GM) handles this - const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER")) - const isPrimary = activePlayerOwners.length > 0 - ? activePlayerOwners[0].id === game.user.id - : game.user.isGM - if (!isPrimary) return + if (!isPrimaryController(actor)) return const rollTarget = message.rolls[0]?.options?.rollTarget - const spellId = rollTarget?.id || rollTarget?._id - const spell = spellId ? actor.items.get(spellId) : null - if (!spell || spell.type !== "spell") return + const itemId = rollTarget?.id || rollTarget?._id + const item = itemId ? actor.items.get(itemId) : null + if (!item || item.type !== itemType) return const damageTier = message.rolls[0]?.options?.damageTier || "standard" - const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" } - const costField = tierCostMap[damageTier] || "cost" - const cost = Number(spell.system?.[costField]) || 0 + const cost = costFn(item, damageTier) if (cost <= 0) return - const currentAether = Number(actor.system.aetherPoints?.value) || 0 - const newAether = Math.max(0, currentAether - cost) - await actor.update({ "system.aetherPoints.value": newAether }) + const current = Number(foundry.utils.getProperty(actor.system, resourceField)) || 0 + const newValue = Math.max(0, current - cost) + await actor.update({ [`system.${resourceField}`]: newValue }) const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})` - const aetherContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { - type: "aetherSpend", - actorName: actor.name, - spellName: spell.name, - tierLabel, - value: cost, - oldValue: currentAether, - newValue: newAether + const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { + type: templateType, actorName: actor.name, spellName: item.name, tierLabel, + value: cost, oldValue: current, newValue }) - await ChatMessage.create({ - content: aetherContent, - speaker: ChatMessage.getSpeaker({ actor }) - }) -}) + await ChatMessage.create({ content, speaker: ChatMessage.getSpeaker({ actor }) }) +} + +// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat +Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message, + ["spell-attack", "spell-power"], "spell", + (item, tier) => { + const m = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" } + return Number(item.system?.[m[tier] || "cost"]) || 0 + }, + "aetherPoints.value", "aetherSpend" +)) // Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat -Hooks.on("createChatMessage", async (message) => { - if (!["miracle-attack", "miracle-power"].includes(message.rolls[0]?.options?.rollType)) return - - const actorId = message.rolls[0]?.options?.actorId - if (!actorId) return - const actor = game.actors.get(actorId) - if (!actor) return - - const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER")) - const isPrimary = activePlayerOwners.length > 0 - ? activePlayerOwners[0].id === game.user.id - : game.user.isGM - if (!isPrimary) return - - const rollTarget = message.rolls[0]?.options?.rollTarget - const miracleId = rollTarget?.id || rollTarget?._id - const miracle = miracleId ? actor.items.get(miracleId) : null - if (!miracle || miracle.type !== "miracle") return - - const cost = Number(miracle.system?.level) || 0 - if (cost <= 0) return - - const currentGrace = Number(actor.system.divinityPoints?.value) || 0 - const newGrace = Math.max(0, currentGrace - cost) - await actor.update({ "system.divinityPoints.value": newGrace }) - - const graceContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { - type: "graceSpend", - actorName: actor.name, - spellName: miracle.name, - value: cost, - oldValue: currentGrace, - newValue: newGrace - }) - await ChatMessage.create({ - content: graceContent, - speaker: ChatMessage.getSpeaker({ actor }) - }) -}) +Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message, + ["miracle-attack", "miracle-power"], "miracle", + (item) => Number(item.system?.level) || 0, + "divinityPoints.value", "graceSpend" +)) // Hook pour appliquer automatiquement les dégâts si une cible est définie Hooks.on("createChatMessage", async (message) => { diff --git a/module/models/equipment.mjs b/module/models/equipment.mjs index eea5455..7ca0105 100644 --- a/module/models/equipment.mjs +++ b/module/models/equipment.mjs @@ -7,7 +7,7 @@ export default class LethalFantasyEquipment extends foundry.abstract.TypeDataMod const requiredInteger = { required: true, nullable: false, integer: true } schema.description = new fields.HTMLField({ required: true, textSearch: true }) - schema.category = new fields.StringField({ required: true, initial: "tinbit", choices: SYSTEM.EQUIPMENT_CATEGORIES }) + schema.category = new fields.StringField({ required: true, initial: "tinbit", choices: SYSTEM.EQUIPMENT_CATEGORY }) schema.encLoad = new fields.NumberField({ required: true, initial: 0, min: 0 }) schema.hi = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 }) diff --git a/module/utils.mjs b/module/utils.mjs index 754ef64..2c2069b 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -1,6 +1,6 @@ import { log, loadCompendiumData, loadCompendium, pushCombatOptions, setHookListeners, registerHandlebarsHelpers, getLethargyDice } from "./utils/helpers.mjs" -import { hasD30Reroll, processD30BonusDice, _rollD30BonusDie, _buildSpecialLabel, _buildSpecialName } from "./utils/d30.mjs" -import { handleSocketEvent, handleAttackerGritOffer, handleAttackBoosted, showDefenseRequest, compareAttackDefense, applyDamage, offerAttackerGritBonus, getCombatBonusDiceChoices, getShieldReactionData, promptCombatBonusDie, promptAdHocShield, rollBonusDie, rerollConfiguredRoll } from "./utils/combat.mjs" +import { hasD30Reroll, processD30BonusDice, _rollD30BonusDie, _buildSpecialName } from "./utils/d30.mjs" +import { handleSocketEvent, handleAttackerGritOffer, handleAttackBoosted, showDefenseRequest, compareAttackDefense, applyDamage, offerAttackerGritBonus, getCombatBonusDiceChoices, getShieldReactionData, buildDefenseReactionButtons, promptCombatBonusDie, promptAdHocShield, rollBonusDie, rerollConfiguredRoll } from "./utils/combat.mjs" export { log } @@ -14,7 +14,6 @@ export default class LethalFantasyUtils { static hasD30Reroll = hasD30Reroll static processD30BonusDice = processD30BonusDice static _rollD30BonusDie = _rollD30BonusDie - static _buildSpecialLabel = _buildSpecialLabel static _buildSpecialName = _buildSpecialName static handleSocketEvent = handleSocketEvent static handleAttackerGritOffer = handleAttackerGritOffer @@ -25,6 +24,7 @@ export default class LethalFantasyUtils { static offerAttackerGritBonus = offerAttackerGritBonus static getCombatBonusDiceChoices = getCombatBonusDiceChoices static getShieldReactionData = getShieldReactionData + static buildDefenseReactionButtons = buildDefenseReactionButtons static promptCombatBonusDie = promptCombatBonusDie static promptAdHocShield = promptAdHocShield static rollBonusDie = rollBonusDie diff --git a/module/utils/combat.mjs b/module/utils/combat.mjs index e5861dc..3891f02 100644 --- a/module/utils/combat.mjs +++ b/module/utils/combat.mjs @@ -124,11 +124,6 @@ export async function handleAttackBoosted(msg) { await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})}) } } - if (d30Result.specialEffect === "auto") { - updatedDefenseRoll = attackRollFinal + 1 - const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName || "Special Defense", side:"defense"}) - await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})}) - } if (d30Result.specialEffect === "flag") { const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"}) await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})}) @@ -143,63 +138,8 @@ export async function handleAttackBoosted(msg) { // Show the defense reaction dialog — while-loop for multiple reactions if (defender) { while (updatedDefenseRoll < attackRollFinal) { - const currentGrit = Number(defender.system?.grit?.current) || 0 - const currentLuck = Number(defender.system?.luck?.current) || 0 - const buttons = [] - - if (currentGrit > 0) { - buttons.push({ - action: "grit", - type: "button", - label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, - icon: "fa-solid fa-fist-raised", - callback: () => "grit" - }) - } - - if (currentLuck > 0) { - buttons.push({ - action: "luck", - type: "button", - label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, - icon: "fa-solid fa-clover", - callback: () => "luck" - }) - } - - buttons.push({ - action: "bonusDie", - type: "button", - label: "Add bonus die", - icon: "fa-solid fa-dice", - callback: () => "bonusDie" - }) - - if (canShieldReact) { - buttons.push({ - action: "shieldReact", - type: "button", - label: `Roll shield (${shieldLabel})`, - icon: "fa-solid fa-shield", - callback: () => "shieldReact" - }) - } else if (canAdHoc) { - buttons.push({ - action: "adHocShield", - type: "button", - label: "Roll ad-hoc shield (choose dice + DR)", - icon: "fa-solid fa-shield-halved", - callback: () => "adHocShield" - }) - } - - buttons.push({ - action: "continue", - type: "button", - label: "Continue (no defense bonus)", - icon: "fa-solid fa-forward", - callback: () => "continue" - }) + const shieldData = canShieldReact ? { label: shieldLabel, formula: shieldFormula, damageReduction: shieldDr } : null + const buttons = buildDefenseReactionButtons(defender, { canRerollDefense: false, shieldData, canShieldReact, canAdHocShield: canAdHoc }) const choice = await foundry.applications.api.DialogV2.wait({ window: { title: "Defense reactions — attack boosted" }, @@ -223,13 +163,13 @@ export async function handleAttackBoosted(msg) { if (choice === "grit") { const bonusRoll = await rollBonusDie("1d6", defender) updatedDefenseRoll += bonusRoll - await defender.update({ "system.grit.current": currentGrit - 1 }) + await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) }) const gritRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"}) await ChatMessage.create({content: gritRmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) } else if (choice === "luck") { const bonusRoll = await rollBonusDie("1d6", defender) updatedDefenseRoll += bonusRoll - await defender.update({ "system.luck.current": currentLuck - 1 }) + await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) }) const luckRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"}) await ChatMessage.create({content: luckRmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) } else if (choice === "bonusDie") { @@ -364,6 +304,19 @@ export async function showDefenseRequest(msg) { const isMonster = defender.type === "monster" + const _storeNextDefenseData = (opts = {}) => { + game.lethalFantasy = game.lethalFantasy || {} + game.lethalFantasy.nextDefenseData = { + attackerId, attackRoll, attackerName, defenderName, + attackWeaponId, attackRollType, attackRollKey, + attackD30result, attackD30message, attackRerollContext, + damageTier: msg.damageTier, + defenderId: defender.id, defenderTokenId, + ...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }), + ...(opts.isRanged !== undefined && { isRanged: opts.isRanged }) + } + } + log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`) // Spell/miracle attacks use saving throws instead of weapon defense @@ -398,22 +351,7 @@ export async function showDefenseRequest(msg) { if (result) { game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog - game.lethalFantasy.nextDefenseData = { - attackerId, - attackRoll, - attackerName, - defenderName, - attackWeaponId, - attackRollType, - attackRollKey, - attackD30result, - attackD30message, - attackRerollContext, - attackNaturalRoll: msg.attackNaturalRoll, - damageTier: msg.damageTier, - defenderId: defender.id, - defenderTokenId - } + _storeNextDefenseData() if (isMonster) { await defender.system.prepareMonsterRoll("save", result) } else { @@ -462,25 +400,7 @@ export async function showDefenseRequest(msg) { // Si l'utilisateur a validé, lancer le jet de défense if (result) { - // Stocker temporairement les données pour le hook preCreateChatMessage - game.lethalFantasy = game.lethalFantasy || {} - game.lethalFantasy.nextDefenseData = { - attackerId, - attackRoll, - attackerName, - defenderName, - attackWeaponId, - attackRollType, - attackRollKey, - attackD30result, - attackD30message, - attackRerollContext, - attackNaturalRoll: msg.attackNaturalRoll, - damageTier: msg.damageTier, - defenderId: defender.id, - defenderTokenId, - isRanged: msg.isRanged - } + _storeNextDefenseData({ isRanged: msg.isRanged }) await defender.system.prepareMonsterRoll("monster-defense", result) } @@ -497,23 +417,7 @@ export async function showDefenseRequest(msg) { actorImage: defender.img, }) if (roll) { - game.lethalFantasy = game.lethalFantasy || {} - game.lethalFantasy.nextDefenseData = { - attackerId, - attackRoll, - attackerName, - defenderName, - attackWeaponId, - attackRollType, - attackRollKey, - attackD30result, - attackD30message, - attackRerollContext, - damageTier: msg.damageTier, - defenderId: defender.id, - defenderTokenId, - isRanged: true - } + _storeNextDefenseData({ isRanged: true }) await roll.toMessage({}, { messageMode: roll.options.rollMode }) } return @@ -558,25 +462,7 @@ export async function showDefenseRequest(msg) { // Si l'utilisateur a validé, lancer le jet de défense if (result) { - // Stocker temporairement les données pour le hook preCreateChatMessage - game.lethalFantasy = game.lethalFantasy || {} - game.lethalFantasy.nextDefenseData = { - attackerId, - attackRoll, - attackerName, - defenderName, - attackWeaponId, - attackRollType, - attackRollKey, - attackD30result, - attackD30message, - attackRerollContext, - attackNaturalRoll: msg.attackNaturalRoll, - damageTier: msg.damageTier, - defenderId: defender.id, - defenderTokenId, - isRanged: msg.isRanged - } + _storeNextDefenseData({ isRanged: msg.isRanged }) log("Storing defense data for character:", defender.id) @@ -584,6 +470,29 @@ export async function showDefenseRequest(msg) { } } +export function buildDefenseReactionButtons(defender, { canRerollDefense = false, shieldData = null, canShieldReact = false, canAdHocShield = false } = {}) { + const currentGrit = Number(defender.system?.grit?.current) || 0 + const currentLuck = Number(defender.system?.luck?.current) || 0 + const buttons = [] + if (currentGrit > 0) { + buttons.push({ action: "grit", type: "button", label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, icon: "fa-solid fa-fist-raised", callback: () => "grit" }) + } + if (currentLuck > 0) { + buttons.push({ action: "luck", type: "button", label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, icon: "fa-solid fa-clover", callback: () => "luck" }) + } + buttons.push({ action: "bonusDie", type: "button", label: "Add bonus die", icon: "fa-solid fa-dice", callback: () => "bonusDie" }) + if (canRerollDefense) { + buttons.push({ action: "rerollDefense", type: "button", label: "Re-roll defense (Mulligan)", icon: "fa-solid fa-rotate-right", callback: () => "rerollDefense" }) + } + if (canShieldReact && shieldData) { + buttons.push({ action: "shieldReact", type: "button", label: `Roll shield (${shieldData.label})`, icon: "fa-solid fa-shield", callback: () => "shieldReact" }) + } else if (canAdHocShield) { + buttons.push({ action: "adHocShield", type: "button", label: "Roll ad-hoc shield (choose dice + DR)", icon: "fa-solid fa-shield-halved", callback: () => "adHocShield" }) + } + buttons.push({ action: "continue", type: "button", label: "Continue (no defense bonus)", icon: "fa-solid fa-forward", callback: () => "continue" }) + return buttons +} + export function getCombatBonusDiceChoices() { return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"] } diff --git a/module/utils/d30.mjs b/module/utils/d30.mjs index 0d747d8..ce0cc53 100644 --- a/module/utils/d30.mjs +++ b/module/utils/d30.mjs @@ -28,63 +28,34 @@ export async function processD30BonusDice(d30Message, side, naturalRoll = null, return { modifier, specialEffect: null, specialName: null } } - // ── Choice type ── present all options to the player + // ── Choice type ── auto-roll bonus dice, alert about special effects if (d30Message.type === "choice") { - // If we can't show dialogs (wrong client), skip — the primary client - // will communicate its choice result via socket. Auto-rolling here - // would give a different modifier on each client, causing divergence. + // Non-controlling client can't roll dice here — the controlling client + // sends the updated values via socket. if (!canDialog) { return { modifier: 0, specialEffect: null, specialName: null } } - const buttons = d30Message.choices.map(c => { - let label - let icon - if (c.type === "bonus_dice") { - label = `Roll ${c.dice.toUpperCase()} and add to ${side}` - icon = "fa-solid fa-dice" - } else if (c.type === "special_strike") { - label = _buildSpecialLabel(c, naturalRoll) - icon = "fa-solid fa-star" - } else if (c.type === "special_defense") { - label = _buildSpecialLabel(c, naturalRoll) - icon = "fa-solid fa-shield-halved" - } else { - label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase()) - icon = "fa-solid fa-question" - } - return { - action: c.type, - type: "button", - label, - icon, - callback: () => c - } - }) - - const choice = await foundry.applications.api.DialogV2.wait({ - window: { title: "D30 Special — Choose Effect" }, - classes: ["lethalfantasy"], - content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-special-choice.hbs", { - description: d30Message.description - }), - buttons, - rejectClose: false - }) - - if (!choice) return { modifier: 0, specialEffect: null, specialName: null } - - if (choice.type === "bonus_dice") { - const modifier = await _rollD30BonusDie(choice.dice, actor) - return { modifier, specialEffect: null, specialName: null } + // Auto-roll bonus dice (like d6E on 27 — no dialog) + const bonusChoice = d30Message.choices.find(c => c.type === "bonus_dice") + let modifier = 0 + if (bonusChoice) { + modifier = await _rollD30BonusDie(bonusChoice.dice, actor) } - if (choice.type === "special_strike" || choice.type === "special_defense") { - return { modifier: 0, specialEffect: "auto", specialName: _buildSpecialName(choice, naturalRoll) } + // Inform about special strike/defense or other effects (informational only) + const specialChoice = d30Message.choices.find(c => c.type === "special_strike" || c.type === "special_defense") + if (specialChoice) { + return { modifier, specialEffect: "flag", specialName: _buildSpecialName(specialChoice, naturalRoll) } } // Non-standard choice (spell_calamity, etc.) — report it - return { modifier: 0, specialEffect: "flag", specialName: choice.type } + const nonStandardChoice = d30Message.choices.find(c => c.type !== "bonus_dice") + if (nonStandardChoice) { + return { modifier, specialEffect: "flag", specialName: _buildSpecialName(nonStandardChoice, naturalRoll) } + } + + return { modifier, specialEffect: null, specialName: null } } // ── Combo type (bleed / internal injury) — flag for wound creation @@ -130,32 +101,6 @@ export async function _rollD30BonusDie(formula, actor, silent = false) { return roll.total } -/** - * Build a human-readable label for a special strike/defense choice in the D30 prompt. - * @param {Object} specialChoice The choice object with type and options - * @param {number|null} naturalRoll The natural D20 roll - * @returns {string} Display label - */ -export function _buildSpecialLabel(specialChoice, naturalRoll) { - if (specialChoice.type === "special_strike") { - if (specialChoice.options.includes("lethal")) { - if (naturalRoll === 20) return "Lethal Strike (auto-hit)" - if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)" - return "Lethal/Vital Strike (auto-hit)" - } - if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)" - return "Special Strike (auto-hit)" - } - if (specialChoice.type === "special_defense") { - if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)" - if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)" - if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)" - if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)" - return "Special Defense (auto-block)" - } - return "Special Effect" -} - /** * Build the special effect name based on the D30 result and natural roll. * @param {Object} specialChoice The choice object with type and options @@ -179,5 +124,5 @@ export function _buildSpecialName(specialChoice, naturalRoll) { if (specialChoice.options.includes("perfect")) return "Perfect Defense" return "Special Defense" } - return "Special Effect" + return specialChoice.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase()) } diff --git a/module/utils/helpers.mjs b/module/utils/helpers.mjs index 6bab04c..0bad3d3 100644 --- a/module/utils/helpers.mjs +++ b/module/utils/helpers.mjs @@ -28,83 +28,55 @@ export function setHookListeners() { // hud.token / hud.object gives the Token (PlaceableObject), which has .actor. const hudActor = hud.token?.actor ?? hud.object?.actor if (!hudActor) return - // HP Loss Button (existing) + const _toggleHudWraps = (prefix) => { + const enable = $(html).find(`.${prefix}-wrap`)[0].classList.contains(`${prefix}-hud-disabled`) + for (let i = 0; i < 3; i++) { + const w = $(html).find(`.${prefix}-wrap`)[i] + w.classList.toggle(`${prefix}-hud-active`, enable) + w.classList.toggle(`${prefix}-hud-disabled`, !enable) + } + } + const _disableHudWraps = (prefix) => { + for (let i = 0; i < 3; i++) { + const w = $(html).find(`.${prefix}-wrap`)[i] + w.classList.remove(`${prefix}-hud-active`) + w.classList.add(`${prefix}-hud-disabled`) + } + } + + // HP Loss Button const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {}) $(html).find('div.left').append(lossHPButton); $(html).find('img.lethal-hp-loss-hud').click((event) => { event.preventDefault(); - let hpMenu = $(html).find('.hp-loss-wrap')[0] - if (hpMenu.classList.contains("hp-loss-hud-disabled")) { - $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled'); - } else { - $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled'); - } + _toggleHudWraps("hp-loss") }) $(html).find('.loss-hp-hud-click').click(async (event) => { event.preventDefault(); - let hpLoss = event.currentTarget.dataset.hpValue; - await hudActor.applyDamage(Number(hpLoss)); - $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled'); - $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active'); - $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled'); + await hudActor.applyDamage(Number(event.currentTarget.dataset.hpValue)); + _disableHudWraps("hp-loss") }) - // HP Gain Button (new) + // HP Gain Button const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {}) $(html).find('div.left').append(gainHPButton); $(html).find('img.lethal-hp-gain-hud').click((event) => { event.preventDefault(); - let hpMenu = $(html).find('.hp-gain-wrap')[0] - if (hpMenu.classList.contains("hp-gain-hud-disabled")) { - $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled'); - } else { - $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled'); - } + _toggleHudWraps("hp-gain") }) $(html).find('.gain-hp-hud-click').click(async (event) => { event.preventDefault(); - let hpGain = event.currentTarget.dataset.hpValue; - await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP - // Clear bleeding wounds on heal — regardless of heal amount, any - // healing is enough to stop bleeding (field dressing / magic / rest). - const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || []) - const hadBleeding = wounds.some(w => w.description === "Bleeding") - if (hadBleeding) { - await hudActor.update({ - "system.hp.wounds": wounds.map(w => - w.description === "Bleeding" ? { value: 0, duration: 0 } : w - ) - }) - } - $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled'); - $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active'); - $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled'); + await hudActor.applyDamage(Number(event.currentTarget.dataset.hpValue)); + // Clear bleeding wounds on heal + const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || []) + if (wounds.some(w => w.description === "Bleeding")) { + await hudActor.update({ + "system.hp.wounds": wounds.map(w => + w.description === "Bleeding" ? { value: 0, duration: 0 } : w + ) + }) + } + _disableHudWraps("hp-gain") }) // Luck/Grit Buttons diff --git a/templates/chat/reaction-message.hbs b/templates/chat/reaction-message.hbs index c4f8d48..c4a76a2 100644 --- a/templates/chat/reaction-message.hbs +++ b/templates/chat/reaction-message.hbs @@ -21,10 +21,8 @@ adds {{formula}} and rolls {{value}} for {{side}}. {{else if (equals type "d30Bonus")}} gains +{{value}} from D30 bonus die for {{side}}. - {{else if (equals type "d30Auto")}} - uses {{specialName}} from D30 — {{#if (equals side "defense")}}defense automatically succeeds!{{else}}attack automatically hits!{{/if}} {{else if (equals type "d30Flag")}} - D30 — {{specialName}} triggered for {{actorName}}! + D30 — {{specialName}} possible for {{actorName}}! {{else if (equals type "d30Bleed")}} D30 — Bleeding/Internal Injury on hit! Damage past DR will cause a bleeding wound. {{else if (equals type "d30DamageMultiplier")}} diff --git a/templates/dialogs/d30-special-choice.hbs b/templates/dialogs/d30-special-choice.hbs deleted file mode 100644 index 43d5e22..0000000 --- a/templates/dialogs/d30-special-choice.hbs +++ /dev/null @@ -1,4 +0,0 @@ -
D30 result: {{description}}
-{{localize "LETHALFANTASY.D30.chooseEffect"}}
-