Compare commits

...

9 Commits

Author SHA1 Message Date
uberwald 25648aa2a3 refactor: remove D30 choice dialog, extract defense reaction buttons, fix bugs
Release Creation / build (release) Successful in 45s
- Remove D30 choice dialog — auto-roll bonus dice, flag special effects
- Fix d30ChangedAttack infinite loop in defense do-while (missing reset)
- Fix chat button dataset attributes (rollType/rollTarget/rollAvantage)
- Extract buildDefenseReactionButtons from both defense loops
- Merge Aether/Grace deduction via _deductResourceOnCast helper
- Extract HP HUD toggling (_toggleHudWraps/_disableHudWraps)
- Fix SYSTEM.EQUIPMENT_CATEGORIES typo in equipment model
- Add missing imports to combat.mjs
- Remove dead d30Auto branches, _buildSpecialLabel, d30-special-choice.hbs
2026-06-29 11:44:46 +02:00
uberwald 41b1199704 fix: cross-client sync of D30 attack bonus so defense dialog shows
When monster (GM) rolls D30 natural 30 and applies bonus dice
(eg +D20), the boosted attackRollFinal was only applied on GM's
client. Defender (player) saw stale unboosted values — defense
dialog never appeared.

Add d30RequiresSocket flag (GM side): always send attackBoosted
socket when GM-owned attacker has D30 data, because the D30
choice/dice result is only known on GM's client.

Add d30PendingFromGM flag (player side): suppress hook-based
defense dialog and comparison message creation when D30 data
needs GM-side processing. Socket handler (handleAttackBoosted)
shows dialog with correct values.

Track d30ChangedAttack for same-client restart logic.
2026-06-29 07:55:28 +02:00
uberwald 3df46b5848 refactor: extract inline HTML to templates, split oversized files, fix bugs
- Extract all inline HTML from JS into 21 Handlebars templates (chat/, dialogs/, ui/)
- Split utils.mjs (1507) into barrel + helpers.mjs, combat.mjs, d30.mjs
- Split roll.mjs (1632) into barrel + roll-base.mjs, roll-prompt.mjs, roll-combat.mjs, roll-damage.mjs
- Split lethal-fantasy.mjs (1426) into bootstrap + chat-reaction.mjs
- Fix: missing async on injectDiceTray (free-roll.mjs:29 SyntaxError)
- Fix: weapon._id fallback for deserialized chat-message weapon objects
- Fix: missing await on rollModifier.evaluate() calls in roll-combat.mjs
- Fix: choices→choicesList ReferenceError in utils.mjs
- Fix: add 12 missing i18n keys (chooseWeapon, chooseSave, attackRoll, etc.)
- Fix: restore sideLabel in bonus-die-select.hbs
- Clean: remove dead messageContent param, console.log→log()
- Style: barrel files preserve existing import paths
2026-06-28 19:13:05 +02:00
uberwald 05c93f9475 Full reroll management
Release Creation / build (release) Successful in 43s
2026-06-28 08:39:55 +02:00
uberwald bb005ee9fc feat: full reroll includes D30 + shows dice breakdown in chat
- Remove forceNoD30 from rerollConfiguredRoll so mulligan
  rerolls the D30 along with the d20 and modifier
- Reset defenseD30Processed/attackD30Processed after mulligan
  so the new D30's effects (bonus dice, specials) are applied
- Render reroll dice breakdown (diceResults + D30 result) as
  inline HTML in the reaction chat message using existing
  CSS classes so players see what was rolled
2026-06-28 08:04:20 +02:00
uberwald fa5c4cc9ce debug: log favor/disfavor dice totals and results for RNG investigation
9/12 identical d20 results under disfavor suggests RNG issue.
Add structured logging of both roll totals, per-die results,
and baseFormula. Enable debug mode in system settings to see
output in browser console.
2026-06-22 22:33:00 +02:00
uberwald 3b0d4e032e fix: cache results.length before explosion loop to prevent double-count
Release Creation / build (release) Successful in 50s
Pushing explosion dice to DieTerm.results made the for loop
condition (j < results.length) grow mid-iteration, re-processing
the explosion result as a normal die. This produced spurious
entries like `1D6 → 4` alongside the correct `1D6-1 → 3`.

Fixes prompt() (for loop) and rollSpellDamageToMessage()
(for...of) by caching result count / snapshotting the array
before iterating.
2026-06-16 19:34:13 +02:00
uberwald 539841c4ff fix: push explosion dice to DieTerm results so DSN displays them
Explosion rolls were evaluated as separate Roll instances but never
added to the original DieTerm's results array. Dice So Nice reads
DieTerm.results to render 3D dice, so explosions were invisible.

Now each explosion result is pushed into the DieTerm's results array
({result, active:true}), letting DSN render explosion dice in the
correct chronological order alongside the main die.

