Files
fvtt-adventures-with-emmy/module/documents/roll.mjs
T
2026-03-06 08:06:57 +01:00

199 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SYSTEM } from "../config/system.mjs"
export default class AwERoll extends Roll {
/** @type {string} */
static CHAT_TEMPLATE = "systems/fvtt-adventures-with-emmy/templates/chat-message.hbs"
// --- Accessors for roll options ---
get attributeKey() { return this.options.attributeKey }
get rollName() { return this.options.rollName }
get modifier() { return this.options.modifier ?? 0 }
get attributeBonus() { return this.options.attributeBonus ?? 0 }
get bonus() { return this.options.bonus ?? 0 }
get knowledgeBonus() { return this.options.knowledgeBonus ?? 0 }
get dc() { return this.options.dc }
get outcome() { return this.options.outcome }
get actorId() { return this.options.actorId }
get actorName() { return this.options.actorName }
get actorImage() { return this.options.actorImage }
// --- Outcome calculation ---
/**
* Compute the degree of success for a d20 check.
* - Critical Success : total ≥ dc + 10, OR natural 20 upgrades result
* - Success : total ≥ dc
* - Failure : total < dc
* - Critical Failure : total ≤ dc 10, OR natural 1 downgrades result
*
* @param {number} total The final roll total (d20 + modifiers)
* @param {number} dc The Difficulty Class to compare against
* @param {number} d20Value The raw d20 result (for nat-20 / nat-1 adjustment)
* @returns {"criticalSuccess"|"success"|"failure"|"criticalFailure"}
*/
static computeOutcome(total, dc, d20Value) {
const DEGREES = ["criticalFailure", "failure", "success", "criticalSuccess"]
let idx
if (total >= dc + 10) idx = 3
else if (total >= dc) idx = 2
else if (total <= dc - 10) idx = 0
else idx = 1
if (d20Value === 20) idx = Math.min(3, idx + 1)
if (d20Value === 1) idx = Math.max(0, idx - 1)
return DEGREES[idx]
}
// --- Prompt dialog ---
/**
* Open a dialog so the player can add a situational bonus and choose visibility,
* then evaluate and post the roll to chat.
*
* @param {object} options
* @param {string} options.attributeKey Attribute id (agility | fitness | awareness | influence)
* @param {number} options.modifier Base attribute modifier (level + boostLevel)
* @param {number} [options.attributeBonus] Persistent attribute bonus/penalty from the +/- column
* @param {object[]} [options.knowledgeBonuses] Field knowledge bonuses [{label, bonus}]
* @param {number} [options.dc] Pre-set DC
* @param {string} options.actorId
* @param {string} options.actorName
* @param {string} options.actorImage
* @returns {Promise<AwERoll|null>} Resolved roll, or null if dialog was cancelled
*/
static async prompt(options = {}) {
const attrLabel = options.attributeKey
? game.i18n.localize(SYSTEM.ATTRIBUTES[options.attributeKey]?.label ?? options.attributeKey)
: (options.rollName ?? game.i18n.localize("AWEMMY.Roll.Check"))
const mod = options.modifier ?? 0
const attrBonus = options.attributeBonus ?? 0
const knowledgeBonuses = options.knowledgeBonuses ?? []
const rollModes = Object.fromEntries(
Object.entries(CONFIG.Dice.rollModes).map(([k, v]) => [k, game.i18n.localize(v.label ?? v)])
)
// Bonus choices: -5 to +5
const bonusChoices = Array.from({length: 11}, (_, i) => i - 5)
.map(v => ({ value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }))
// DC choices: blank + 10..30
const dcChoices = [{ value: "", label: "—", selected: !options.dc }]
.concat(Array.from({length: 21}, (_, i) => i + 10)
.map(v => ({ value: v, label: String(v), selected: v === (options.dc ?? null) })))
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-adventures-with-emmy/templates/roll-dialog.hbs",
{
attrLabel,
modifier: mod,
attributeBonus: attrBonus,
totalMod: mod + attrBonus,
knowledgeBonuses,
bonusChoices,
dcChoices,
rollModes,
visibility: game.settings.get("core", "rollMode")
}
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("AWEMMY.Roll.DialogTitle", { name: attrLabel }) },
classes: ["awemmy"],
content,
render: (event, dialog) => {
const baseMod = mod + attrBonus
const el = dialog.element
const bonusSelect = el.querySelector('#awe-bonus')
const knowledgeSel = el.querySelector('#awe-knowledge')
const preview = el.querySelector('#awe-formula-preview')
function updatePreview() {
const sit = parseInt(bonusSelect?.value) || 0
const kn = parseInt(knowledgeSel?.value) || 0
const total = baseMod + sit + kn
const sign = total >= 0 ? '+' : ''
const abs = Math.abs(total)
preview.textContent = total === 0 ? '1d20' : `1d20 ${sign} ${abs}`
}
bonusSelect?.addEventListener('change', updatePreview)
knowledgeSel?.addEventListener('change', updatePreview)
updatePreview()
},
buttons: [{
label: game.i18n.localize("AWEMMY.Roll.Roll"),
callback: (_event, button) =>
Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
}],
rejectClose: false
})
if (!result) return null
const bonus = parseInt(result.bonus) || 0
const knowledgeBonus = parseInt(result.knowledgeBonus) || 0
const dc = result.dc !== "" ? parseInt(result.dc) : undefined
const rollMode = result.visibility ?? game.settings.get("core", "rollMode")
// Formula: 1d20 + (mod + attrBonus) [± bonus] [± knowledgeBonus]
const totalMod = mod + attrBonus + bonus + knowledgeBonus
let formula = `1d20`
if (totalMod > 0) formula += ` + ${totalMod}`
else if (totalMod < 0) formula += ` - ${Math.abs(totalMod)}`
const roll = new this(formula, {}, {
attributeKey: options.attributeKey,
rollName: attrLabel,
modifier: mod + attrBonus,
attributeBonus: attrBonus,
bonus,
knowledgeBonus,
dc,
actorId: options.actorId,
actorName: options.actorName,
actorImage: options.actorImage
})
await roll.evaluate()
// Compute degree of success when a DC is known
if (dc !== undefined) {
const d20Value = roll.dice[0]?.results[0]?.result ?? 0
roll.options.outcome = AwERoll.computeOutcome(roll.total, dc, d20Value)
}
await roll.toMessage({
speaker: ChatMessage.getSpeaker({ actor: game.actors.get(options.actorId) }),
flavor: attrLabel,
rollMode
})
return roll
}
/** @override — render the custom chat message template */
async render(chatOptions = {}) {
const isPrivate = chatOptions.isPrivate ?? false
return foundry.applications.handlebars.renderTemplate(
AwERoll.CHAT_TEMPLATE,
{
flavor: this.rollName,
total: this.total,
modifier: this.modifier,
bonus: this.bonus,
knowledgeBonus: this.knowledgeBonus,
dice: this.dice,
outcome: isPrivate ? null : this.outcome,
dc: this.dc,
actorName: this.actorName,
actorImage: this.actorImage,
isPrivate
}
)
}
}