536 lines
18 KiB
JavaScript
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 },
|
|
)
|
|
}
|
|
}
|