import { SYSTEM } from "../config/system.mjs" /** * D&D 5e style Roll system for Prism RPG * Simple 1d20 + modifier, with advantage/disadvantage */ export default class PrismRPGRoll extends Roll { static CHAT_TEMPLATE = "systems/fvtt-prism-rpg/templates/chat-message.hbs" /** * Calculate D&D 5e style ability modifier from ability score * @param {number} abilityScore The ability score value (3-18+) * @returns {number} The ability modifier */ static getAbilityModifier(abilityScore) { return Math.floor((abilityScore - 10) / 2) } // Getters for roll data 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 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 rollTotal() { return this.options.rollTotal } get rollTarget() { return this.options.rollTarget } get rollData() { return this.options.rollData } /** * D&D 5e style dice roll prompt * Formula: 1d20 + modifier, or damage dice + modifier * Advantage: 2d20kh, Disadvantage: 2d20kl * @param {Object} options Roll options * @returns {Promise} The roll result or null if cancelled */ static async prompt(options = {}) { let dice = "1d20" let hasModifier = true let hasAdvantage = true let isDamageRoll = false // Determine roll type and modifiers switch (options.rollType) { case "characteristic": options.rollName = options.rollTarget.name // Calculate D&D 5e modifier from characteristic value options.rollTarget.value = this.getAbilityModifier(options.rollTarget.value) break case "sub-attribute": options.rollName = options.rollTarget.name // Sub-attribute value is already a modifier (calculated in prepareDerivedData) break case "challenge": options.rollName = game.i18n.localize(`PRISMRPG.Label.${options.rollTarget.rollKey}`) break case "save": options.rollName = `${game.i18n.localize(`PRISMRPG.Label.${options.rollTarget.rollKey}`)} Save` // Calculate D&D 5e saving throw: ability modifier + proficiency bonus // Get the characteristic value from rollTarget const charValue = options.rollTarget.characteristicValue const abilityMod = this.getAbilityModifier(charValue) const saveBonus = options.rollTarget.saveBonus || 0 // Store separate values for display options.rollTarget.abilityModifier = abilityMod options.rollTarget.saveProficiency = saveBonus // Add the save bonus (proficiency) stored in saves options.rollTarget.value = abilityMod + saveBonus break case "skill": options.rollName = options.rollTarget.name // D&D 5e style: ability modifier + proficiency bonus const skillCharValue = options.rollTarget.characteristicValue const skillAbilityMod = this.getAbilityModifier(skillCharValue) const proficiency = options.rollTarget.proficiencyBonus || 0 options.rollTarget.value = skillAbilityMod + proficiency break case "weapon-attack": options.rollName = options.rollTarget.name // Default to STR for melee, DEX for ranged (will be updated by dialog choice) if (options.rollTarget.weapon.system.weaponType === "melee") { options.rollTarget.value = options.rollTarget.strMod + options.rollTarget.weapon.system.bonuses.attackBonus } else { options.rollTarget.value = options.rollTarget.dexMod + options.rollTarget.weapon.system.bonuses.attackBonus } break case "spell": case "spell-attack": case "spell-power": case "spell-cast": options.rollName = options.rollTarget.name // Find best mental characteristic (INT, WIS, CHA) const actor = game.actors.get(options.actorId) const intMod = this.getAbilityModifier(actor.system.characteristics.int.value) const wisMod = this.getAbilityModifier(actor.system.characteristics.wis.value) const chaMod = this.getAbilityModifier(actor.system.characteristics.cha.value) const bestMentalMod = Math.max(intMod, wisMod, chaMod) options.rollTarget.value = bestMentalMod // Store which characteristic is being used if (bestMentalMod === intMod) { options.rollTarget.mentalCharacteristic = "INT" options.rollTarget.mentalCharValue = actor.system.characteristics.int.value } else if (bestMentalMod === wisMod) { options.rollTarget.mentalCharacteristic = "WIS" options.rollTarget.mentalCharValue = actor.system.characteristics.wis.value } else { options.rollTarget.mentalCharacteristic = "CHA" options.rollTarget.mentalCharValue = actor.system.characteristics.cha.value } break case "miracle": case "miracle-attack": case "miracle-power": options.rollName = options.rollTarget.name options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier break case "monster-attack": options.rollName = options.rollTarget.name options.rollTarget.value = options.rollTarget.attackModifier break case "monster-defense": options.rollName = options.rollTarget.name options.rollTarget.value = options.rollTarget.defenseModifier break case "monster-skill": options.rollName = game.i18n.localize(`PRISMRPG.Label.${options.rollTarget.rollKey}`) break default: if (options.rollType.includes("weapon-damage")) { isDamageRoll = true hasAdvantage = false options.rollName = options.rollTarget.weapon.name // Default to STR for melee, DEX for ranged (will be updated by dialog choice) if (options.rollTarget.weapon.system.weaponType === "melee") { options.rollTarget.value = options.rollTarget.strMod + options.rollTarget.weapon.system.bonuses.damageBonus } else { options.rollTarget.value = options.rollTarget.dexMod + options.rollTarget.weapon.system.bonuses.damageBonus } // Use the weapon's damage dice dice = options.rollTarget.weapon.system.damage || "1d6" dice = dice.replace(/E/gi, "") } else if (options.rollType.includes("monster-damage")) { isDamageRoll = true hasAdvantage = false options.rollName = options.rollTarget.name options.rollTarget.value = options.rollTarget.damageModifier dice = options.rollTarget.damageDice.replace(/E/gi, "") } else if (options.rollType === "granted") { hasModifier = false hasAdvantage = false options.rollName = `Granted ${options.rollTarget.rollKey}` dice = options.rollTarget.formula } } // Setup dialog const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) const choiceModifier = SYSTEM.CHOICE_MODIFIERS const choiceAdvantage = SYSTEM.ADVANTAGE_CHOICES const attackerAimChoices = SYSTEM.ATTACKER_AIM_CHOICES // For weapon damage rolls, skip dialog and roll directly if (options.rollType.includes("weapon-damage")) { // Just roll the weapon's damage dice, no modifiers const finalFormula = dice const rollData = { type: options.rollType, rollType: options.rollType, target: options.rollTarget, rollName: options.rollName, actorId: options.actorId, actorName: options.actorName, actorImage: options.actorImage, rollMode: "publicroll", hasTarget: options.hasTarget, titleFormula: finalFormula } if (Hooks.call("fvtt-prism-rpg.preRoll", options, rollData) === false) return // Execute the roll let roll = new this(finalFormula, options.data, rollData) await roll.evaluate() // Store results const duplicatedRollTarget = foundry.utils.duplicate(options.rollTarget) roll.options.resultType = "success" roll.options.rollTotal = roll.total roll.options.rollTarget = duplicatedRollTarget roll.options.titleFormula = finalFormula roll.options.rollData = foundry.utils.duplicate(rollData) if (Hooks.call("fvtt-prism-rpg.Roll", options, rollData, roll) === false) return return roll } let dialogContext = { rollType: options.rollType, rollTarget: options.rollTarget, rollName: options.rollName, actorName: options.actorName, rollModes, hasModifier, hasAdvantage, isDamageRoll, baseValue: options.rollTarget.value || 0, dice, choiceModifier, choiceAdvantage, attackerAimChoices, hasTarget: options.hasTarget, modifier: "+0", advantage: "none" } const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-prism-rpg/templates/roll-dialog-v2.hbs", dialogContext ) let position = game.user.getFlag(SYSTEM.id, "roll-dialog-pos") || { top: -1, left: -1 } const label = game.i18n.localize("PRISMRPG.Roll.roll") const rollContext = await foundry.applications.api.DialogV2.wait({ window: { title: "Roll dialog" }, classes: ["prismrpg"], content, position, buttons: [{ label: label, callback: (event, button, dialog) => { game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(dialog.position)) const output = Array.from(button.form.elements).reduce((obj, input) => { if (input.name) { if (input.type === "checkbox") { obj[input.name] = input.checked } else { obj[input.name] = input.value } } return obj }, {}) return output }, }], rejectClose: false }) if (rollContext === null) return // Build D&D 5e formula: 1d20 + modifier let finalFormula = dice let totalModifier = 0 if (hasModifier) { let bonus = Number(options.rollTarget.value) || 0 // Recalculate bonus if player chose different attribute for weapon attack/damage if (rollContext.attackAttribute && options.rollTarget.weapon) { const chosenMod = rollContext.attackAttribute === "str" ? options.rollTarget.strMod : options.rollTarget.dexMod const weaponBonus = options.rollTarget.weapon.system.bonuses.attackBonus || 0 const damageBonus = options.rollTarget.weapon.system.bonuses.damageBonus || 0 if (options.rollType === "weapon-attack") { bonus = chosenMod + weaponBonus } else if (options.rollType.includes("weapon-damage")) { bonus = chosenMod + damageBonus } } let extraModifier = rollContext.modifier === "" ? 0 : Number.parseInt(rollContext.modifier, 10) totalModifier = bonus + extraModifier // Apply aiming modifier for ranged attacks if (rollContext.attackerAim && rollContext.attackerAim !== "0") { const aimModifier = Number.parseInt(rollContext.attackerAim, 10) totalModifier += aimModifier } if (totalModifier !== 0) { finalFormula = totalModifier > 0 ? `${dice} + ${totalModifier}` : `${dice} - ${Math.abs(totalModifier)}` } } // Apply advantage/disadvantage if (rollContext.advantage === "advantage" && !isDamageRoll) { finalFormula = finalFormula.replace(dice, `2${dice}kh`) } else if (rollContext.advantage === "disadvantage" && !isDamageRoll) { finalFormula = finalFormula.replace(dice, `2${dice}kl`) } // Special ranged weapon modifiers if (rollContext.letItFly) { // Let it Fly: Pure D20E (replace with 1d20 if it was modified) finalFormula = finalFormula.replace(/2d20k[hl]/, "1d20") } if (rollContext.pointBlank) { // Point Blank: Add special advantage or bonus (implement based on your rules) // This could add advantage or a flat bonus } // Handle spell upcast let upcastLevel = 0 let totalManaCost = 0 let totalAPC = 0 let manaUpkeep = 0 let mentalCharacteristic = null let mentalCharValue = null if (options.rollType === "spell-cast") { upcastLevel = rollContext.upcastLevel ? Number.parseInt(rollContext.upcastLevel, 10) : 0 totalManaCost = options.rollTarget.system.manaCost + upcastLevel totalAPC = options.rollTarget.system.apc + upcastLevel manaUpkeep = options.rollTarget.system.manaUpkeep // Get mental characteristic info from rollTarget mentalCharacteristic = options.rollTarget.mentalCharacteristic mentalCharValue = options.rollTarget.mentalCharValue } 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, titleFormula: finalFormula, upcastLevel, totalManaCost, totalAPC, manaUpkeep, mentalCharacteristic, mentalCharValue, ...rollContext, } if (Hooks.call("fvtt-prism-rpg.preRoll", options, rollData) === false) return // Handle mana spending for spell-cast if (options.rollType === "spell-cast" && totalManaCost > 0) { const actor = game.actors.get(options.actorId) const currentMana = actor.system.manaPoints.value // Check if enough mana if (currentMana < totalManaCost) { ui.notifications.error( `Not enough Mana! Need ${totalManaCost}, but only have ${currentMana} Mana points.` ) return null } // Spend mana await actor.update({ "system.manaPoints.value": currentMana - totalManaCost }) ui.notifications.info( `Spent ${totalManaCost} Mana (${currentMana} → ${currentMana - totalManaCost})` ) } // Execute the roll let roll = new this(finalFormula, options.data, rollData) await roll.evaluate() // Store results - duplicate rollTarget to properly serialize weapon Item const duplicatedRollTarget = foundry.utils.duplicate(options.rollTarget) roll.options.resultType = "success" roll.options.rollTotal = roll.total roll.options.rollTarget = duplicatedRollTarget roll.options.titleFormula = finalFormula roll.options.rollData = foundry.utils.duplicate(rollData) if (Hooks.call("fvtt-prism-rpg.Roll", options, rollData, roll) === false) return return roll } /** @override */ async render(chatOptions = {}) { let chatData = await this._getChatCardData(chatOptions.isPrivate) return await foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, chatData) } async _getChatCardData(isPrivate) { 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, actingCharName: this.actorName, actingCharImg: this.actorImage, resultType: this.resultType, hasTarget: this.hasTarget, targetName: this.targetName, rollData: this.rollData, isPrivate: isPrivate } cardData.cssClass = cardData.css.join(" ") cardData.tooltip = isPrivate ? "" : await this.getTooltip() return cardData } async toMessage(messageData = {}, { rollMode, create = true } = {}) { super.toMessage( { isFailure: this.resultType === "failure", rollType: this.type, rollTarget: this.rollTarget, actingCharName: this.actorName, actingCharImg: this.actorImage, hasTarget: this.hasTarget, targetName: this.targetName, rollData: this.rollData, ...messageData, }, { rollMode: rollMode }, ) } }