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} 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" }