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: `

${f("MGNE.RollDialog.OmenMaximizePrompt", { omens: actorOmens })}

`, 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) } }