Applies to prompt(), promptRangedDefense(), promptRangedAttack(),
and rollSpellDamageToMessage().
2026-06-16 19:29:07 +02:00
uberwald ffba37b59e Fix D30 management, again 2026-06-14 23:00:39 +02:00
40 changed files with 4479 additions and 4638 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
token: ${{ secrets.FOUNDRY_PUBLISH_KEY }}
id: "fvtt-lethal-fantasy"
version: ${{github.event.release.tag_name}}
manifest: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{github.event.release.tag_name}}/system.json"
manifest: "https://www.uberwald.me/gitea/uberwald/fvtt-lethal-fantasy/releases/download/latest/system.json"
notes: "https://www.uberwald.me/gitea/public/fvtt-lethal-fantasy/raw/branch/main/changelog.md"
compatibility-minimum: "14"
compatibility-verified: "14"
+60 -87
View File
@@ -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
+13 -1
View File
@@ -1081,7 +1081,19 @@
"spellDRDialogMsg": "Enter a damage reduction value to subtract, or click No DR to apply full damage.",
"spellDRLabel": "DR:",
"spellNoDR": "No DR",
"spellApplyDR": "Apply DR"
"spellApplyDR": "Apply DR",
"chooseWeapon": "Choose a weapon for your reaction",
"chooseSave": "Choose a save type",
"chooseBonusDie": "Select a bonus die",
"attackRoll": "Attack roll",
"currentDefense": "Current defense",
"shieldDice": "Shield dice",
"shieldDR": "Shield DR",
"attacker": "Attacker",
"defender": "Defender"
},
"D30": {
"chooseEffect": "Choose a D30 special effect to apply"
},
"EquipmentCategories": {
"ClassKit": "Class Kit",
+3 -1179
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,3 +1,5 @@
import { SYSTEM } from "../config/system.mjs"
import { log } from "../utils.mjs"
/* -------------------------------------------- */
export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tabs.CombatTracker {
@@ -208,9 +210,10 @@ export class LethalFantasyCombat extends Combat {
c => c.actor?.type === "monster" && !c.isDefeated && c.initiative !== null && this.round >= c.initiative
);
if (eligibleMonsters.length > 0 && this._monsterProgressionRolledRound !== this.round) {
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/combat-monsters-not-rolled.hbs", {message: game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledMsg")})
const proceed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledTitle") },
content: `<p>${game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledMsg")}</p>`,
content,
yes: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedYes") },
no: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo") },
rejectClose: false,
+20 -47
View File
@@ -16,7 +16,7 @@ const DICE_TYPES = ["d4", "d6", "d8", "d10", "d12", "d20", "d30"]
* @param {Application} _chatLog
* @param {HTMLElement|jQuery} html
*/
export function injectDiceTray(_chatLog, html) {
export async function injectDiceTray(_chatLog, html) {
const el = (html instanceof HTMLElement) ? html : (html[0] ?? html)
if (!el?.querySelector) return
if (el.querySelector(".lf-dice-tray")) return
@@ -24,27 +24,14 @@ export function injectDiceTray(_chatLog, html) {
const bar = document.createElement("div")
bar.className = "lf-dice-tray"
const diceButtons = DICE_TYPES.map(d =>
`<button type="button" class="lf-dt-die-btn" data-die="${d}" title="${d.toUpperCase()}">${d.toUpperCase()}</button>`
).join("")
const countOptions = Array.from({ length: 9 }, (_, i) =>
`<option value="${i + 1}">${i + 1}</option>`
).join("")
bar.innerHTML = `
<div class="lf-dt-row">
<span class="lf-dt-label"><i class="fa-solid fa-dice"></i></span>
<select class="lf-dt-count" title="${game.i18n.localize("LETHALFANTASY.DiceTray.CountTitle")}">
${countOptions}
</select>
<div class="lf-dt-dice">${diceButtons}</div>
<label class="lf-dt-explode-label" title="${game.i18n.localize("LETHALFANTASY.DiceTray.ExplodeTitle")}">
<input type="checkbox" class="lf-dt-explode" />
<i class="fa-solid fa-explosion"></i>
</label>
</div>
`
const diceButtons = DICE_TYPES.map(d => ({ value: d, label: d.toUpperCase() }))
const countOptions = Array.from({ length: 9 }, (_, i) => i + 1)
bar.innerHTML = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/ui/dice-tray.hbs", {
countTitle: game.i18n.localize("LETHALFANTASY.DiceTray.CountTitle"),
explodeTitle: game.i18n.localize("LETHALFANTASY.DiceTray.ExplodeTitle"),
countOptions,
diceButtons
})
bar.addEventListener("click", async ev => {
const btn = ev.target.closest(".lf-dt-die-btn")
@@ -113,33 +100,19 @@ export async function rollFreeDie(dieType, count = 1, explode = false) {
}
}
const resultHtml = dieChips.map(chip => {
const isMax = !chip.exploded && chip.value === sides
const isMin = chip.value === 1
const explodeIcon = chip.exploded ? `<i class="fa-solid fa-burst lf-dt-explode-icon"></i>` : ""
const classes = ["lf-frc-die-chip", isMax ? "lf-frc-max" : "", isMin ? "lf-frc-min" : ""].filter(Boolean).join(" ")
return `<div class="${classes}">
<span class="lf-frc-die-type">${chip.label}</span>
<span class="lf-frc-die-sep">→</span>
<span class="lf-frc-die-val">${chip.value}${explodeIcon}</span>
</div>`
}).join("")
const dieChipsWithClasses = dieChips.map(chip => ({
...chip,
classes: ["lf-frc-die-chip", !chip.exploded && chip.value === sides ? "lf-frc-max" : "", chip.value === 1 ? "lf-frc-min" : ""].filter(Boolean).join(" ")
}))
const totalLabel = game.i18n.localize("LETHALFANTASY.Label.total").toUpperCase()
const content = `
<div class="lf-free-roll-card">
<div class="lf-frc-header">
<i class="fa-solid fa-dice"></i>
<span class="lf-frc-title-text">${game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle")}</span>
<span class="lf-frc-badge">${label}</span>
</div>
<div class="lf-frc-dice">${resultHtml}</div>
<div class="lf-frc-total-bar">
<span class="lf-frc-total-label">${totalLabel}</span>
<span class="lf-frc-total-value">${total}</span>
</div>
</div>
`
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/free-roll-card.hbs", {
titleText: game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle"),
badge: label,
dieChips: dieChipsWithClasses,
totalLabel,
total
})
const rollMode = game.settings.get("core", "rollMode")
// Normalize old-style rollMode keys (v12/v13) to new-style (v14), fallback to "public"
@@ -241,17 +241,15 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
const formula = formulaMap[tier]
if (!formula) return
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/spell-dr.hbs", {
msg: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg"),
label: game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")
})
const manualDR = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
classes: ["lethalfantasy"],
position: { width: 320 },
content: `<div style="padding:0.5rem 0">
<p style="margin-bottom:0.6rem">${game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg")}</p>
<div style="display:flex;align-items:center;gap:0.5rem">
<label style="font-weight:bold">${game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")}</label>
<input type="number" name="manualDr" value="0" min="0" style="width:5rem"/>
</div>
</div>`,
content,
buttons: [
{
action: "noDR",
+1 -1
View File
@@ -225,7 +225,7 @@ export default class LethalFantasyActor extends Actor {
damageTier = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose spell tier" },
classes: ["lethalfantasy"],
content: `<p>Select the power level for <strong>${rollTarget.name}</strong>:</p>`,
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/power-level.hbs", {itemName: rollTarget.name}),
buttons,
rejectClose: false,
}) || "standard"
+1 -1
View File
@@ -13,7 +13,7 @@ export default class LethalFantasyChatMessage extends ChatMessage {
messageData.isWhisper = false
messageData.alias = this.user.name
}
data.content = `<section class="dice-rolls">${rollHTML}</section>`
data.content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/dice-rolls.hbs", {rollHTML})
return
}
+284
View File
@@ -0,0 +1,284 @@
import { SYSTEM } from "../config/system.mjs"
import { prompt } from "./roll-prompt.mjs"
import { promptInitiative, promptCombatAction, promptRangedDefense, promptRangedAttack } from "./roll-combat.mjs"
import { rollSpellDamageToMessage } from "./roll-damage.mjs"
export default class LethalFantasyRoll extends Roll {
/**
* The HTML template path used to render dice checks of this type
* @type {string}
*/
static CHAT_TEMPLATE = "systems/fvtt-lethal-fantasy/templates/chat-message.hbs"
get type() {
return this.options.type
}
get titleFormula() {
return this.options.titleFormula
}
get rollName() {
return this.options.rollName
}
get target() {
return this.options.target
}
get value() {
return this.options.value
}
get treshold() {
return this.options.treshold
}
get actorId() {
return this.options.actorId
}
get actorName() {
return this.options.actorName
}
get actorImage() {
return this.options.actorImage
}
get modifier() {
return this.options.modifier
}
get resultType() {
return this.options.resultType
}
get isFailure() {
return this.resultType === "failure"
}
get hasTarget() {
return this.options.hasTarget
}
get targetName() {
return this.options.targetName
}
get targetArmor() {
return this.options.targetArmor
}
get targetMalus() {
return this.options.targetMalus
}
get realDamage() {
return this.options.realDamage
}
get rollTotal() {
return this.options.rollTotal
}
get diceResults() {
return this.options.diceResults
}
get rollTarget() {
return this.options.rollTarget
}
get D30result() {
return this.options.D30result
}
get D30message() {
return this.options.D30message
}
get badResult() {
return this.options.badResult
}
get rollData() {
return this.options.rollData
}
get defenderId() {
return this.options.defenderId
}
/**
* Creates a title based on the given type.
*
* @param {string} type The type of the roll.
* @param {string} target The target of the roll.
* @returns {string} The generated title.
*/
static createTitle(type, target) {
switch (type) {
case "challenge":
return `${game.i18n.localize("LETHALFANTASY.Label.titleChallenge")}`
case "save":
return `${game.i18n.localize("LETHALFANTASY.Label.titleSave")}`
case "monster-skill":
case "skill":
return `${game.i18n.localize("LETHALFANTASY.Label.titleSkill")}`
case "weapon-attack":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-attack")}`
case "weapon-defense":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-defense")}`
case "weapon-damage":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage")}`
case "spell":
case "spell-attack":
case "spell-power":
return `${game.i18n.localize("LETHALFANTASY.Label.spell")}`
case "miracle":
case "miracle-attack":
case "miracle-power":
return `${game.i18n.localize("LETHALFANTASY.Label.miracle")}`
default:
return game.i18n.localize("LETHALFANTASY.Label.titleStandard")
}
}
/** @override */
async render(chatOptions = {}) {
let chatData = await this._getChatCardData(chatOptions.isPrivate)
log("ChatData", chatData)
return await foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, chatData)
}
/*
* Generates the data required for rendering a roll chat card.
*/
async _getChatCardData(isPrivate) {
// Générer la liste des combatants de la scène
let combatants = []
let isAttack = this.type === "weapon-attack" || this.type === "monster-attack" || this.type === "spell-attack" || this.type === "miracle-attack"
if (this.rollData?.isDamage || isAttack) {
// D'abord, ajouter les combattants du combat actif
if (game?.combat?.combatants) {
for (let c of game.combat.combatants) {
if (c.actorId !== this.actorId) {
combatants.push({ id: c.id, name: c.name, tokenId: c.token.id })
}
}
}
// Ensuite, ajouter tous les tokens de la scène active qui ne sont pas déjà dans la liste
if (canvas?.scene?.tokens) {
const existingTokenIds = new Set(combatants.map(c => c.tokenId))
for (let token of canvas.scene.tokens) {
if (token.actorId !== this.actorId && !existingTokenIds.has(token.id)) {
combatants.push({
id: token.id,
name: token.name,
tokenId: token.id
})
}
}
}
}
// Récupérer les informations de l'arme pour les attaques réussies
let weaponDamageOptions = null
log("Roll type:", this.type, "rollTarget:", this.rollTarget, "Has weapon:", !!this.rollTarget?.weapon)
if (this.type === "weapon-attack" && this.rollTarget?.weapon) {
const weapon = this.rollTarget.weapon
weaponDamageOptions = {
weaponId: weapon._id || weapon.id,
weaponName: weapon.name,
damageM: weapon.system?.damage?.damageM
}
log("Weapon damage options:", weaponDamageOptions)
} else if (this.type === "monster-attack" && this.rollTarget) {
weaponDamageOptions = {
weaponId: this.rollTarget.rollKey,
weaponName: this.rollTarget.name,
damageFormula: this.rollTarget.damageDice,
damageModifier: this.rollTarget.damageModifier,
isMonster: true
}
log("Monster damage options:", weaponDamageOptions)
}
const cardData = {
css: [SYSTEM.id, "dice-roll"],
data: this.data,
diceTotal: this.dice.reduce((t, d) => t + d.total, 0),
isGM: game.user.isGM,
formula: this.formula,
titleFormula: this.titleFormula,
rollName: this.rollName,
rollType: this.type,
rollTarget: this.rollTarget,
total: this.rollTotal,
isFailure: this.isFailure,
actorId: this.actorId,
diceResults: this.diceResults,
actingCharName: this.actorName,
actingCharImg: this.actorImage,
resultType: this.resultType,
hasTarget: this.hasTarget,
targetName: this.targetName,
targetArmor: this.targetArmor,
D30result: this.D30result,
D30message: this.D30message,
badResult: this.badResult,
rollData: this.rollData,
isPrivate: isPrivate,
combatants: combatants,
weaponDamageOptions: weaponDamageOptions,
isAttack: isAttack,
defenderId: this.defenderId,
// Vérifier si l'utilisateur peut sélectionner une cible (est GM ou possède l'acteur)
canSelectTarget: game.user.isGM || game.actors.get(this.actorId)?.testUserPermission(game.user, "OWNER")
}
cardData.cssClass = cardData.css.join(" ")
cardData.tooltip = isPrivate ? "" : await this.getTooltip()
return cardData
}
/**
* Converts the roll result to a chat message.
*
* @param {Object} [messageData={}] Additional data to include in the message.
* @param {Object} options Options for message creation.
* @param {string} options.messageMode The mode of the roll (e.g., public, private).
* @param {boolean} [options.create=true] Whether to create the message.
* @returns {Promise} - A promise that resolves when the message is created.
*/
async toMessage(messageData = {}, { messageMode, create = true } = {}) {
return await super.toMessage(
{
isSave: this.isSave,
isChallenge: this.isChallenge,
isFailure: this.resultType === "failure",
rollType: this.type,
rollTarget: this.rollTarget,
actingCharName: this.actorName,
actingCharImg: this.actorImage,
hasTarget: this.hasTarget,
targetName: this.targetName,
targetArmor: this.targetArmor,
targetMalus: this.targetMalus,
realDamage: this.realDamage,
rollData: this.rollData,
...messageData,
},
{ messageMode, create },
)
}
}
// Attach imported prompt methods
LethalFantasyRoll.prompt = prompt
LethalFantasyRoll.promptInitiative = promptInitiative
LethalFantasyRoll.promptCombatAction = promptCombatAction
LethalFantasyRoll.promptRangedDefense = promptRangedDefense
LethalFantasyRoll.promptRangedAttack = promptRangedAttack
LethalFantasyRoll.rollSpellDamageToMessage = rollSpellDamageToMessage
+714
View File
@@ -0,0 +1,714 @@
import { SYSTEM } from "../config/system.mjs"
import D30Roll from "./d30-roll.mjs"
import LethalFantasyUtils from "../utils.mjs"
/* ***********************************************************/
export async function promptInitiative(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes); // v12 : Object.fromEntries(Object.entries(CONFIG.Dice.rollModes).map(([key, value]) => [key, game.i18n.localize(value)]))
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
if (SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS[options.actorClass]) {
options.initiativeDiceChoice = SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS[options.actorClass]
} else {
options.initiativeDiceChoice = SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS["untrained"]
}
let dialogContext = {
actorClass: options.actorClass,
initiativeDiceChoice: options.initiativeDiceChoice,
initiativeDice: "1D20",
maxInit: options.maxInit,
fieldRollMode,
rollModes
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/roll-initiative-dialog.hbs", dialogContext)
const label = game.i18n.localize("LETHALFANTASY.Label.initiative")
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Initiative Roll" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "initiative",
type: "button",
label: label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
rejectClose: false // Click on Close button will not launch an error
})
if (!rollContext) return
// When the value is a plain number (e.g. "1" for Declared Ready on Alert), wrapping it in
// min(1, maxInit) produces a dice-less formula that FoundryVTT cannot evaluate to a valid
// total. Use the constant directly; min() is only needed for actual dice expressions.
const isDiceFormula = /[dD]/.test(rollContext.initiativeDice)
const formula = isDiceFormula ? `min(${rollContext.initiativeDice}, ${options.maxInit})` : rollContext.initiativeDice
let initRoll = new Roll(formula, options.data)
await initRoll.evaluate()
let msg = await initRoll.toMessage({ flavor: `Initiative for ${options.actorName}` }, { messageMode: rollContext.visibility })
if (game?.dice3d && initRoll.dice?.length) {
await game.dice3d.waitFor3DAnimationByMessageID(msg.id)
}
if (options.combatId && options.combatantId) {
let combat = game.combats.get(options.combatId)
await combat.updateEmbeddedDocuments("Combatant", [{ _id: options.combatantId, initiative: initRoll.total, 'system.progressionCount': 0, [`flags.${SYSTEM.id}.firstActionTaken`]: false }])
}
}
/* ***********************************************************/
export async function promptCombatAction(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes); // v12 : Object.fromEntries(Object.entries(CONFIG.Dice.rollModes).map(([key, value]) => [key, game.i18n.localize(value)]))
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
let combatant = game.combats.get(options.combatId)?.combatants?.get(options.combatantId)
if (!combatant) {
console.error("No combatant found for this combat")
return
}
let currentAction = combatant.getFlag(SYSTEM.id, "currentAction")
let position = game.user.getFlag(SYSTEM.id, "combat-action-dialog-pos") || { top: -1, left: -1 }
let dialogContext = {
progressionDiceId: "",
fieldRollMode,
rollModes,
currentAction,
...options
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/combat-action-dialog.hbs", dialogContext)
let buttons = []
if (currentAction) {
if (currentAction.type === "weapon") {
let weaponLabel = "Roll progression dice"
if (currentAction.rangedMode) {
// Compute loading count from the speed formula (e.g. "3+1d6" → load=3)
const speedStr = currentAction.system?.speed?.[currentAction.rangedMode] ?? ""
const rangedLoad = currentAction.rangedLoad ?? (Number(speedStr.split("+")[0]) || 0)
if (rangedLoad > 0 && !currentAction.weaponLoaded) {
weaponLabel = "Load weapon"
}
}
buttons.push({
action: "roll",
type: "button",
label: weaponLabel,
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
return "rollProgressionDice"
},
})
} else if (currentAction.type === "spell" || currentAction.type === "miracle") {
let label = ""
if (currentAction.spellStatus === "castingTime") {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
label = "Wait casting time"
}
if (currentAction.spellStatus === "toBeCasted") {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
label = "Cast spell/miracle"
}
if (currentAction.spellStatus === "lethargy") {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
label = "Roll lethargy dice"
}
buttons.push({
action: "roll",
type: "button",
label: label,
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
return "rollLethargyDice"
},
})
}
} else {
buttons.push({
action: "roll",
type: "button",
label: "Select action",
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
)
}
buttons.push({
action: "cancel",
type: "button",
label: "Other action, not listed here",
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
return null;
}
})
let rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Combat Action Dialog" },
id: "combat-action-dialog",
classes: ["lethalfantasy"],
position,
content,
buttons,
rejectClose: false // Click on Close button will not launch an error
})
log("RollContext", dialogContext, rollContext)
// If action is cancelled, exit
if (rollContext === null || rollContext === "cancel") {
await combatant.setFlag(SYSTEM.id, "currentAction", "")
let message = `${combatant.name} : Other action, progression reset`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
return
}
// Setup the current action
if (!currentAction || currentAction === "") {
// Get the item from the returned selectedChoice value
let selectedChoice = rollContext.selectedChoice
let rangedMode
if (selectedChoice.match("simpleAim")) {
selectedChoice = selectedChoice.replace("simpleAim", "")
rangedMode = "simpleAim"
}
if (selectedChoice.match("carefulAim")) {
selectedChoice = selectedChoice.replace("carefulAim", "")
rangedMode = "carefulAim"
}
if (selectedChoice.match("focusedAim")) {
selectedChoice = selectedChoice.replace("focusedAim", "")
rangedMode = "focusedAim"
}
let selectedItem = combatant.actor.items.find(i => i.id === selectedChoice)
// Setup flag for combat action usage
let actionItem = foundry.utils.duplicate(selectedItem)
// First action of this combat: use the class-based starting threshold;
// all subsequent actions reset to 1 (normal progression).
const firstActionTaken = combatant.getFlag(SYSTEM.id, "firstActionTaken")
actionItem.progressionCount = firstActionTaken ? 1 : (combatant.actor.system.combat?.combatProgressionStart ?? 1)
if (!firstActionTaken) await combatant.setFlag(SYSTEM.id, "firstActionTaken", true)
actionItem.rangedMode = rangedMode
// If this is a spell/miracle with multiple damage tiers, prompt tier choice
if (actionItem.system?.damageDice) {
const tiers = [
{ id: "standard", label: "Standard", dice: actionItem.system.damageDice },
{ id: "overpowered", label: "Overpowered", dice: actionItem.system.damageDiceOverpowered },
{ id: "overpowered2", label: "Overpowered 2", dice: actionItem.system.damageDiceOverpowered2 },
].filter(t => t.dice)
if (tiers.length > 1) {
const tierChoice = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose Damage Tier" },
classes: ["lethalfantasy"],
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/damage-tier.hbs", {itemName: selectedItem.name}),
buttons: tiers.map(t => ({
action: t.id,
type: "button",
label: `${t.label} (${t.dice.toUpperCase()})`,
icon: "fa-solid fa-wand-magic-sparkles",
callback: () => t.id
})),
rejectClose: false
})
actionItem.damageTier = tierChoice || "standard"
}
}
actionItem.castingTime = 1
actionItem.spellStatus = "castingTime"
// Set the flag on the combatant
await combatant.setFlag(SYSTEM.id, "currentAction", actionItem)
let message = `${combatant.name} action : ${selectedItem.name}, start rolling progression dice or casting time`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
rollContext = (actionItem.type === "weapon") ? "rollProgressionDice" : "rollLethargyDice" // Set the roll context to rollProgressionDice
currentAction = actionItem
}
if (currentAction) {
if (rollContext === "rollLethargyDice") {
if (currentAction.spellStatus === "castingTime") {
let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime
if (currentAction.castingTime < time) {
let message = `Casting time : ${currentAction.name}, count : ${currentAction.castingTime}/${time}`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.castingTime += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
return
} else {
// Last counting second — announce ready and transition immediately (no extra second consumed)
let message = `Casting time : ${currentAction.name}, count : ${time}/${time} — ready to cast next second !`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.castingTime = 1
currentAction.spellStatus = "toBeCasted"
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
return
}
}
if (currentAction.spellStatus === "toBeCasted") {
combatant.actor.prepareRoll((currentAction.type === "spell") ? "spell-attack" : "miracle-attack", currentAction._id)
if (currentAction.type === "spell") {
currentAction.spellStatus = "lethargy"
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
} else {
// No lethargy for miracle
await combatant.setFlag(SYSTEM.id, "currentAction", "")
}
return
}
if (currentAction.spellStatus === "lethargy") {
// Roll lethargy dice
let dice = LethalFantasyUtils.getLethargyDice(currentAction.system.level)
let roll = new Roll(dice)
await roll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(roll)
}
let max = roll.dice[0].faces - 1
let toCompare = Math.min(currentAction.progressionCount, max)
if (roll.total <= toCompare) {
// Notify that the player can act now with a chat message
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{
success: true,
actorName: combatant.actor.name,
weaponName: currentAction.name,
rollResult: roll.total,
isLethargy: true
}
)
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
// Update the combatant progression count
await combatant.setFlag(SYSTEM.id, "currentAction", "")
// Display the action selection window again
combatant.actor.system.rollProgressionDice(options.combatId, options.combatantId)
} else {
// Notify that the player cannot act now with a chat message
currentAction.progressionCount += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{
success: false,
actorName: combatant.actor.name,
weaponName: currentAction.name,
rollResult: roll.total,
progressionCount: currentAction.progressionCount,
isLethargy: true
}
)
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
}
}
}
if (rollContext === "rollProgressionDice") {
let formula = currentAction.system.combatProgressionDice
if (currentAction?.rangedMode) {
let toSplit = currentAction.system.speed[currentAction.rangedMode]
let split = toSplit.split("+")
currentAction.rangedLoad = Number(split[0]) || 0
formula = split[1]
log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula)
}
// Range weapon loading
if (!currentAction.weaponLoaded && currentAction.rangedLoad) {
if (currentAction.progressionCount < currentAction.rangedLoad) {
let message = `Ranged weapon ${currentAction.name} is loading, loading count : ${currentAction.progressionCount}/${currentAction.rangedLoad}`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.progressionCount += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
} else {
let message = `Ranged weapon ${currentAction.name} is loaded !`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.weaponLoaded = true
currentAction.progressionCount = 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
}
return
}
// Melee mode
let isMonster = combatant.actor.type === "monster"
// Get the dice and roll it if
let roll = new Roll(formula)
await roll.evaluate()
let max = roll.dice[0].faces - 1
max = Math.min(currentAction.progressionCount, max)
let msg = await roll.toMessage({ flavor: `Progression Roll for ${currentAction.name}, progression count : ${currentAction.progressionCount}/${max}` }, { messageMode: rollContext.visibility })
if (game?.dice3d) {
await game.dice3d.waitFor3DAnimationByMessageID(msg.id)
}
if (roll.total <= max) {
// Notify that the player can act now with a chat message
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{
success: true,
actorName: combatant.actor.name,
weaponName: currentAction.name,
rollResult: roll.total
}
)
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await combatant.setFlag(SYSTEM.id, "currentAction", "")
combatant.actor.prepareRoll(currentAction.type === "weapon" ? "weapon-attack" : "spell-attack", currentAction._id)
} else {
// Notify that the player cannot act now with a chat message
currentAction.progressionCount += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{
success: false,
actorName: combatant.actor.name,
weaponName: currentAction.name,
rollResult: roll.total,
progressionCount: currentAction.progressionCount
}
)
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
}
}
}
}
/* ***********************************************************/
export async function promptRangedDefense(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes);
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
let dialogContext = {
movementChoices: SYSTEM.MOVEMENT_CHOICES,
moveDirectionChoices: SYSTEM.MOVE_DIRECTION_CHOICES,
sizeChoices: SYSTEM.SIZE_CHOICES,
rangeChoices: SYSTEM.RANGE_CHOICES,
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
movement: "none",
moveDirection: "none",
size: "+5",
range: "short",
attackerAim: "simple",
fieldRollMode,
rollModes
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-defense-dialog.hbs", dialogContext)
const label = game.i18n.localize("LETHALFANTASY.Label.rangeDefenseRoll")
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Range Defense" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "rangeDefense",
type: "button",
label: label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
rejectClose: false // Click on Close button will not launch an error
})
// If the user cancels the dialog, exit
if (rollContext === null) return
log("RollContext", rollContext)
// Add disfavor/favor option if point blank range
if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kl" // Add the kl to the movement (disfavor for point blank range)
rollContext.range = "0"
}
if (rollContext.range === "beyondskill") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kh" // Add the kl to the movement (favor for point blank range)
rollContext.range = "+11"
}
// Build the final modifier
let fullModifier = Number(rollContext.moveDirection) +
Number(rollContext.size) +
Number(rollContext.range) +
Number(rollContext?.attackerAim || 0)
let modifierFormula
if (fullModifier === 0) {
modifierFormula = "0"
} else {
let modAbs = Math.abs(fullModifier)
modifierFormula = `D${modAbs + 1} -1`
}
let rollData = { ...rollContext }
// Merge rollContext object into options object
options = { ...options, ...rollContext }
options.rollName = "Ranged Defense"
options.rollType = "weapon-defense"
options.type = options.rollType // Required: this.type reads options.type
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
await rollModifier.evaluate()
await rollBase.evaluate()
let rollD30 = await new Roll("1D30").evaluate()
options.D30result = rollD30.total
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, options.rollTarget?.weapon, { isRanged: true })
let badResult = 0
if (rollContext.movement.includes("kh")) {
rollData.favor = "favor"
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
}
if (rollContext.movement.includes("kl")) {
rollData.favor = "disfavor"
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
}
let dice = rollContext.movement
let maxValue = 20 // As per latest changes (was : Number(dice.match(/\d+$/)[0])
let rollTotal = -1
let diceResults = []
let resultType
let diceResult = rollBase.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
let diceSum = diceResult
while (diceResult === maxValue) {
let r = await new Roll(dice).evaluate()
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
}
if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
if (fullModifier < 0) {
rollTotal = Math.max(diceSum - rollModifier.total, 0)
} else {
rollTotal = diceSum + rollModifier.total
}
} else {
rollTotal = diceSum
}
rollBase.options = { ...rollBase.options, ...options }
rollBase.options.resultType = resultType
rollBase.options.rollTotal = rollTotal
rollBase.options.diceResults = diceResults
rollBase.options.rollTarget = options.rollTarget
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
rollBase.options.D30result = options.D30result
rollBase.options.D30message = options.D30message
rollBase.options.rollName = "Ranged Defense"
rollBase.options.badResult = badResult
rollBase.options.rollData = foundry.utils.duplicate(rollData)
return rollBase
}
/**
* Prompts the GM for ranged attack context (movement, range, target size, aim) when a monster
* attacks with a ranged weapon, then evaluates an exploding D20 attack roll with the resulting modifiers.
*
* @param {Object} options Options for the roll.
* @param {string} options.actorId The attacker actor ID.
* @param {string} options.actorName The attacker actor name.
* @param {Object} options.rollTarget The rollTarget containing attackModifier and related data.
* @returns {Promise<LethalFantasyRoll|null>} The resulting roll, or null if cancelled.
*/
export async function promptRangedAttack(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes)
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
let dialogContext = {
attackerMovementChoices: SYSTEM.ATTACKER_MOVEMENT_CHOICES,
rangeChoices: SYSTEM.RANGE_CHOICES,
sizeChoices: SYSTEM.SIZE_CHOICES,
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
movement: "none",
range: "short",
size: "+5",
attackerAim: "simple",
fieldRollMode,
rollModes
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-attack-dialog.hbs", dialogContext)
const label = game.i18n.localize("LETHALFANTASY.Label.rangeAttackRoll")
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Ranged Attack" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "rangedAttack",
type: "button",
label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
rejectClose: false
})
if (rollContext === null) return null
// Handle pointblank: attacker at point blank gets favor (standing still easier to aim)
if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kh" // Favor for attacker at point blank
rollContext.range = "0"
}
// Handle beyondskill: extreme range gives disfavor to attacker
if (rollContext.range === "beyondskill") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kl" // Disfavor for attacker beyond skill range
rollContext.range = "+11"
}
// Compute contextual penalty: range + target_size, reduced by aim bonus and attack modifier
const attackModifier = options.rollTarget?.attackModifier ?? 0
const contextualPenalty = Number(rollContext.range) + Number(rollContext.size)
const aimBonus = Number(rollContext.attackerAim || 0)
const fullModifier = contextualPenalty - aimBonus - attackModifier
let modifierFormula
if (fullModifier === 0) {
modifierFormula = "0"
} else {
const modAbs = Math.abs(fullModifier)
modifierFormula = `D${modAbs + 1} -1`
}
const rollData = { ...rollContext }
options = { ...options, ...rollContext }
options.rollName = "Ranged Attack"
options.rollType = options.rollType || "monster-attack"
options.type = options.rollType // Required: this.type reads options.type, used to build weaponDamageOptions in toHTML
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
options.isRangedAttack = true
const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
await rollModifier.evaluate()
await rollBase.evaluate()
const rollD30 = await new Roll("1D30").evaluate()
options.D30result = rollD30.total
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, undefined, { isRanged: true })
// Determine favor from dice formula
let badResult = 0
if (rollContext.movement.includes("kh")) {
rollData.favor = "favor"
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
}
if (rollContext.movement.includes("kl")) {
rollData.favor = "disfavor"
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
}
const dice = rollContext.movement
const maxValue = 20
let rollTotal = -1
let diceResults = []
let diceResult = rollBase.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
let diceSum = diceResult
// Exploding dice
while (diceResult === maxValue) {
const r = await new Roll(dice).evaluate()
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
}
if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
if (fullModifier > 0) {
// Net penalty: subtract from roll
rollTotal = Math.max(diceSum - rollModifier.total, 0)
} else {
// Net bonus: add to roll
rollTotal = diceSum + rollModifier.total
}
} else {
rollTotal = diceSum
}
rollBase.options = { ...rollBase.options, ...options }
rollBase.options.resultType = undefined
rollBase.options.rollTotal = rollTotal
rollBase.options.diceResults = diceResults
rollBase.options.rollTarget = options.rollTarget
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
rollBase.options.D30result = options.D30result
rollBase.options.D30message = options.D30message
rollBase.options.rollName = "Ranged Attack"
rollBase.options.badResult = badResult
rollBase.options.rollData = foundry.utils.duplicate(rollData)
return rollBase
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Evaluate a spell/miracle damage formula with per-die explosion, then post to chat.
* Explosion dice are shown manually via showForRoll; the main roll is shown automatically
* by toMessage() (which triggers Dice So Nice via its createChatMessage hook).
* Append "NE" to the formula to disable explosion.
*
* @param {string} formula Dice formula, e.g. "1d8", "2d6", "1d8NE"
* @param {Object} rollOpts Options for LethalFantasyRoll (rollType, actorId, defenderId, etc.)
* @returns {Promise<ChatMessage>}
*/
export async function rollSpellDamageToMessage(formula, rollOpts) {
const roll = new this(formula, {}, rollOpts)
await roll.evaluate()
const shouldExplode = !/NE$/i.test(formula)
const diceResults = []
let diceSum = 0
for (const term of roll.dice) {
const singleDice = `1D${term.faces}`
const termResults = Array.from(term.results)
for (const r of termResults) {
let diceResult = r.result
diceResults.push({ dice: singleDice.toUpperCase(), value: diceResult })
diceSum += diceResult
if (shouldExplode && term.faces > 0) {
while (diceResult === term.faces) {
const xr = await new Roll(singleDice).evaluate()
// Optional chaining guards against unexpected roll structure
diceResult = xr.dice?.[0]?.results?.[0]?.result ?? (term.faces - 1)
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
term.results.push({ result: diceResult, active: true })
}
}
}
}
roll.options.diceResults = diceResults
roll.options.rollTotal = diceSum
return roll.toMessage()
}
+598
View File
@@ -0,0 +1,598 @@
import { SYSTEM } from "../config/system.mjs"
import D30Roll from "./d30-roll.mjs"
/**
* Prompt the user with a dialog to configure and execute a roll.
*
* @param {Object} options Configuration options for the roll.
* @param {string} options.rollType The type of roll being performed (e.g., RESOURCE, DAMAGE, ATTACK, SAVE).
* @param {string} options.rollValue The initial value or formula for the roll.
* @param {string} options.rollTarget The target of the roll.
* @param {"="|"+"|"++"|"-"|"--"} options.rollAdvantage If there is an avantage (+), a disadvantage (-), a double advantage (++), a double disadvantage (--) or a normal roll (=).
* @param {string} options.actorId The ID of the actor performing the roll.
* @param {string} options.actorName The name of the actor performing the roll.
* @param {string} options.actorImage The image of the actor performing the roll.
* @param {boolean} options.hasTarget Whether the roll has a target.
* @param {Object} options.target The target of the roll, if any.
* @param {Object} options.data Additional data for the roll.
*
* @returns {Promise<Object|null>} The roll result or null if the dialog was cancelled.
*/
export async function prompt(options = {}) {
try {
let dice = "1D20"
let maxValue = 20
let baseFormula = "1D20"
let modifierFormula = "1D0"
let hasModifier = true
let hasChangeDice = false
let hasD30 = false
let hasFavor = false
let hasMaxValue = true
let hasGrantedDice = false
let pointBlank = false
let letItFly = false
let saveSpell = game.lethalFantasy?.spellDefense ?? false
let beyondSkill = false
let hasStaticModifier = false
let hasExplode = true
let actor = game.actors.get(options.actorId)
if (options.rollType === "challenge" || options.rollType === "save") {
options.rollName = game.i18n.localize(`LETHALFANTASY.Label.${options.rollTarget.rollKey}`)
hasD30 = options.rollType === "save"
if (options.rollTarget.rollKey === "dying") {
dice = options.rollTarget.value
hasModifier = false
hasChangeDice = true
hasFavor = true
} else {
dice = "1D20"
hasFavor = true
}
} else if (options.rollType === "granted") {
hasD30 = false
options.rollName = `Granted ${options.rollTarget.rollKey}`
dice = options.rollTarget.formula
baseFormula = options.rollTarget.formula
hasModifier = false
hasMaxValue = false
hasChangeDice = false
hasFavor = false
} else if (options.rollType === "monster-attack" || options.rollType === "monster-defense") {
hasD30 = true
options.rollName = options.rollTarget.name
dice = "1D20"
baseFormula = "D20"
hasModifier = true
hasChangeDice = false
hasFavor = true
if (options.rollType === "monster-attack") {
options.rollTarget.value = options.rollTarget.attackModifier
options.rollTarget.charModifier = 0
} else {
options.rollTarget.value = options.rollTarget.defenseModifier
options.rollTarget.charModifier = 0
options.isRangedDefense = options.rollTarget.isRangedDefense ?? false
}
} else if (options.rollType === "monster-skill") {
options.rollName = game.i18n.localize(`LETHALFANTASY.Label.${options.rollTarget.rollKey}`)
dice = "1D20"
baseFormula = "D20"
hasModifier = true
hasFavor = true
hasChangeDice = false
} else if (options.rollType === "skill") {
options.rollName = options.rollTarget.name
hasD30 = true
dice = "1D20"
baseFormula = "D20"
hasModifier = true
hasFavor = true
hasChangeDice = false
options.rollTarget.value = Math.floor(options.rollTarget.system.skillTotal / 10)
} else if (options.rollType === "weapon-attack" || options.rollType === "weapon-defense") {
hasD30 = true
options.rollName = options.rollTarget.name
dice = "1D20"
baseFormula = "D20"
hasModifier = true
hasChangeDice = false
hasFavor = true
if (options.rollType === "weapon-attack") {
if (options.rollTarget.weapon.system.weaponType === "melee") {
options.rollTarget.value = options.rollTarget.combat.attackModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.attackBonus
options.rollTarget.charModifier = options.rollTarget.combat.attackModifier
} else {
options.rollTarget.value = options.rollTarget.combat.rangedAttackModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.attackBonus
options.rollTarget.charModifier = options.rollTarget.combat.rangedAttackModifier
}
} else {
// For defense, check if it's a ranged defense
const defenseModifier = options.rollTarget.isRangedDefense
? options.rollTarget.combat.rangedDefenseModifier
: options.rollTarget.combat.defenseModifier
options.rollTarget.value = defenseModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.defenseBonus + options.rollTarget.armorDefense
options.rollTarget.charModifier = defenseModifier
// Store isRanged flag for D30 lookup
options.isRangedDefense = options.rollTarget.isRangedDefense
}
} else if (options.rollType === "spell" || options.rollType === "spell-attack" || options.rollType === "spell-power") {
hasD30 = true
options.rollName = options.rollTarget.name
dice = "1D20"
baseFormula = "D20"
hasModifier = true
hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelSpellModifier + options.rollTarget.actorModifiers.intSpellModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.intSpellModifier
hasStaticModifier = options.rollType === "spell-power"
//hasModifier = options.rollType !== "spell-attack"
if (hasStaticModifier) {
options.rollTarget.staticModifier = options.rollTarget.actorLevel
} else {
options.rollTarget.staticModifier = 0
}
} else if (options.rollType === "miracle" || options.rollType === "miracle-attack" || options.rollType === "miracle-power") {
hasD30 = true
options.rollName = options.rollTarget.name
dice = "1D20"
baseFormula = "D20"
hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.chaMiracleModifier
hasStaticModifier = options.rollType === "miracle-power"
//hasModifier = options.rollType !== "miracle-attack"
if (hasStaticModifier) {
options.rollTarget.staticModifier = options.rollTarget.actorLevel
} else {
options.rollTarget.staticModifier = 0
}
} else if (options.rollType === "shield-roll") {
hasD30 = false
options.rollName = "Shield Defense"
dice = options.rollTarget.system.defense.toUpperCase()
baseFormula = dice
hasModifier = true
hasChangeDice = false
hasMaxValue = false
hasExplode = false
hasFavor = true
options.rollTarget.value = 0
} else if (options.rollType.includes("weapon-damage")) {
options.rollName = options.rollTarget.name
options.isDamage = true
hasModifier = true
hasChangeDice = false
let damageBonus = (options.rollTarget.weapon.system.applyStrengthDamageBonus) ? options.rollTarget.combat.damageModifier : 0
options.rollTarget.value = damageBonus + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.damageBonus
options.rollTarget.charModifier = damageBonus
dice = options.rollTarget.weapon.system.damage.damageM
if (/NE$/i.test(dice)) {
hasMaxValue = false
hasExplode = false
}
dice = dice.replace(/NE$/i, "").replace("E", "")
baseFormula = dice
} else if (options.rollType.includes("monster-damage")) {
options.rollName = options.rollTarget.name
options.isDamage = true
hasModifier = true
hasChangeDice = false
options.rollTarget.value = options.rollTarget.damageModifier
options.rollTarget.charModifier = 0
dice = options.rollTarget.damageDice
dice = dice.replace("E", "")
baseFormula = dice
if (options.rollTarget.noExplode) {
hasMaxValue = false
hasExplode = false
}
}
if (options.rollType === "save" && (options.rollTarget.rollKey === "pain" || options.rollTarget.rollKey === "paincourage")) {
dice = options.rollTarget.rollDice
baseFormula = options.rollTarget.rollDice
hasModifier = false
}
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes);
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
const choiceModifier = SYSTEM.CHOICE_MODIFIERS
const choiceDice = SYSTEM.CHOICE_DICE
const choiceFavor = SYSTEM.FAVOR_CHOICES
let modifier = "+0"
let targetName
// True for any ranged attack: PC weapon (ranged type) or monster attack (ranged mode)
const isRangedAttack = (options.rollType === "weapon-attack" && options.rollTarget?.weapon?.system?.weaponType === "ranged")
|| (options.rollType === "monster-attack" && options.rollTarget?.attackMode === "ranged")
let dialogContext = {
rollType: options.rollType,
rollTarget: options.rollTarget,
rollName: options.rollName,
actorName: options.actorName,
rollModes,
hasModifier,
hasFavor,
hasChangeDice,
pointBlank,
baseValue: options.rollTarget.value,
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
attackerAim: "0",
changeDice: `${dice}`,
fieldRollMode,
choiceModifier,
choiceDice,
choiceFavor,
baseFormula,
dice,
hasTarget: options.hasTarget,
modifier,
saveSpell,
favor: "none",
targetName,
isRangedAttack
}
let rollContext
if (options.rollContext) {
rollContext = foundry.utils.duplicate(options.rollContext)
hasGrantedDice = !!rollContext.hasGrantedDice
pointBlank = !!rollContext.pointBlank
beyondSkill = !!rollContext.beyondSkill
letItFly = !!rollContext.letItFly
saveSpell = !!rollContext.saveSpell
const _rawMode = rollContext.rollMode || game.settings.get("core", "rollMode")
const _modeMap = { publicroll: "public", gmroll: "gm", blindroll: "blind", selfroll: "self" }
rollContext.visibility ||= _modeMap[_rawMode] ?? _rawMode ?? "public"
rollContext.modifier ||= modifier
rollContext.favor ||= "none"
rollContext.changeDice ||= `${dice}`
rollContext.attackerAim ||= "0"
} else {
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/roll-dialog.hbs", dialogContext)
let position = game.user.getFlag(SYSTEM.id, "roll-dialog-pos") || { top: -1, left: -1 }
const label = game.i18n.localize("LETHALFANTASY.Roll.roll")
rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Roll dialog" },
classes: ["lethalfantasy"],
content,
position,
buttons: [
{
action: "roll",
type: "button",
label: label,
callback: (event, button, dialog) => {
log("Roll context", event, button, dialog)
let position = dialog?.position
game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(position))
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
actions: {
"selectGranted": (event, button) => {
hasGrantedDice = event.target.checked
},
"selectBeyondSkill": (event, button) => {
beyondSkill = button.checked
},
"selectPointBlank": (event, button) => {
pointBlank = button.checked
},
"selectLetItFly": (event, button) => {
letItFly = button.checked
},
"saveSpellCheck": (event, button) => {
saveSpell = button.checked
},
"gotoToken": (event, button) => {
let tokenId = $(button).data("tokenId")
let token = canvas.tokens?.get(tokenId)
if (token) {
canvas.animatePan({ x: token.x, y: token.y, duration: 200 })
canvas.tokens.releaseAll()
token.control({ releaseOthers: true })
}
}
},
rejectClose: false // Click on Close button will not launch an error
})
}
// If the user cancels the dialog, exit
if (rollContext === null) return
log("rollContext", rollContext, hasGrantedDice)
rollContext.saveSpell = saveSpell // Update fucking flag
let fullModifier = 0
let titleFormula = ""
dice = rollContext.changeDice || dice
if (hasModifier) {
let bonus = Number(options.rollTarget.value)
fullModifier = rollContext.modifier === "" ? 0 : parseInt(rollContext.modifier, 10) + bonus
fullModifier += (rollContext.saveSpell) ? (options.rollTarget.actorModifiers?.saveModifier ?? 0) : 0
if (Number(rollContext.attackerAim) > 0) {
fullModifier += Number(rollContext.attackerAim)
}
if (fullModifier === 0) {
modifierFormula = "0"
} else {
let modAbs = Math.abs(fullModifier)
modifierFormula = `D${modAbs + 1} - 1`
}
if (hasStaticModifier) {
modifierFormula += ` + ${options.rollTarget.staticModifier}`
}
let sign = fullModifier < 0 ? "-" : "+"
if (hasExplode) {
titleFormula = `${dice}E ${sign} ${modifierFormula}`
} else {
titleFormula = `${dice} ${sign} ${modifierFormula}`
}
} else {
modifierFormula = "0"
fullModifier = 0
baseFormula = `${dice}`
if (hasExplode) {
titleFormula = `${dice}E`
} else {
titleFormula = `${dice}`
}
}
// Latest addition : favor choice at point blank range
if (pointBlank) {
rollContext.favor = "favor"
}
if (beyondSkill) {
rollContext.favor = "disfavor"
}
// Specific pain case
if (options.rollType === "save" && options.rollTarget.rollKey === "pain" || options.rollTarget.rollKey === "paincourage") {
baseFormula = options.rollTarget.rollDice
titleFormula = `${dice}`
modifierFormula = "0"
fullModifier = 0
}
// Specific pain/poison/contagion case
if (options.rollType === "save" && (options.rollTarget.rollKey === "poison" || options.rollTarget.rollKey === "contagion")) {
hasD30 = false
hasStaticModifier = true
modifierFormula = ` + ${Math.abs(fullModifier)}`
titleFormula = `${dice}E + ${Math.abs(fullModifier)}`
}
if (letItFly) {
baseFormula = "1D20"
titleFormula = `1D20E`
modifierFormula = "0"
fullModifier = 0
hasFavor = false
hasExplode = true
rollContext.favor = "none"
}
const maxMatch = baseFormula ? baseFormula.match(/\d+$/) : null
maxValue = maxMatch ? Number(maxMatch[0]) : 0
const rollData = {
type: options.rollType,
rollType: options.rollType,
target: options.rollTarget,
rollName: options.rollName,
actorId: options.actorId,
actorName: options.actorName,
actorImage: options.actorImage,
rollMode: rollContext.visibility,
hasTarget: options.hasTarget,
isDamage: options.isDamage,
pointBlank,
beyondSkill,
letItFly,
hasGrantedDice,
titleFormula,
targetName,
...rollContext,
}
/**
* A hook event that fires before the roll is made.
* @function
* @memberof hookEvents
* @param {Object} options Options for the roll.
* @param {Object} rollData All data related to the roll.
* @returns {boolean} Explicitly return `false` to prevent roll to be made.
*/
if (Hooks.call("fvtt-lethal-fantasy.preRoll", options, rollData) === false) return
let rollBase = new this(baseFormula, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
await rollModifier.evaluate()
await rollBase.evaluate()
let rollFavor
let badResult
if (rollContext.favor === "favor") {
rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate()
log("Favor dice", {
rollBaseTotal: rollBase.total,
rollFavorTotal: rollFavor.total,
rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)),
rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)),
baseFormula
})
if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true)
}
if (Number(rollFavor.result) > Number(rollBase.result)) {
badResult = rollBase.result
rollBase = rollFavor
} else {
badResult = rollFavor.result
}
rollFavor = null
}
if (rollContext.favor === "disfavor") {
rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate()
log("Disfavor dice", {
rollBaseTotal: rollBase.total,
rollFavorTotal: rollFavor.total,
rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)),
rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)),
baseFormula
})
if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true)
}
if (Number(rollFavor.result) < Number(rollBase.result)) {
badResult = rollBase.result
rollBase = rollFavor
} else {
badResult = rollFavor.result
}
rollFavor = null
}
if (options.forceNoD30) {
hasD30 = false
}
if (hasD30) {
let rollD30 = await new Roll("1D30").evaluate()
if (game?.dice3d) {
game.dice3d.showForRoll(rollD30, game.user, true)
}
options.D30result = rollD30.total
// Compute isRanged for D30: covers defense (isRangedDefense), monster ranged attacks (attackMode),
// and PC weapon attacks (isRangedAttack or weaponType)
const isRangedForD30 = options.isRangedDefense
|| options.rollTarget?.attackMode === "ranged"
|| options.rollTarget?.isRangedAttack === true
|| options.rollTarget?.weapon?.system?.weaponType === "ranged"
const d30Message = D30Roll.getResult(
rollD30.total,
options.rollType,
options.rollTarget?.weapon,
{ isRanged: isRangedForD30, isSpellSave: saveSpell }
)
options.D30message = d30Message
}
let rollTotal = 0
let diceResults = []
let resultType
let diceSum = 0
let singleDice = `1D${maxValue}`
for (let i = 0; i < rollBase.dice.length; i++) {
const dieResults = rollBase.dice[i].results
const resultCount = dieResults.length
for (let j = 0; j < resultCount; j++) {
let diceResult = dieResults[j].result
diceResults.push({ dice: `${singleDice.toUpperCase()}`, value: diceResult })
diceSum += diceResult
if (hasMaxValue) {
while (diceResult === maxValue) {
let r = await new Roll(baseFormula).evaluate()
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
// Add to DieTerm results so DSN/Foundry display shows explosion dice
dieResults.push({ result: diceResult, active: true })
}
}
}
}
if (hasGrantedDice && options.rollTarget.grantedDice && options.rollTarget.grantedDice !== "") {
titleFormula += ` + ${options.rollTarget.grantedDice.toUpperCase()}`
let grantedRoll = new Roll(options.rollTarget.grantedDice)
await grantedRoll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(grantedRoll, game.user, true)
}
diceResults.push({ dice: `${options.rollTarget.grantedDice.toUpperCase()}`, value: grantedRoll.total })
rollTotal += grantedRoll.total
}
if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
if (fullModifier < 0) {
rollTotal += Math.max(diceSum - rollModifier.total, 0)
} else {
rollTotal += diceSum + rollModifier.total
}
} else {
rollTotal += diceSum
}
rollBase.options.resultType = resultType
rollBase.options.rollTotal = rollTotal
rollBase.options.diceResults = diceResults
rollBase.options.rollTarget = options.rollTarget
rollBase.options.titleFormula = titleFormula
rollBase.options.D30result = options.D30result
rollBase.options.D30message = options.D30message
rollBase.options.badResult = badResult
rollBase.options.rollData = foundry.utils.duplicate(rollData)
rollBase.options.defenderId = options.defenderId
rollBase.options.defenderTokenId = options.defenderTokenId
rollBase.options.extraShieldDr = options.extraShieldDr || 0
rollBase.options.damageTier = options.damageTier || "standard"
rollBase.options.d30Bleed = options.d30Bleed || false
rollBase.options.d30DamageMultiplier = options.d30DamageMultiplier || 1
rollBase.options.d30DrMultiplier = options.d30DrMultiplier || 1
/**
* A hook event that fires after the roll has been made.
* @function
* @memberof hookEvents
* @param {Object} options Options for the roll.
* @param {Object} rollData All data related to the roll.
* @param {LethalFantasyRoll} roll The resulting roll.
* @returns {boolean} Explicitly return `false` to prevent roll to be made.
*/
if (Hooks.call("fvtt-lethal-fantasy.Roll", options, rollData, rollBase) === false) return
return rollBase
} finally {
// Clear one-shot flag so it doesn't leak to subsequent non-spell saves
if (game.lethalFantasy) game.lethalFantasy.spellDefense = false
}
}
+1 -1611
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 })
+28 -1701
View File
File diff suppressed because it is too large Load Diff
+909
View File
@@ -0,0 +1,909 @@
import { SYSTEM } from "../config/system.mjs"
import { log } from "./helpers.mjs"
import { processD30BonusDice } from "./d30.mjs"
export async function handleSocketEvent(msg = {}) {
log(`handleSocketEvent !`, msg)
let actor
switch (msg.type) {
case "applyDamage":
if (game.user.isGM) {
// Prefer the specific token actor (correct for unlinked monsters); fall back to world actor.
actor = msg.tokenId
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
: (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
?? game.actors.get(msg.actorId))
if (actor) await actor.applyDamage(msg.damage)
}
break
case "rollInitiative":
if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId)
await actor.system.rollInitiative(msg.combatId, msg.combatantId)
break
case "rollProgressionDice":
if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId)
await actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
break
case "requestDefense":
// Vérifier si le message est destiné à cet utilisateur
if (msg.userId === game.user.id) {
showDefenseRequest(msg)
}
break
case "offerAttackerGrit":
// Vérifier si le message est destiné à cet utilisateur
if (msg.userId === game.user.id) {
handleAttackerGritOffer(msg)
}
break
case "applyBleeding":
if (game.user.isGM) {
actor = msg.tokenId
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
: game.actors.get(msg.actorId)
if (actor && actor.system.hp?.wounds && msg.damage > 0) {
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
const slot = wounds.findIndex(w => !w.value && !w.duration)
if (slot !== -1) {
wounds[slot] = { value: msg.damage, duration: msg.damage, description: "Bleeding" }
await actor.update({ "system.hp.wounds": wounds })
}
}
}
break
case "attackBoosted":
if (msg.userId === game.user.id) {
handleAttackBoosted(msg)
}
break
}
}
export async function handleAttackerGritOffer(msg) {
const { attackerId, attackRoll, defenseRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, defenderId } = msg
const attacker = game.actors.get(attackerId)
if (!attacker) {
console.warn("Attacker not found:", attackerId)
return
}
const attackBonus = await offerAttackerGritBonus(
attacker,
attackRoll,
defenseRoll,
attackerName,
defenderName
)
const attackRollFinal = attackRoll + attackBonus
// Maintenant créer le message de comparaison
await compareAttackDefense({
attackerName,
attackerId,
attackRoll: attackRollFinal,
attackWeaponId,
attackRollType,
attackRollKey,
defenderName,
defenderId,
defenseRoll
})
}
export async function handleAttackBoosted(msg) {
const {
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: initialShieldDR,
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
damageTier, attackD30message, defenseD30message,
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
} = msg
const defender = game.actors.get(defenderId)
if (!defender) return
let updatedDefenseRoll = defenseRoll
let shieldBlocked = false
let shieldReaction = null
let canShieldReact = hasShield
let canAdHoc = canAdHocShield
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
let defenseDrMultiplier = null
if (defenseD30message && defender) {
const d30Result = await processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
updatedDefenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, 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})})
}
if (d30Result.specialEffect === "drMultiplier") {
defenseDrMultiplier = d30Result.multiplier
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
}
}
// Show the defense reaction dialog — while-loop for multiple reactions
if (defender) {
while (updatedDefenseRoll < attackRollFinal) {
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" },
classes: ["lethalfantasy"],
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
attackerName,
attackRoll: attackRollFinal,
attackStatus: "boosted attack to",
defenderName,
defenseRoll: updatedDefenseRoll,
defenseStatus: "currently has",
d30message: null,
offerText: "The attack was boosted! Choose how to improve the defense."
}),
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
if (choice === "grit") {
const bonusRoll = await rollBonusDie("1d6", defender)
updatedDefenseRoll += bonusRoll
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": 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") {
const bonusDie = await promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
if (bonusDie) {
const bonusRoll = await rollBonusDie(bonusDie, defender)
updatedDefenseRoll += bonusRoll
const bonusRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"bonusDie", actorName:defenderName, formula:bonusDie.toUpperCase(), value:bonusRoll, side:"defense"})
await ChatMessage.create({content: bonusRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
}
} else if (choice === "shieldReact" && canShieldReact) {
const shieldBonus = await rollBonusDie(shieldFormula, defender)
const newDefenseTotal = updatedDefenseRoll + shieldBonus
updatedDefenseRoll = newDefenseTotal
canShieldReact = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
const shieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:shieldDr})
await ChatMessage.create({
content: shieldBlockContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
const shieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
await ChatMessage.create({
content: shieldFailContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
} else if (choice === "adHocShield" && canAdHoc) {
const adHoc = await promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
if (adHoc) {
const shieldBonus = await rollBonusDie(adHoc.formula, defender)
const newDefenseTotal = updatedDefenseRoll + shieldBonus
updatedDefenseRoll = newDefenseTotal
canShieldReact = false
canAdHoc = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus }
const adHocShieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:adHoc.damageReduction})
await ChatMessage.create({
content: adHocShieldBlockContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
const adHocShieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
await ChatMessage.create({
content: adHocShieldFailContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
}
}
}
}
const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
await compareAttackDefense({
attackerName,
attackerId,
attackRoll: attackRollFinal,
attackWeaponId,
attackRollType,
attackRollKey,
defenderName,
defenderId,
defenderTokenId,
defenseRoll: updatedDefenseRoll,
outcome,
shieldDamageReduction: finalShieldDR,
d30Bleed: d30Bleed || "",
d30DamageMultiplier: d30DamageMultiplier || 1,
d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
damageTier: damageTier || "standard",
attackD30message
})
}
export async function showDefenseRequest(msg) {
const attackerName = msg.attackerName
const attackerId = msg.attackerId
const defenderName = msg.defenderName
const weaponName = msg.weaponName || "attack"
const attackRoll = msg.attackRoll
const attackWeaponId = msg.attackWeaponId
const attackRollType = msg.attackRollType
const attackRollKey = msg.attackRollKey
const attackD30result = msg.attackD30result
const attackD30message = msg.attackD30message
const attackRerollContext = msg.attackRerollContext
const combatantId = msg.combatantId
const tokenId = msg.tokenId
// Récupérer le défenseur - essayer d'abord depuis le combat, puis depuis le token
let defender = null
if (game.combat && combatantId) {
const combatant = game.combat.combatants.get(combatantId)
if (combatant) {
defender = combatant.actor
}
}
// Si pas trouvé dans le combat, chercher le token directement
if (!defender && tokenId) {
const token = canvas.tokens.get(tokenId)
if (token) {
defender = token.actor
}
}
if (!defender) {
ui.notifications.error("Defender actor not found")
return
}
// Resolve the specific token ID now while we still have combatant/token context.
// This is passed through to the damage roll so the GM-side socket handler can find the
// correct synthetic actor for unlinked tokens (avoids wrong-instance damage with multiple
// unlinked copies of the same monster).
const defenderTokenId = (() => {
if (game.combat && combatantId) {
const cbt = game.combat.combatants.get(combatantId)
if (cbt?.token?.id) return cbt.token.id
}
return tokenId ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id ?? null
})()
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
const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
if (isSpellAttack) {
const savesConfig = isMonster ? SYSTEM.MONSTER_SAVES : SYSTEM.SAVES
const combatSaves = ["will", "dodge", "toughness"]
const savesList = Object.values(savesConfig)
.filter(s => combatSaves.includes(s.id))
.map(s => ({id: s.id, label: game.i18n.localize(s.label)}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-save.hbs", {
attackerName, defenderName, weaponName, attackRoll, saves: savesList
})
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Saving Throw vs Spell" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "rollSave",
type: "button",
label: "Roll Save",
icon: "fa-solid fa-person-running",
callback: (event, button) => button.form.elements.saveKey.value,
},
],
rejectClose: false
})
if (result) {
game.lethalFantasy = game.lethalFantasy || {}
game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
_storeNextDefenseData()
if (isMonster) {
await defender.system.prepareMonsterRoll("save", result)
} else {
await defender.prepareRoll("save", result)
}
}
return
}
// Pour les monstres, récupérer les attaques activées
if (isMonster) {
const attacksSet = defender.system.attackMode === "ranged" ? defender.system.rangedAttacks : defender.system.attacks
const enabledAttacks = Object.entries(attacksSet).filter(([key, attack]) => attack.enabled)
if (enabledAttacks.length === 0) {
ui.notifications.warn("No enabled attacks available for defense")
return
}
// Créer le contenu du dialogue pour monstre
const attacksList = enabledAttacks.map(([key, attack]) => ({key, name: attack.name}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-monster.hbs", {
attackerName, defenderName, weaponName, attackRoll, attacks: attacksList
})
// Afficher le dialogue
const result = await foundry.applications.api.DialogV2.wait({
window: { title: msg.isRanged ? "Ranged Defense Roll" : "Defense Roll" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "rangeDefense",
type: "button",
label: "Roll Defense",
icon: "fa-solid fa-shield",
callback: (event, button, dialog) => {
const attackKey = button.form.elements.attackKey.value
return attackKey
},
},
],
rejectClose: false
})
// Si l'utilisateur a validé, lancer le jet de défense
if (result) {
_storeNextDefenseData({ isRanged: msg.isRanged })
await defender.system.prepareMonsterRoll("monster-defense", result)
}
return
}
// Pour les personnages, récupérer les armes équipées
// Si l'attaque est une attaque à distance, utiliser le dialogue de défense à distance
if (msg.isRanged) {
const { default: LethalFantasyRoll } = await import("../documents/roll.mjs")
const roll = await LethalFantasyRoll.promptRangedDefense({
actorId: defender.id,
actorName: defender.name,
actorImage: defender.img,
})
if (roll) {
_storeNextDefenseData({ isRanged: true })
await roll.toMessage({}, { messageMode: roll.options.rollMode })
}
return
}
// Pour les personnages, récupérer les armes équipées
const equippedWeapons = defender.items.filter(i =>
i.type === "weapon" && i.system.equipped === true
)
if (equippedWeapons.length === 0) {
ui.notifications.warn("No equipped weapons for defense")
return
}
// Créer le contenu du dialogue pour personnage
const weaponsList = equippedWeapons.map(w => ({id: w.id, name: w.name}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-character.hbs", {
attackerName, defenderName, weaponName, attackRoll, weapons: weaponsList
})
// Afficher le dialogue
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Defense Roll" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "defenseRoll",
type: "button",
label: "Roll Defense",
icon: "fa-solid fa-shield",
callback: (event, button, dialog) => {
const weaponId = button.form.elements.weaponId.value
return weaponId
},
},
],
rejectClose: false
})
// Si l'utilisateur a validé, lancer le jet de défense
if (result) {
_storeNextDefenseData({ isRanged: msg.isRanged })
log("Storing defense data for character:", defender.id)
await defender.prepareRoll("weapon-defense", result)
}
}
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"]
}
export function getShieldReactionData(actor) {
if (!actor) return null
if (actor.type === "monster") {
const formula = actor.system.combat?.shieldDefenseDice
const damageReduction = actor.getShieldDR()
if (!formula || damageReduction <= 0) return null
return {
label: game.i18n.localize("LETHALFANTASY.Label.shieldDefenseDice"),
formula,
damageReduction
}
}
const equippedShields = actor.items.filter(item => item.type === "shield" && item.system.equipped)
if (equippedShields.length === 0) return null
const shield = equippedShields[0]
return {
label: shield.name,
formula: shield.system.defense,
damageReduction: actor.getShieldDR(),
shieldId: shield.id
}
}
export async function promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) {
const choicesList = getCombatBonusDiceChoices()
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/bonus-die-select.hbs", {
actorName, currentRoll, opposingRoll, sideLabel, choices: choicesList
})
return await foundry.applications.api.DialogV2.wait({
window: { title: "Add Bonus Die" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "roll",
type: "button",
label: "Roll Bonus Die",
icon: "fa-solid fa-dice",
callback: (event, button) => {
const sel = button.form?.elements?.bonusDie ?? button.closest("form")?.elements?.bonusDie
return sel?.value ?? choicesList[0]
}
},
{
action: "cancel",
type: "button",
label: "Cancel",
icon: "fa-solid fa-xmark",
callback: () => null
}
],
rejectClose: false
})
}
/**
* Prompt the GM or player to choose an ad-hoc shield dice and DR value.
* Used when the defender has no pre-configured shield equipment.
* @param {string} defenderName
* @param {number} attackRoll
* @param {number} defenseRoll
* @returns {Promise<{formula: string, damageReduction: number}|null>}
*/
export async function promptAdHocShield(defenderName, attackRoll, defenseRoll) {
const choicesList = getCombatBonusDiceChoices()
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/ad-hoc-shield.hbs", {
defenderName, attackRoll, defenseRoll, choices: choicesList
})
const raw = await foundry.applications.api.DialogV2.wait({
window: { title: "Ad-hoc Shield Roll" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "roll",
type: "button",
label: "Roll Shield",
icon: "fa-solid fa-shield",
callback: (event, button) => {
const shieldDice = button.form?.elements?.shieldDice ?? button.closest("form")?.elements?.shieldDice
const shieldDR = button.form?.elements?.shieldDR ?? button.closest("form")?.elements?.shieldDR
return {
formula: shieldDice?.value ?? "1d6",
damageReduction: Number(shieldDR?.value) || 0
}
}
},
{
action: "cancel",
type: "button",
label: "Cancel",
icon: "fa-solid fa-xmark",
callback: () => null
}
],
rejectClose: false
})
return raw ?? null
}
/**
* Roll a bonus die formula, optionally showing Dice So Nice animation.
* @param {string} formula
* @param {Actor} actor
* @returns {Promise<number>}
*/
export async function rollBonusDie(formula, actor) {
const roll = new Roll(formula)
await roll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(roll, game.user, true)
}
return roll.total
}
export async function rerollConfiguredRoll(rerollContext = {}) {
const RollClass = CONFIG.Dice.rolls.find(r => r.name === "LethalFantasyRoll")
if (typeof RollClass?.prompt !== "function") {
ui.notifications.error("Lethal Fantasy roll class not available for reroll")
return null
}
return await RollClass.prompt({
...foundry.utils.duplicate(rerollContext),
rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}),
hasTarget: false,
target: false
})
}
export async function offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) {
let totalBonus = 0
let keepOffering = true
while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) {
const currentGrit = attacker.system.grit.current
if (currentGrit <= 0) {
break
}
const buttons = [
{
action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
},
{
action: "continue",
type: "button",
label: "Continue (no bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
}
]
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/attack-grit.hbs", {
attackerName,
currentAttackRollWithBonus: currentAttackRoll + totalBonus,
defenderName,
defenseRoll,
totalBonus
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Attack with Grit" },
classes: ["lethalfantasy"],
content,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") {
keepOffering = false
break
}
const bonusRoll = new Roll("1d6")
await bonusRoll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(bonusRoll, game.user, true)
}
totalBonus += bonusRoll.total
await attacker.update({ "system.grit.current": currentGrit - 1 })
const gritRm = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:attackerName, resource:"Grit", value:bonusRoll.total, side:"attack"})
await ChatMessage.create({content: gritRm, speaker: ChatMessage.getSpeaker({actor: attacker})})
}
return totalBonus
}
export async function compareAttackDefense(data) {
log("compareAttackDefense called with:", data)
// Compute D30 effects from the attack D30 message directly.
// This is more reliable than depending on the caller-provided values, which are
// computed per-client and may differ between clients due to cross-client processing order.
const d30DamageMultiplier = data.attackD30message?.type === "damage_multiplier"
? data.attackD30message.multiplier
: (data.d30DamageMultiplier || 1)
const d30Bleed = data.attackD30message?.type === "combo"
? (data.attackD30message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") ? "true" : "")
: data.attackD30message?.type === "bleed" ? "true" : (data.d30Bleed || "")
const d30DrMultiplier = data.d30DrMultiplier || 1
const shieldDamageReduction = data.shieldDamageReduction || 0
const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
const isAttackWin = outcome !== "miss"
log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
let damageButton = ""
if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) {
log("Creating damage button. defenderId:", data.defenderId)
// Déterminer le type de dégâts à lancer
if (data.attackRollType === "weapon-attack") {
damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "weapon",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
shieldDamageReduction: shieldDamageReduction,
attackWeaponId: data.attackWeaponId,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
} else if (data.attackRollType === "monster-attack") {
damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "monster",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
shieldDamageReduction: shieldDamageReduction,
attackRollKey: data.attackRollKey,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
} else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
const attacker = game.actors.get(data.attackerId)
const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
const chosenTier = data.damageTier || "standard"
const allTiers = [
{ id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
{ id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
{ id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
]
const tierData = allTiers.filter(t => t.id === chosenTier && t.formula).map(t => ({
formula: Handlebars.escapeExpression(t.formula),
label: t.label
}))
if (tierData.length) {
damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "spell",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
tiers: tierData,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
}
}
}
const resultMessage = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/combat-result.hbs", {
isAttackWin,
outcome,
attackerName: data.attackerName,
defenderName: data.defenderName,
attackRoll: data.attackRoll,
defenseRoll: data.defenseRoll,
shieldDamageReduction: shieldDamageReduction,
damageButton
})
log("Creating combat result message...")
await ChatMessage.create({
content: resultMessage,
speaker: { alias: "Combat System" }
})
log("Combat result message created!")
}
export async function applyDamage(message, event) {
// Récupérer les données du message
let combatantId = event.currentTarget.dataset.combatantId
if (!combatantId) {
ui.notifications.error("No combatant selected")
return
}
// Try to find the target: first as a combat combatant, then as a scene token
let targetActor = null
if (game.combat) {
const combatant = game.combat.combatants.get(combatantId)
if (combatant) {
targetActor = combatant.token?.actor || game.actors.get(combatant.actorId)
}
}
if (!targetActor) {
// Fall back to scene token lookup (non-combat tokens use tokenId as their combatantId)
const token = canvas.tokens?.placeables?.find(t => t.id === combatantId)
targetActor = token?.actor
}
if (!targetActor) {
ui.notifications.error("Target actor not found")
return
}
// Récupérer les données de dégâts du message
// Use options.rollTotal (includes weapon modifier bonus) rather than roll.total (dice formula only)
let damageTotal = message.rolls[0]?.options?.rollTotal ?? message.rolls[0]?.total ?? 0
let weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
// Calculer les DR
let armorDR = targetActor.computeDamageReduction() || 0
let shieldDR = targetActor.getShieldDR() || 0
let totalDR = armorDR + shieldDR
// Créer le dialogue
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/apply-damage-dialog.hbs",
{
targetName: targetActor.name,
weaponName: weaponName,
damageTotal: damageTotal,
armorDR: armorDR,
shieldDR: shieldDR,
totalDR: totalDR,
damageNoDR: damageTotal,
damageWithArmor: Math.max(0, damageTotal - armorDR),
damageWithAll: Math.max(0, damageTotal - totalDR)
}
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Apply Damage" },
classes: ["lethalfantasy"],
position: { width: 280 },
content,
buttons: [
{
action: "noDR",
type: "button",
label: "No DR",
callback: () => ({ drType: "none", damage: damageTotal })
},
{
action: "armorDR",
type: "button",
label: "With Armor DR",
callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
},
{
action: "allDR",
type: "button",
label: "With Armor + Shield DR",
callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
},
{
action: "cancel",
type: "button",
label: "Cancel",
callback: () => null
}
],
rejectClose: false
})
if (result && result.damage !== undefined) {
await targetActor.applyDamage(-result.damage)
// Message de confirmation
let drText = ""
if (result.drType === "armor") {
drText = `Armor DR: ${armorDR}`
} else if (result.drType === "all") {
drText = `Total DR: ${totalDR}`
}
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
{
targetName: targetActor.name,
damage: result.damage,
drText: drText,
weaponName: weaponName
}
)
await ChatMessage.create({
user: game.user.id,
speaker: { alias: targetActor.name },
mode: "gm",
content: messageContent
})
}
}
+128
View File
@@ -0,0 +1,128 @@
import { SYSTEM } from "../config/system.mjs"
export { log } from "./helpers.mjs"
export function hasD30Reroll(d30Message) {
return d30Message?.type === "mulligan"
}
/**
* Process D30 bonus dice for attack or defense.
* Rolls and applies bonus dice BEFORE grit/luck/shield decisions.
* For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect.
* For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice.
* @param {Object|null} d30Message The D30 result object
* @param {"attack"|"defense"} side Whether processing the attack or defense side
* @param {number|null} naturalRoll The natural D20 roll (for special strike type detection)
* @param {Object} actor The actor (for dice3d display)
* @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>}
*/
export async function processD30BonusDice(d30Message, side, naturalRoll = null, actor = null, canDialog = true) {
if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null }
const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"]
// ── Simple bonus_dice type ── auto-roll if target matches
if (d30Message.type === "bonus_dice") {
if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null }
const modifier = await _rollD30BonusDie(d30Message.dice, actor, !canDialog)
return { modifier, specialEffect: null, specialName: null }
}
// ── Choice type ── auto-roll bonus dice, alert about special effects
if (d30Message.type === "choice") {
// 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 }
}
// 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)
}
// 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
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
if (d30Message.type === "combo") {
const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury")
if (hasBleed) {
return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" }
}
}
// ── Damage multiplier type (2x/3x damage before DR)
if (d30Message.type === "damage_multiplier") {
return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
}
// ── DR multiplier type (2x/3x DR including shield)
if (d30Message.type === "dr_multiplier") {
return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier }
}
return { modifier: 0, specialEffect: null, specialName: null }
}
/**
* Roll a D30 bonus die and show with 3D dice if available.
* @param {string} formula Dice formula (e.g. "D6", "D12", "D20E")
* @param {Object} actor Actor for chat message speaker
* @returns {Promise<number>} The roll total
*/
export async function _rollD30BonusDie(formula, actor, silent = false) {
const cleaned = formula.replace(/NE$/i, "").replace("E", "")
const roll = new Roll(cleaned)
await roll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(roll, game.user, true)
}
if (!silent) {
await ChatMessage.create({
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30BonusRoll", formula: cleaned.toUpperCase(), value: roll.total}),
speaker: ChatMessage.getSpeaker({ actor })
})
}
return roll.total
}
/**
* Build the special effect name based on the D30 result and natural roll.
* @param {Object} specialChoice The choice object with type and options
* @param {number|null} naturalRoll The natural D20 roll
* @returns {string} The special effect name
*/
export function _buildSpecialName(specialChoice, naturalRoll) {
if (specialChoice.type === "special_strike") {
if (specialChoice.options.includes("lethal")) {
if (naturalRoll === 20) return "Lethal Strike"
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike"
return "Lethal/Vital Strike"
}
if (specialChoice.options.includes("vicious")) return "Vicious Strike"
return "Special Strike"
}
if (specialChoice.type === "special_defense") {
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense"
if (specialChoice.options.includes("flawless")) return "Flawless Defense"
if (specialChoice.options.includes("legendary")) return "Legendary Defense"
if (specialChoice.options.includes("perfect")) return "Perfect Defense"
return "Special Defense"
}
return specialChoice.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
}
+275
View File
@@ -0,0 +1,275 @@
import { SYSTEM } from "../config/system.mjs"
export function log(...args) {
if (game?.settings?.get(game.system.id, "debug")) {
console.log(...args)
}
}
export async function loadCompendiumData(compendium) {
const pack = game.packs.get(compendium)
return await pack?.getDocuments() ?? []
}
export async function loadCompendium(compendium, filter = item => true) {
let compendiumData = await loadCompendiumData(compendium)
return compendiumData.filter(filter)
}
export function pushCombatOptions(html, options) {
options.push({ name: "Reset Progression", condition: true, icon: '<i class="fas fa-rotate-right"></i>', callback: target => { game.combat.resetProgression(target.data('combatant-id')); } })
}
export function setHookListeners() {
Hooks.on('renderTokenHUD', async (hud, html, data) => {
if (html.querySelector(".lethal-hp-loss-hud")) return
// The token/actor is on the HUD application instance, not the third param.
// hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
const hudActor = hud.token?.actor ?? hud.object?.actor
if (!hudActor) return
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();
_toggleHudWraps("hp-loss")
})
$(html).find('.loss-hp-hud-click').click(async (event) => {
event.preventDefault();
await hudActor.applyDamage(Number(event.currentTarget.dataset.hpValue));
_disableHudWraps("hp-loss")
})
// 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();
_toggleHudWraps("hp-gain")
})
$(html).find('.gain-hp-hud-click').click(async (event) => {
event.preventDefault();
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
const luckGritButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/luck-grit-hud.hbs', {})
$(html).find('div.left').append(luckGritButton);
$(html).find('.lethal-luck-grit-hud').click((event) => {
event.preventDefault();
let wrap = $(html).find('.luck-grit-wrap')[0]
if (wrap.classList.contains("luck-grit-hud-disabled")) {
wrap.classList.add('luck-grit-hud-active');
wrap.classList.remove('luck-grit-hud-disabled');
} else {
wrap.classList.remove('luck-grit-hud-active');
wrap.classList.add('luck-grit-hud-disabled');
}
})
$(html).find('.luck-grit-btn').click(async (event) => {
event.preventDefault();
const resource = event.currentTarget.dataset.resource;
const amount = Number(event.currentTarget.dataset.amount);
const current = Number(foundry.utils.getProperty(hudActor.system, `${resource}.current`)) || 0;
const newValue = Math.max(0, current + amount);
await hudActor.update({ [`system.${resource}.current`]: newValue });
$(html).find('.luck-grit-wrap')[0].classList.remove('luck-grit-hud-active');
$(html).find('.luck-grit-wrap')[0].classList.add('luck-grit-hud-disabled');
})
})
}
export function registerHandlebarsHelpers() {
Handlebars.registerHelper('isNull', function (val) {
return val == null;
});
Handlebars.registerHelper('match', function (val, search) {
if (val && search) {
return val?.match(search);
}
return false
});
Handlebars.registerHelper('exists', function (val) {
return val != null && val !== undefined;
});
Handlebars.registerHelper('isEmpty', function (list) {
if (list) return list.length === 0;
else return false;
});
Handlebars.registerHelper('notEmpty', function (list) {
return list.length > 0;
});
Handlebars.registerHelper('isNegativeOrNull', function (val) {
return val <= 0;
});
Handlebars.registerHelper('isNegative', function (val) {
return val < 0;
});
Handlebars.registerHelper('isPositive', function (val) {
return val > 0;
});
Handlebars.registerHelper('equals', function (val1, val2) {
return val1 === val2;
});
Handlebars.registerHelper('neq', function (val1, val2) {
return val1 !== val2;
});
Handlebars.registerHelper('gt', function (val1, val2) {
return val1 > val2;
})
Handlebars.registerHelper('lt', function (val1, val2) {
return val1 < val2;
})
Handlebars.registerHelper('gte', function (val1, val2) {
return val1 >= val2;
})
Handlebars.registerHelper('lte', function (val1, val2) {
return val1 <= val2;
})
Handlebars.registerHelper('and', function (val1, val2) {
return val1 && val2;
})
Handlebars.registerHelper('or', function (val1, val2) {
return val1 || val2;
})
Handlebars.registerHelper('or3', function (val1, val2, val3) {
return val1 || val2 || val3;
})
Handlebars.registerHelper('for', function (from, to, incr, block) {
let accum = '';
for (let i = from; i < to; i += incr)
accum += block.fn(i);
return accum;
})
Handlebars.registerHelper('not', function (cond) {
return !cond;
})
Handlebars.registerHelper('count', function (list) {
return list.length;
})
Handlebars.registerHelper('countKeys', function (obj) {
return Object.keys(obj).length;
})
Handlebars.registerHelper('isEnabled', function (configKey) {
return game.settings.get("bol", configKey);
})
Handlebars.registerHelper('split', function (str, separator, keep) {
return str.split(separator)[keep];
})
// If you need to add Handlebars helpers, here are a few useful examples:
Handlebars.registerHelper('concat', function () {
let outStr = '';
for (let arg in arguments) {
if (typeof arguments[arg] != 'object') {
outStr += arguments[arg];
}
}
return outStr;
})
Handlebars.registerHelper('add', function (a, b) {
return parseInt(a) + parseInt(b);
});
Handlebars.registerHelper('mul', function (a, b) {
return parseInt(a) * parseInt(b);
})
Handlebars.registerHelper('sub', function (a, b) {
return parseInt(a) - parseInt(b);
})
Handlebars.registerHelper('abbrev2', function (a) {
return a.substring(0, 2);
})
Handlebars.registerHelper('abbrev3', function (a) {
return a.substring(0, 3);
})
Handlebars.registerHelper('valueAtIndex', function (arr, idx) {
return arr[idx];
})
Handlebars.registerHelper('includesKey', function (items, type, key) {
return items.filter(i => i.type === type).map(i => i.system.key).includes(key);
})
Handlebars.registerHelper('includes', function (array, val) {
return array.includes(val);
})
Handlebars.registerHelper('eval', function (expr) {
return eval(expr);
})
Handlebars.registerHelper('isOwnerOrGM', function (actor) {
log("Testing actor", actor.isOwner, game.userId)
return actor.isOwner || game.isGM;
})
Handlebars.registerHelper('upperCase', function (text) {
if (typeof text !== 'string') return text
return text.toUpperCase()
})
Handlebars.registerHelper('upperFirst', function (text) {
if (typeof text !== 'string') return text
return text.charAt(0).toUpperCase() + text.slice(1)
})
Handlebars.registerHelper('upperFirstOnly', function (text) {
if (typeof text !== 'string') return text
return text.charAt(0).toUpperCase()
})
// Handle v12 removal of this helper
Handlebars.registerHelper('select', function (selected, options) {
const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
const html = options.fn(this);
return html.replace(rgx, "$& selected");
});
}
export function getLethargyDice(level) {
for (let s of SYSTEM.SPELL_LETHARGY_DICE) {
if (Number(level) <= s.maxLevel) {
return s.dice
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<div class="dice-breakdown">
{{#each diceResults}}
<span class="dice-item">
<span class="dice-type">{{this.dice}}</span>
<span class="dice-separator">→</span>
<span class="dice-value">{{this.value}}</span>
</span>
{{/each}}
</div>
{{#if D30message}}
<div class="d30-result">
<span class="d30-value">D30 → {{D30result}}</span> — {{D30message.description}}
</div>
{{/if}}
+30
View File
@@ -0,0 +1,30 @@
<div class="attack-result {{#if isAttackWin}}attack-success{{else}}attack-failure{{/if}}">
<h3><i class="fa-solid {{#if isAttackWin}}fa-sword{{else}}fa-shield{{/if}}"></i> Combat Result</h3>
<div class="combat-comparison">
<div class="combat-side attacker {{#if isAttackWin}}winner{{else}}loser{{/if}}">
<div class="side-label">{{localize "LETHALFANTASY.Combat.attacker"}}</div>
<div class="side-info">
<div class="side-name">{{attackerName}}</div>
<div class="side-roll">{{attackRoll}}</div>
</div>
</div>
<div class="combat-vs">VS</div>
<div class="combat-side defender {{#if isAttackWin}}loser{{else}}winner{{/if}}">
<div class="side-label">{{localize "LETHALFANTASY.Combat.defender"}}</div>
<div class="side-info">
<div class="side-name">{{defenderName}}</div>
<div class="side-roll">{{defenseRoll}}</div>
</div>
</div>
</div>
<div class="combat-result-text">
{{#if (equals outcome "shielded-hit")}}
<i class="fa-solid fa-shield"></i> <strong>{{defenderName}}</strong> has blocked with shield — apply armor DR + shield DR <strong>{{shieldDamageReduction}}</strong>.
{{else if isAttackWin}}
<i class="fa-solid fa-circle-check"></i> <strong>{{attackerName}}</strong> hits <strong>{{defenderName}}</strong>!
{{else}}
<i class="fa-solid fa-shield-halved"></i> <strong>{{defenderName}}</strong> avoided the attack!
{{/if}}
</div>
{{{damageButton}}}
</div>
+47
View File
@@ -0,0 +1,47 @@
{{#if (equals type "weapon")}}
<div class="attack-result-damage single-btn">
<button class="roll-damage-btn"
data-attacker-id="{{attackerId}}"
data-defender-id="{{defenderId}}"
data-defender-token-id="{{defenderTokenId}}"
data-extra-shield-dr="{{shieldDamageReduction}}"
data-weapon-id="{{attackWeaponId}}"
data-damage-type="medium"
data-d30-bleed="{{d30Bleed}}"
data-d30-damage-mult="{{d30DamageMultiplier}}"
data-d30-dr-mult="{{d30DrMultiplier}}">
<i class="fa-solid fa-dice-d20"></i> Damage
</button>
</div>
{{else if (equals type "monster")}}
<div class="attack-result-damage single-btn">
<button class="roll-damage-btn"
data-attacker-id="{{attackerId}}"
data-defender-id="{{defenderId}}"
data-defender-token-id="{{defenderTokenId}}"
data-extra-shield-dr="{{shieldDamageReduction}}"
data-attack-key="{{attackRollKey}}"
data-damage-type="monster"
data-d30-bleed="{{d30Bleed}}"
data-d30-damage-mult="{{d30DamageMultiplier}}"
data-d30-dr-mult="{{d30DrMultiplier}}">
<i class="fa-solid fa-burst"></i> Damage
</button>
</div>
{{else if (equals type "spell")}}
<div class="attack-result-damage spell-damage">
{{#each tiers}}
<button class="roll-damage-btn"
data-attacker-id="{{../attackerId}}"
data-defender-id="{{../defenderId}}"
data-defender-token-id="{{../defenderTokenId}}"
data-damage-type="spell"
data-damage-formula="{{this.formula}}"
data-d30-bleed="{{../d30Bleed}}"
data-d30-damage-mult="{{../d30DamageMultiplier}}"
data-d30-dr-mult="{{../d30DrMultiplier}}">
<i class="fa-solid fa-wand-magic-sparkles"></i> {{this.label}} ({{this.formula}})
</button>
{{/each}}
</div>
{{/if}}
+1
View File
@@ -0,0 +1 @@
<section class="dice-rolls">{{{rollHTML}}}</section>
+20
View File
@@ -0,0 +1,20 @@
<div class="lf-free-roll-card">
<div class="lf-frc-header">
<i class="fa-solid fa-dice"></i>
<span class="lf-frc-title-text">{{titleText}}</span>
<span class="lf-frc-badge">{{badge}}</span>
</div>
<div class="lf-frc-dice">
{{#each dieChips}}
<div class="{{this.classes}}">
<span class="lf-frc-die-type">{{this.label}}</span>
<span class="lf-frc-die-sep">→</span>
<span class="lf-frc-die-val">{{this.value}}{{#if this.exploded}}<i class="fa-solid fa-burst lf-dt-explode-icon"></i>{{/if}}</span>
</div>
{{/each}}
</div>
<div class="lf-frc-total-bar">
<span class="lf-frc-total-label">{{totalLabel}}</span>
<span class="lf-frc-total-value">{{total}}</span>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
{{#if (equals type "aetherSpend")}}
<p>🔮 <strong>{{actorName}}</strong> casts <em>{{spellName}}{{#if tierLabel}}{{tierLabel}}{{/if}}</em> — spends <strong>{{value}}</strong> Aether <span style="color:#888;">({{oldValue}}{{newValue}})</span>.</p>
{{else if (equals type "graceSpend")}}
<p>✨ <strong>{{actorName}}</strong> invokes <em>{{spellName}}</em> — spends <strong>{{value}}</strong> Grace <span style="color:#888;">({{oldValue}}{{newValue}})</span>.</p>
{{else if (equals type "bleedingNotice")}}
<p><strong>Bleeding:</strong> Wound of {{value}} HP for {{value}} seconds.</p>
{{else if (equals type "d30BonusRoll")}}
<p>D30 bonus: rolled <strong>{{formula}}</strong> = <strong>{{value}}</strong></p>
{{else if (equals type "mulligan")}}
<p><strong>{{actorName}}</strong> uses Mulligan and re-rolls {{side}}: <strong>{{oldRoll}}</strong> → <strong>{{newRoll}}</strong>.</p>
{{> chat/dice-breakdown diceResults=diceResults D30result=D30result D30message=D30message}}
<p>Both sides may now react to the new numbers.</p>
{{else}}
<p>
<strong>{{actorName}}</strong>
{{#if (equals type "grit")}}
spends 1 {{resource}} and rolls <strong>{{value}}</strong> for {{side}}.
{{else if (equals type "luck")}}
spends 1 {{resource}} and rolls <strong>{{value}}</strong> for {{side}}.
{{else if (equals type "bonusDie")}}
adds <strong>{{formula}}</strong> and rolls <strong>{{value}}</strong> for {{side}}.
{{else if (equals type "d30Bonus")}}
gains <strong>+{{value}}</strong> from D30 bonus die for {{side}}.
{{else if (equals type "d30Flag")}}
D30 — <strong>{{specialName}}</strong> possible for {{actorName}}!
{{else if (equals type "d30Bleed")}}
D30 — <strong>Bleeding/Internal Injury</strong> on hit! Damage past DR will cause a bleeding wound.
{{else if (equals type "d30DamageMultiplier")}}
D30 — <strong>x{{value}} damage</strong> before damage reduction!
{{else if (equals type "d30DRMultiplier")}}
D30 — Defense grants <strong>x{{value}} DR</strong> (choose which DR types to multiply when damage is applied).
{{else if (equals type "shieldBlock")}}
rolls <strong>{{shieldLabel}}</strong> and adds <strong>{{shieldBonus}}</strong> to defense ({{newTotal}}{{opposingRoll}}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>{{shieldDR}}</strong> will apply to damage.
{{else if (equals type "shieldFail")}}
rolls <strong>{{shieldLabel}}</strong> and adds <strong>{{shieldBonus}}</strong> to defense ({{newTotal}} < {{opposingRoll}}). Shield did not block — normal hit, armor DR only.
{{else if (equals type "generic")}}
{{{body}}}
{{/if}}
</p>
{{/if}}
+18
View File
@@ -0,0 +1,18 @@
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>{{defenderName}}</strong> uses a shield (not equipped)</p>
<p>{{localize "LETHALFANTASY.Combat.attackRoll"}}: <strong>{{attackRoll}}</strong> — {{localize "LETHALFANTASY.Combat.currentDefense"}}: <strong>{{defenseRoll}}</strong></p>
</div>
<div class="weapon-selection" style="margin-top:8px;">
<label for="shield-dice">{{localize "LETHALFANTASY.Combat.shieldDice"}}:</label>
<select id="shield-dice" name="shieldDice" style="width: 100%; margin-top: 4px;">
{{#each choices}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>
<div class="weapon-selection" style="margin-top:8px;">
<label for="shield-dr">{{localize "LETHALFANTASY.Combat.shieldDR"}}:</label>
<input id="shield-dr" name="shieldDR" type="number" min="0" value="0" style="width: 100%; margin-top: 4px;" />
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>{{attackerName}}</strong> currently has <strong>{{currentAttackRollWithBonus}}</strong></p>
<p><strong>{{defenderName}}</strong> rolled <strong>{{defenseRoll}}</strong></p>
{{#if totalBonus}}<p class="bonus-info">Bonus already added: +{{totalBonus}}</p>{{/if}}
</div>
<p class="offer-text">You are losing! Spend Grit to add 1D6 to your attack?</p>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>{{attackerName}}</strong> currently has <strong>{{attackRoll}}</strong></p>
<p><strong>{{defenderName}}</strong> rolled <strong>{{defenseRoll}}</strong></p>
{{#if d30message}}<p class="bonus-info">D30 special: {{d30message.description}}</p>{{/if}}
</div>
<p class="offer-text">{{offerText}}</p>
</div>
+14
View File
@@ -0,0 +1,14 @@
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>{{actorName}}</strong> currently has <strong>{{currentRoll}}</strong></p>
<p>{{sideLabel}} opposing roll: <strong>{{opposingRoll}}</strong></p>
</div>
<div class="weapon-selection">
<label for="bonus-die">{{localize "LETHALFANTASY.Combat.chooseBonusDie"}}:</label>
<select id="bonus-die" name="bonusDie" style="width: 100%; margin-top: 8px;">
{{#each choices}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>
</div>
@@ -0,0 +1 @@
<p>{{message}}</p>
+16
View File
@@ -0,0 +1,16 @@
<div class="grit-luck-dialog">
<p><strong>D30 DR Multiplier ×{{multiplier}}</strong></p>
<p>Choose which DR types to multiply:</p>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-base" {{#if canBase}}checked{{/if}} {{#unless baseEnabled}}disabled{{/unless}}>
Base DR (Armor/Natural): {{baseDR}}×{{multiplier}} = {{baseDRMultiplied}}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-shield" {{#if canShield}}checked{{/if}} {{#unless shieldEnabled}}disabled{{/unless}}>
Shield DR: {{shieldDR}}×{{multiplier}} = {{shieldDRMultiplied}}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-magic" {{#if canMagic}}checked{{/if}} {{#unless magicEnabled}}disabled{{/unless}}>
Magic DR: {{magicDR}}×{{multiplier}} = {{magicDRMultiplied}}
</label>
</div>
+4
View File
@@ -0,0 +1,4 @@
<div class="grit-luck-dialog">
<p><strong>{{itemName}}</strong> has multiple damage tiers.</p>
<p>Choose which damage to use when the attack lands:</p>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>{{attackerName}}</strong> {{attackStatus}} <strong>{{attackRoll}}</strong></p>
<p><strong>{{defenderName}}</strong> {{defenseStatus}} <strong>{{defenseRoll}}</strong></p>
{{#if d30message}}<p class="bonus-info">D30 special: {{d30message.description}}</p>{{/if}}
</div>
<p class="offer-text">{{offerText}}</p>
</div>
@@ -0,0 +1,14 @@
<div class="defense-request-dialog">
<div class="attack-info">
<p><strong>{{attackerName}}</strong> attacks <strong>{{defenderName}}</strong> with <strong>{{weaponName}}</strong>!</p>
<p>{{localize "LETHALFANTASY.Combat.attackRoll"}}: <strong>{{attackRoll}}</strong></p>
</div>
<div class="weapon-selection">
<label for="defense-weapon">{{localize "LETHALFANTASY.Combat.chooseWeapon"}}:</label>
<select id="defense-weapon" name="weaponId" style="width: 100%; margin-top: 8px;">
{{#each weapons}}
<option value="{{this.id}}">{{this.name}}</option>
{{/each}}
</select>
</div>
</div>
@@ -0,0 +1,14 @@
<div class="defense-request-dialog">
<div class="attack-info">
<p><strong>{{attackerName}}</strong> attacks <strong>{{defenderName}}</strong> with <strong>{{weaponName}}</strong>!</p>
<p>{{localize "LETHALFANTASY.Combat.attackRoll"}}: <strong>{{attackRoll}}</strong></p>
</div>
<div class="weapon-selection">
<label for="defense-attack">{{localize "LETHALFANTASY.Combat.chooseWeapon"}}:</label>
<select id="defense-attack" name="attackKey" style="width: 100%; margin-top: 8px;">
{{#each attacks}}
<option value="{{this.key}}">{{this.name}}</option>
{{/each}}
</select>
</div>
</div>
@@ -0,0 +1,14 @@
<div class="defense-request-dialog">
<div class="attack-info">
<p><strong>{{attackerName}}</strong> targets <strong>{{defenderName}}</strong> with <strong>{{weaponName}}</strong>!</p>
<p>{{localize "LETHALFANTASY.Combat.attackRoll"}}: <strong>{{attackRoll}}</strong></p>
</div>
<div class="weapon-selection">
<label for="save-type">{{localize "LETHALFANTASY.Combat.chooseSave"}}:</label>
<select id="save-type" name="saveKey" style="width: 100%; margin-top: 8px;">
{{#each saves}}
<option value="{{this.id}}">{{this.label}}</option>
{{/each}}
</select>
</div>
</div>
+1
View File
@@ -0,0 +1 @@
<p>Select the power level for <strong>{{itemName}}</strong>:</p>
+7
View File
@@ -0,0 +1,7 @@
<div style="padding:0.5rem 0">
<p style="margin-bottom:0.6rem">{{msg}}</p>
<div style="display:flex;align-items:center;gap:0.5rem">
<label style="font-weight:bold">{{label}}</label>
<input type="number" name="manualDr" value="0" min="0" style="width:5rem"/>
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
<div class="lf-dt-row">
<span class="lf-dt-label"><i class="fa-solid fa-dice"></i></span>
<select class="lf-dt-count" title="{{countTitle}}">
{{#each countOptions}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
<div class="lf-dt-dice">
{{#each diceButtons}}
<button type="button" class="lf-dt-die-btn" data-die="{{this.value}}" title="{{this.label}}">{{this.label}}</button>
{{/each}}
</div>
<label class="lf-dt-explode-label" title="{{explodeTitle}}">
<input type="checkbox" class="lf-dt-explode" />
<i class="fa-solid fa-explosion"></i>
</label>
</div>