Compare commits

..

7 Commits

Author SHA1 Message Date
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
39 changed files with 4687 additions and 4550 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
token: ${{ secrets.FOUNDRY_PUBLISH_KEY }} token: ${{ secrets.FOUNDRY_PUBLISH_KEY }}
id: "fvtt-lethal-fantasy" id: "fvtt-lethal-fantasy"
version: ${{github.event.release.tag_name}} 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" notes: "https://www.uberwald.me/gitea/public/fvtt-lethal-fantasy/raw/branch/main/changelog.md"
compatibility-minimum: "14" compatibility-minimum: "14"
compatibility-verified: "14" compatibility-verified: "14"
+13 -1
View File
@@ -1081,7 +1081,19 @@
"spellDRDialogMsg": "Enter a damage reduction value to subtract, or click No DR to apply full damage.", "spellDRDialogMsg": "Enter a damage reduction value to subtract, or click No DR to apply full damage.",
"spellDRLabel": "DR:", "spellDRLabel": "DR:",
"spellNoDR": "No 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": { "EquipmentCategories": {
"ClassKit": "Class Kit", "ClassKit": "Class Kit",
+3 -1179
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -208,9 +208,10 @@ export class LethalFantasyCombat extends Combat {
c => c.actor?.type === "monster" && !c.isDefeated && c.initiative !== null && this.round >= c.initiative c => c.actor?.type === "monster" && !c.isDefeated && c.initiative !== null && this.round >= c.initiative
); );
if (eligibleMonsters.length > 0 && this._monsterProgressionRolledRound !== this.round) { 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({ const proceed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledTitle") }, 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") }, yes: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedYes") },
no: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo") }, no: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo") },
rejectClose: false, rejectClose: false,
+20 -47
View File
@@ -16,7 +16,7 @@ const DICE_TYPES = ["d4", "d6", "d8", "d10", "d12", "d20", "d30"]
* @param {Application} _chatLog * @param {Application} _chatLog
* @param {HTMLElement|jQuery} html * @param {HTMLElement|jQuery} html
*/ */
export function injectDiceTray(_chatLog, html) { export async function injectDiceTray(_chatLog, html) {
const el = (html instanceof HTMLElement) ? html : (html[0] ?? html) const el = (html instanceof HTMLElement) ? html : (html[0] ?? html)
if (!el?.querySelector) return if (!el?.querySelector) return
if (el.querySelector(".lf-dice-tray")) return if (el.querySelector(".lf-dice-tray")) return
@@ -24,27 +24,14 @@ export function injectDiceTray(_chatLog, html) {
const bar = document.createElement("div") const bar = document.createElement("div")
bar.className = "lf-dice-tray" bar.className = "lf-dice-tray"
const diceButtons = DICE_TYPES.map(d => const diceButtons = DICE_TYPES.map(d => ({ value: d, label: d.toUpperCase() }))
`<button type="button" class="lf-dt-die-btn" data-die="${d}" title="${d.toUpperCase()}">${d.toUpperCase()}</button>` const countOptions = Array.from({ length: 9 }, (_, i) => i + 1)
).join("") bar.innerHTML = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/ui/dice-tray.hbs", {
countTitle: game.i18n.localize("LETHALFANTASY.DiceTray.CountTitle"),
const countOptions = Array.from({ length: 9 }, (_, i) => explodeTitle: game.i18n.localize("LETHALFANTASY.DiceTray.ExplodeTitle"),
`<option value="${i + 1}">${i + 1}</option>` countOptions,
).join("") diceButtons
})
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>
`
bar.addEventListener("click", async ev => { bar.addEventListener("click", async ev => {
const btn = ev.target.closest(".lf-dt-die-btn") 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 dieChipsWithClasses = dieChips.map(chip => ({
const isMax = !chip.exploded && chip.value === sides ...chip,
const isMin = chip.value === 1 classes: ["lf-frc-die-chip", !chip.exploded && chip.value === sides ? "lf-frc-max" : "", chip.value === 1 ? "lf-frc-min" : ""].filter(Boolean).join(" ")
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 totalLabel = game.i18n.localize("LETHALFANTASY.Label.total").toUpperCase() const totalLabel = game.i18n.localize("LETHALFANTASY.Label.total").toUpperCase()
const content = ` const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/free-roll-card.hbs", {
<div class="lf-free-roll-card"> titleText: game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle"),
<div class="lf-frc-header"> badge: label,
<i class="fa-solid fa-dice"></i> dieChips: dieChipsWithClasses,
<span class="lf-frc-title-text">${game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle")}</span> totalLabel,
<span class="lf-frc-badge">${label}</span> total
</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 rollMode = game.settings.get("core", "rollMode") const rollMode = game.settings.get("core", "rollMode")
// Normalize old-style rollMode keys (v12/v13) to new-style (v14), fallback to "public" // 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] const formula = formulaMap[tier]
if (!formula) return 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({ const manualDR = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") }, window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
classes: ["lethalfantasy"], classes: ["lethalfantasy"],
position: { width: 320 }, position: { width: 320 },
content: `<div style="padding:0.5rem 0"> content,
<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>`,
buttons: [ buttons: [
{ {
action: "noDR", action: "noDR",
+1 -1
View File
@@ -225,7 +225,7 @@ export default class LethalFantasyActor extends Actor {
damageTier = await foundry.applications.api.DialogV2.wait({ damageTier = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose spell tier" }, window: { title: "Choose spell tier" },
classes: ["lethalfantasy"], 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, buttons,
rejectClose: false, rejectClose: false,
}) || "standard" }) || "standard"
+1 -1
View File
@@ -13,7 +13,7 @@ export default class LethalFantasyChatMessage extends ChatMessage {
messageData.isWhisper = false messageData.isWhisper = false
messageData.alias = this.user.name 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 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
+28 -1701
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
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 ── present all options to the player
if (d30Message.type === "choice") {
// If we can't show dialogs (wrong client), skip — the primary client
// will communicate its choice result via socket. Auto-rolling here
// would give a different modifier on each client, causing divergence.
if (!canDialog) {
return { modifier: 0, specialEffect: null, specialName: null }
}
const buttons = d30Message.choices.map(c => {
let label
let icon
if (c.type === "bonus_dice") {
label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
icon = "fa-solid fa-dice"
} else if (c.type === "special_strike") {
label = _buildSpecialLabel(c, naturalRoll)
icon = "fa-solid fa-star"
} else if (c.type === "special_defense") {
label = _buildSpecialLabel(c, naturalRoll)
icon = "fa-solid fa-shield-halved"
} else {
label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
icon = "fa-solid fa-question"
}
return {
action: c.type,
type: "button",
label,
icon,
callback: () => c
}
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "D30 Special — Choose Effect" },
classes: ["lethalfantasy"],
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-special-choice.hbs", {
description: d30Message.description
}),
buttons,
rejectClose: false
})
if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
if (choice.type === "bonus_dice") {
const modifier = await _rollD30BonusDie(choice.dice, actor)
return { modifier, specialEffect: null, specialName: null }
}
if (choice.type === "special_strike" || choice.type === "special_defense") {
return { modifier: 0, specialEffect: "auto", specialName: _buildSpecialName(choice, naturalRoll) }
}
// Non-standard choice (spell_calamity, etc.) — report it
return { modifier: 0, specialEffect: "flag", specialName: choice.type }
}
// ── 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 a human-readable label for a special strike/defense choice in the D30 prompt.
* @param {Object} specialChoice The choice object with type and options
* @param {number|null} naturalRoll The natural D20 roll
* @returns {string} Display label
*/
export function _buildSpecialLabel(specialChoice, naturalRoll) {
if (specialChoice.type === "special_strike") {
if (specialChoice.options.includes("lethal")) {
if (naturalRoll === 20) return "Lethal Strike (auto-hit)"
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)"
return "Lethal/Vital Strike (auto-hit)"
}
if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)"
return "Special Strike (auto-hit)"
}
if (specialChoice.type === "special_defense") {
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)"
if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)"
if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)"
if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)"
return "Special Defense (auto-block)"
}
return "Special Effect"
}
/**
* Build the special effect name based on the D30 result and natural roll.
* @param {Object} specialChoice The choice object with type and options
* @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 "Special Effect"
}
+303
View File
@@ -0,0 +1,303 @@
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
// HP Loss Button (existing)
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
$(html).find('div.left').append(lossHPButton);
$(html).find('img.lethal-hp-loss-hud').click((event) => {
event.preventDefault();
let hpMenu = $(html).find('.hp-loss-wrap')[0]
if (hpMenu.classList.contains("hp-loss-hud-disabled")) {
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled');
} else {
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
}
})
$(html).find('.loss-hp-hud-click').click(async (event) => {
event.preventDefault();
let hpLoss = event.currentTarget.dataset.hpValue;
await hudActor.applyDamage(Number(hpLoss));
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
})
// HP Gain Button (new)
const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
$(html).find('div.left').append(gainHPButton);
$(html).find('img.lethal-hp-gain-hud').click((event) => {
event.preventDefault();
let hpMenu = $(html).find('.hp-gain-wrap')[0]
if (hpMenu.classList.contains("hp-gain-hud-disabled")) {
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled');
} else {
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
}
})
$(html).find('.gain-hp-hud-click').click(async (event) => {
event.preventDefault();
let hpGain = event.currentTarget.dataset.hpValue;
await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
// Clear bleeding wounds on heal — regardless of heal amount, any
// healing is enough to stop bleeding (field dressing / magic / rest).
const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
const hadBleeding = wounds.some(w => w.description === "Bleeding")
if (hadBleeding) {
await hudActor.update({
"system.hp.wounds": wounds.map(w =>
w.description === "Bleeding" ? { value: 0, duration: 0 } : w
)
})
}
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
})
// 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>
+42
View File
@@ -0,0 +1,42 @@
{{#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 "d30Auto")}}
uses <strong>{{specialName}}</strong> from D30 — {{#if (equals side "defense")}}defense automatically succeeds!{{else}}attack automatically hits!{{/if}}
{{else if (equals type "d30Flag")}}
D30 — <strong>{{specialName}}</strong> triggered 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>D30 result:</strong> {{description}}</p>
<p>{{localize "LETHALFANTASY.D30.chooseEffect"}}</p>
</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>