Files

255 lines
10 KiB
JavaScript
Raw Permalink 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 }
get sourceItemName() { return this.options.sourceItemName }
get sourceItemImg() { return this.options.sourceItemImg }
get damageFormula() { return this.options.damageFormula }
get damageType() { return this.options.damageType }
get damageResult() { return this.options.damageResult }
get damageCritical() { return this.options.damageCritical ?? false }
get conditionBonus() { return this.options.conditionBonus ?? 0 }
get conditionLabels() { return this.options.conditionLabels ?? [] }
get rollTwice() { return this.options.rollTwice ?? "" }
// --- 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 + (options.conditionBonus ?? 0)
const el = dialog.element
const bonusSelect = el.querySelector('#awe-bonus')
const knowledgeSel = el.querySelector('#awe-knowledge')
const rollTwiceSel = el.querySelector('#awe-roll-twice')
const preview = el.querySelector('#awe-formula-preview')
const diceExprEl = el.querySelector('#awe-dice-expr')
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)
const mode = rollTwiceSel?.value
const dice = mode === 'higher' ? '2d20kh1' : mode === 'lower' ? '2d20kl1' : '1d20'
if (diceExprEl) diceExprEl.textContent = dice
preview.textContent = total === 0 ? dice : `${dice} ${sign} ${abs}`
}
bonusSelect?.addEventListener('change', updatePreview)
knowledgeSel?.addEventListener('change', updatePreview)
rollTwiceSel?.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")
const rollTwice = result.rollTwice ?? ""
// Dice expression based on roll-twice mode
const diceExpr = rollTwice === 'higher' ? '2d20kh1'
: rollTwice === 'lower' ? '2d20kl1'
: '1d20'
// Formula: {diceExpr} + (mod + attrBonus) [± bonus] [± knowledgeBonus] [± conditionBonus]
const totalMod = mod + attrBonus + bonus + knowledgeBonus + (options.conditionBonus ?? 0)
let formula = diceExpr
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,
conditionBonus: options.conditionBonus ?? 0,
conditionLabels: options.conditionLabels ?? [],
rollTwice,
dc,
actorId: options.actorId,
actorName: options.actorName,
actorImage: options.actorImage,
sourceItemName: options.sourceItemName,
sourceItemImg: options.sourceItemImg,
damageFormula: options.damageFormula,
damageType: options.damageType
})
await roll.evaluate()
// Compute degree of success when a DC is known
// Use the *kept* die result (active:true) for nat-20/nat-1 adjustment
if (dc !== undefined) {
const d20Value = roll.dice[0]?.results.find(r => r.active)?.result ?? 0
roll.options.outcome = AwERoll.computeOutcome(roll.total, dc, d20Value)
}
// If this is a weapon attack and it hit, roll damage
if (options.damageFormula && roll.options.outcome) {
const isHit = roll.options.outcome === "success" || roll.options.outcome === "criticalSuccess"
const isCrit = roll.options.outcome === "criticalSuccess"
if (isHit) {
// Double the dice count on critical success
const formula = isCrit
? options.damageFormula.replace(/(\d+)d(\d+)/gi, (_, n, d) => `${Number(n) * 2}d${d}`)
: options.damageFormula
const dmgRoll = new Roll(formula)
await dmgRoll.evaluate()
roll.options.damageResult = dmgRoll.total
roll.options.damageCritical = isCrit
roll.options.damageType = options.damageType
}
}
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,
conditionBonus: isPrivate ? null : this.conditionBonus,
conditionLabels: this.conditionLabels,
dice: this.dice,
rollTwice: this.rollTwice,
outcome: isPrivate ? null : this.outcome,
dc: this.dc,
actorId: this.actorId,
actorName: this.actorName,
actorImage: this.actorImage,
sourceItemName: this.sourceItemName,
sourceItemImg: this.sourceItemImg,
damageFormula: this.damageFormula,
damageResult: isPrivate ? null : this.damageResult,
damageCritical: this.damageCritical,
damageType: this.damageType,
isPrivate
}
)
}
}