Files
fvtt-machine-gods-noxian-ex…/module/documents/roll.mjs
T
2026-05-08 23:05:29 +02:00

379 lines
14 KiB
JavaScript

import { SYSTEM, SYSTEM_ID } from "../config/system.mjs"
const t = key => game.i18n.localize(key)
const f = (key, data = {}) => game.i18n.format(key, data)
function joinParts(parts) {
return parts.filter(Boolean).join(" ")
}
function getChatModeLabel(mode) {
return game.i18n.localize(`MGNE.Chat.Mode.${mode}`) || mode
}
function pickRandom(list = []) {
if (!list.length) return ""
return list[Math.floor(Math.random() * list.length)]
}
function stepDownDie(die, steps = 1, track = SYSTEM.usageDice) {
let current = die
for (let step = 0; step < steps; step += 1) {
const index = track.indexOf(current)
if (index === -1) return current
current = track[Math.min(index + 1, track.length - 1)]
}
return current
}
function getFirstTargetActor() {
const target = game.user?.targets ? Array.from(game.user.targets)[0] : null
return target?.actor ?? null
}
function serializeForm(form) {
return Array.from(form.elements).reduce((acc, element) => {
if (!element.name) return acc
acc[element.name] = element.type === "checkbox" ? element.checked : element.value
return acc
}, {})
}
function numericOptions(min, max, current = null) {
const resolvedMin = Math.min(min, Number.isFinite(current) ? current : min)
const resolvedMax = Math.max(max, Number.isFinite(current) ? current : max)
return Array.from({ length: (resolvedMax - resolvedMin) + 1 }, (_, index) => {
const value = resolvedMin + index
return { value, label: String(value) }
})
}
async function renderCard(context) {
const outcomeClass = `${context.outcome ?? ""}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
const eyebrow = context.eyebrow ?? getChatModeLabel(context.mode ?? "generic")
const normalizedEyebrow = `${eyebrow}`.trim().toLowerCase()
const normalizedLabel = `${context.label ?? ""}`.trim().toLowerCase()
return foundry.applications.handlebars.renderTemplate(`systems/${SYSTEM_ID}/templates/chat-message.hbs`, {
...context,
modeClass: context.mode ?? "generic",
eyebrow: "",
outcomeClass,
})
}
export default class MGNERoll {
static async promptCheck({ actor, abilityId, label, baseDR = 12, rollType = "check", item = null }) {
const abilityLabel = SYSTEM.abilities[abilityId]?.label ?? abilityId
const content = await foundry.applications.handlebars.renderTemplate(`systems/${SYSTEM_ID}/templates/roll-dialog.hbs`, {
actorName: actor.name,
label,
abilityLabel,
baseDR,
drOptions: numericOptions(6, 20, baseDR),
modifierOptions: numericOptions(-6, 6, 0),
omens: actor.system.omens?.current ?? 0,
rollType,
})
const dialogData = await foundry.applications.api.DialogV2.wait({
window: { title: label },
classes: ["mgne", "roll-dialog"],
content,
buttons: [
{
label: t("MGNE.Common.Roll"),
icon: "fa-solid fa-dice-d20",
callback: (_event, button) => serializeForm(button.form),
},
],
rejectClose: false,
})
if (!dialogData) return null
const modifier = Number.parseInt(dialogData.modifier ?? 0, 10) || 0
const spendOmen = Boolean(dialogData.spendOmen)
// Re-read omens after dialog close to avoid race condition (omen could have changed)
const currentOmensAfterDialog = actor.system.omens?.current ?? 0
const canSpendOmen = spendOmen && currentOmensAfterDialog > 0
const dr = (Number.parseInt(dialogData.dr ?? baseDR, 10) || baseDR) - (canSpendOmen ? 4 : 0)
const abilityValue = actor.system.abilities?.[abilityId]?.value ?? 0
const sign = modifier >= 0 ? "+" : "-"
const formula = modifier === 0 ? `1d20 + ${abilityValue}` : `1d20 + ${abilityValue} ${sign} ${Math.abs(modifier)}`
const roll = await (new Roll(formula)).evaluate()
const natural = roll.dice?.[0]?.results?.[0]?.result ?? roll.total
if (canSpendOmen) {
await actor.update({ "system.omens.current": Math.max(0, currentOmensAfterDialog - 1) })
}
const critical = natural === 20
const fumble = natural === 1
const success = critical || (!fumble && roll.total >= dr)
const outcome = critical ? t("MGNE.Roll.OutcomeCriticalSuccess") : fumble ? t("MGNE.Roll.OutcomeFumble") : success ? t("MGNE.Roll.OutcomeSuccess") : t("MGNE.Roll.OutcomeFailure")
let specialText = ""
if (critical && rollType === "resonance") specialText = pickRandom(SYSTEM.tables.eucatastrophe)
else if (fumble && rollType === "resonance") specialText = pickRandom(SYSTEM.tables.catastrophe)
else if (critical) {
specialText = rollType === "attack"
? t("MGNE.Roll.CriticalAttack")
: rollType === "defense"
? t("MGNE.Roll.DefenseCritical")
: pickRandom(SYSTEM.tables.triumphs)
} else if (fumble) {
specialText = rollType === "attack" ? t("MGNE.Roll.AttackFumble") : rollType === "defense" ? t("MGNE.Roll.DefenseFumble") : pickRandom(SYSTEM.tables.mishaps)
}
const actorOmens = actor.system.omens?.current ?? 0
let omenNeutralizeReminder = ""
let omenRerollReminder = ""
if (actorOmens > 0) {
if (critical) omenNeutralizeReminder = f("MGNE.Roll.OmenNeutralizeCrit", { omens: actorOmens })
else if (fumble) omenNeutralizeReminder = f("MGNE.Roll.OmenNeutralizeFumble", { omens: actorOmens })
else omenRerollReminder = f("MGNE.Roll.OmenRerollReminder", { omens: actorOmens })
}
const showDamageButton = rollType === "attack" && (success || critical) && !!item
const contentHtml = await renderCard({
mode: "check",
actorName: actor.name,
actorImg: actor.img,
label,
subtitle: f("MGNE.Roll.CheckSubtitle", { ability: abilityLabel, dr }),
formula: roll.formula,
total: roll.total,
outcome,
specialText,
omenNeutralizeReminder,
omenRerollReminder,
showDamageButton,
damageActorId: showDamageButton ? actor.id : null,
damageItemId: showDamageButton ? item.id : null,
damageFormula: showDamageButton ? (item.system.damage || "1") : null,
damageCritical: showDamageButton && critical,
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
content: contentHtml,
})
return { roll, success, critical, fumble, outcome, specialText, dr, abilityId, item }
}
static async rollMorale(actor) {
const target = actor.system.morale ?? 7
const roll = await (new Roll("2d6")).evaluate()
const broken = roll.total >= target
const contentHtml = await renderCard({
mode: "morale",
actorName: actor.name,
actorImg: actor.img,
label: t("MGNE.Roll.MoraleCheck"),
subtitle: f("MGNE.Roll.TargetSubtitle", { target }),
formula: roll.formula,
total: roll.total,
outcome: broken ? t("MGNE.Roll.OutcomeBroken") : t("MGNE.Roll.OutcomeSteady"),
specialText: broken ? t("MGNE.Roll.MoraleBrokenText") : "",
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
content: contentHtml,
})
return { roll, broken }
}
static async rollDamage({ actor, item }) {
const damageBonus = await actor.consumePendingDamageBonus(item.id)
const multiplier = damageBonus?.multiplier ?? 1
const baseFormula = item.system.damage || "1"
const formula = multiplier > 1 ? `${multiplier} * (${baseFormula})` : baseFormula
const actorOmens = actor.system.omens?.current ?? 0
let maximize = false
if (actorOmens > 0) {
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: f("MGNE.Roll.ItemDamageLabel", { item: item.name }) },
classes: ["mgne", "roll-dialog"],
content: `<section class="mgne-roll-dialog"><p>${f("MGNE.RollDialog.OmenMaximizePrompt", { omens: actorOmens })}</p></section>`,
buttons: [
{ action: "roll", label: t("MGNE.Common.Roll"), icon: "fa-solid fa-dice", callback: () => "roll" },
{ action: "maximize", label: t("MGNE.RollDialog.SpendOmenMaximize"), icon: "fa-solid fa-star", callback: () => "maximize" },
],
rejectClose: false,
})
if (choice === null) return null
maximize = choice === "maximize"
}
const roll = await (new Roll(formula)).evaluate(maximize ? { maximize: true } : {})
if (maximize) {
// Re-read omens after dialog to avoid overwriting concurrent changes
const currentOmens = actor.system.omens?.current ?? 0
await actor.update({ "system.omens.current": Math.max(0, currentOmens - 1) })
}
const isCritical = multiplier > 1
const contentHtml = await renderCard({
mode: "damage",
actorName: actor.name,
actorImg: actor.img,
label: `${item.name} Damage`,
subtitle: null,
formula: roll.formula,
total: roll.total,
outcome: t("MGNE.Roll.OutcomeRolled"),
specialText: isCritical ? t("MGNE.Roll.CriticalDamageApplied") : "",
omenMaximized: maximize,
showApplyButton: true,
damageTotal: roll.total,
damageCritical: isCritical,
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
content: contentHtml,
})
return { roll }
}
static async rollFlatDamage({ actor, label, formula }) {
const damageBonus = await actor.consumePendingDamageBonus("profile-attack")
const multiplier = damageBonus?.multiplier ?? 1
const baseFormula = formula || "1"
const resolvedFormula = multiplier > 1 ? `${multiplier} * (${baseFormula})` : baseFormula
const roll = await (new Roll(resolvedFormula)).evaluate()
const isCritical = multiplier > 1
const contentHtml = await renderCard({
mode: "damage",
actorName: actor.name,
actorImg: actor.img,
label,
subtitle: null,
formula: roll.formula,
total: roll.total,
outcome: t("MGNE.Roll.OutcomeRolled"),
specialText: isCritical ? t("MGNE.Roll.CriticalDamageApplied") : "",
showApplyButton: true,
damageTotal: roll.total,
damageCritical: isCritical,
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
content: contentHtml,
})
return { roll }
}
static async rollUsage(item) {
const currentDie = item.system.usageDie
if (!currentDie || currentDie === "depleted") {
ui.notifications.warn(f("MGNE.Notification.ItemDepleted", { item: item.name }))
return null
}
const roll = await (new Roll(`1${currentDie}`)).evaluate()
const depleted = roll.total <= 2
const nextDie = depleted ? stepDownDie(currentDie) : currentDie
const updates = { "system.usageDie": nextDie }
if (item.type === "resonance-core" && nextDie === "depleted") updates["system.burnedOut"] = true
await item.update(updates)
const contentHtml = await renderCard({
mode: "usage",
actorName: item.parent?.name ?? item.name,
actorImg: item.img,
label: f("MGNE.Roll.ItemUsageLabel", { item: item.name }),
subtitle: f("MGNE.Roll.CurrentDie", { die: currentDie.toUpperCase() }),
formula: roll.formula,
total: roll.total,
outcome: depleted ? f("MGNE.Roll.DowngradedTo", { die: nextDie.toUpperCase() }) : t("MGNE.Roll.NoChange"),
specialText: depleted && nextDie === "depleted" ? t("MGNE.Roll.ItemNowDepleted") : "",
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: item.parent ?? null }),
rolls: [roll],
content: contentHtml,
})
return { roll, depleted, nextDie }
}
static async applyDamageCard({ actor, sourceActor = null, sourceItem = null, amount, armorRoll = null, appliedDamage, newHp, breakText = "", defenseFumbleText = "", criticalArmorText = "" }) {
const contentHtml = await renderCard({
mode: "apply-damage",
actorName: actor.name,
actorImg: actor.img,
label: f("MGNE.Roll.TakesDamageLabel", { actor: actor.name }),
subtitle: sourceItem
? (sourceActor
? f("MGNE.Roll.DamageSourceWithActor", { item: sourceItem.name, actor: sourceActor.name })
: f("MGNE.Roll.DamageSourceItem", { item: sourceItem.name }))
: t("MGNE.Roll.DirectDamage"),
formula: armorRoll?.formula ?? "",
total: amount,
outcome: f("MGNE.Roll.HPNow", { hp: newHp }),
specialText: joinParts([
defenseFumbleText,
armorRoll ? f("MGNE.Roll.ArmorAbsorbed", { amount: armorRoll.total }) : "",
f("MGNE.Roll.AppliedDamageText", { amount: appliedDamage }),
criticalArmorText,
breakText ? f("MGNE.Roll.BreakText", { text: breakText }) : "",
]),
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: armorRoll ? [armorRoll] : [],
content: contentHtml,
})
}
static async createRestCard({ actor, label, subtitle, roll, outcome, specialText = "" }) {
return this.createActionCard({ mode: "rest", actor, label, subtitle, roll, outcome, specialText })
}
static async createActionCard({ mode = "action", actor = null, label, subtitle = "", roll, outcome, specialText = "" }) {
const contentHtml = await renderCard({
mode,
actorName: actor?.name ?? "",
actorImg: actor?.img ?? "",
label,
subtitle,
formula: roll.formula,
total: roll.total,
outcome,
specialText,
})
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
content: contentHtml,
})
}
static getFirstTargetActor() {
return getFirstTargetActor()
}
static stepDownDie(die, steps = 1, track = SYSTEM.usageDice) {
return stepDownDie(die, steps, track)
}
}