Files
2026-01-14 14:16:31 +01:00

536 lines
18 KiB
JavaScript

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<PrismRPGRoll|null>} 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: sub-attribute modifier + proficiency bonus
// Default to first sub-attribute, will be recalculated if player chooses different one
const proficiency = options.rollTarget.proficiencyBonus || 0
options.rollTarget.value = options.rollTarget.subAttribute1Value + 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",
config: SYSTEM
}
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
}
}
// Recalculate bonus if player chose different sub-attribute for skill
if (rollContext.skillSubAttribute && options.rollType === "skill") {
const chosenSubAttrValue = rollContext.skillSubAttribute === options.rollTarget.subAttribute1 ?
options.rollTarget.subAttribute1Value :
options.rollTarget.subAttribute2Value
const proficiencyBonus = options.rollTarget.proficiencyBonus || 0
bonus = chosenSubAttrValue + proficiencyBonus
}
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
}
// Store skill sub-attribute information
let skillSubAttribute = null
let skillSubAttributeLabel = null
let skillSubAttributeValue = null
if (options.rollType === "skill" && rollContext.skillSubAttribute) {
skillSubAttribute = rollContext.skillSubAttribute
const subAttrConfig = SYSTEM.SUB_ATTRIBUTES?.[skillSubAttribute]
if (subAttrConfig) {
skillSubAttributeLabel = game.i18n.localize(subAttrConfig.label)
}
skillSubAttributeValue = rollContext.skillSubAttribute === options.rollTarget.subAttribute1 ?
options.rollTarget.subAttribute1Value :
options.rollTarget.subAttribute2Value
}
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,
skillSubAttribute,
skillSubAttributeLabel,
skillSubAttributeValue,
...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 },
)
}
}