3df46b5848
- 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
184 lines
7.7 KiB
JavaScript
184 lines
7.7 KiB
JavaScript
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"
|
|
}
|