System development, WIP

This commit is contained in:
2026-05-05 13:55:42 +02:00
commit c0223977d2
250 changed files with 10362 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export { default as MGNEActor } from "./actor.mjs"
export { default as MGNECombat } from "./combat.mjs"
export { default as MGNEItem } from "./item.mjs"
export { default as MGNERoll } from "./roll.mjs"
+386
View File
@@ -0,0 +1,386 @@
import MGNERoll from "./roll.mjs"
import { SYSTEM, SYSTEM_ID } from "../config/system.mjs"
const t = key => game.i18n.localize(key)
const f = (key, data = {}) => game.i18n.format(key, data)
const PENDING_DAMAGE_FLAG = "pendingDamageBonus"
const PENDING_DEFENSE_FLAG = "pendingDefenseFumble"
function formatActionLabel(baseLabel, actionLabel) {
const trimmedBase = `${baseLabel ?? ""}`.trim()
if (!trimmedBase) return actionLabel
return trimmedBase.toLowerCase().endsWith(actionLabel.toLowerCase()) ? trimmedBase : `${trimmedBase} ${actionLabel}`
}
function normalizeGenericActionLabel(baseLabel, genericLabel) {
const trimmedBase = `${baseLabel ?? ""}`.trim()
if (!trimmedBase) return genericLabel
const normalizedWords = trimmedBase
.split(/\s+/)
.filter(Boolean)
.map(word => word.toLowerCase())
return normalizedWords.every(word => word === genericLabel.toLowerCase()) ? genericLabel : trimmedBase
}
function maxDieByRank(items, property) {
const rank = { d12: 0, d10: 1, d8: 2, d6: 3, d4: 4, d2: 5, 0: 6, depleted: 7 }
return [...items].sort((left, right) => (rank[left.system[property]] ?? 99) - (rank[right.system[property]] ?? 99))[0] ?? null
}
export default class MGNEActor extends Actor {
async setPendingDamageBonus(contextId, multiplier = 2) {
await this.setFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG, { contextId, multiplier })
}
async consumePendingDamageBonus(contextId) {
const bonus = this.getFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG)
if (!bonus || bonus.contextId !== contextId) return null
await this.unsetFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG)
return bonus
}
async setPendingDefenseFumble() {
await this.setFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG, { active: true })
}
async consumePendingDefenseFumble() {
const pending = this.getFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG)
if (!pending?.active) return null
await this.unsetFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG)
return pending
}
getEquippedArmorItems() {
return this.items.filter(item => ["armor", "shield"].includes(item.type) && item.system.equipped && !item.system.broken && item.system.armorDie !== "0")
}
getArmorRollFormula() {
const dice = this.getEquippedArmorItems().map(item => `1${item.system.armorDie}`)
return dice.length ? dice.join(" + ") : "0"
}
async degradeArmorStep(steps = 1) {
const item = maxDieByRank(this.getEquippedArmorItems(), "armorDie")
if (!item) return null
const nextDie = MGNERoll.stepDownDie(item.system.armorDie, steps, SYSTEM.armorDice)
const updates = { "system.armorDie": nextDie }
if (nextDie === "0") {
updates["system.broken"] = true
updates["system.equipped"] = false
}
await item.update(updates)
return { item, nextDie }
}
async rollAbility(abilityId, options = {}) {
return MGNERoll.promptCheck({
actor: this,
abilityId,
label: options.label ?? f("MGNE.Roll.CheckLabel", { ability: SYSTEM.abilities[abilityId]?.label ?? abilityId }),
baseDR: options.baseDR ?? 12,
rollType: options.rollType ?? "check",
item: options.item ?? null,
})
}
async rollDefense() {
const result = await this.rollAbility("agility", { label: t("MGNE.Roll.DefenseCheck"), rollType: "defense" })
if (result?.fumble) await this.setPendingDefenseFumble()
return result
}
async rollWeapon(itemId) {
const item = this.items.get(itemId)
if (!item) return null
if (item.system.broken) {
ui.notifications.warn(f("MGNE.Notification.ItemBroken", { item: item.name }))
return null
}
const abilityId = item.system.category === "ranged" ? "presence" : "strength"
const result = await this.rollAbility(abilityId, {
label: f("MGNE.Roll.ItemAttackLabel", { item: item.name }),
rollType: "attack",
item,
})
if (!result) return null
if (result.fumble) await item.update({ "system.broken": true })
if (result.critical) {
await this.setPendingDamageBonus(item.id)
}
return result
}
async rollDamage(itemId) {
const item = this.items.get(itemId)
if (!item) return null
return MGNERoll.rollDamage({
actor: this,
item,
targetActor: MGNERoll.getFirstTargetActor(),
})
}
async rollProfileAttack() {
const attackBaseLabel = normalizeGenericActionLabel(this.system.attack?.label ?? t("MGNE.Common.Attack"), t("MGNE.Common.Attack"))
const attackLabel = formatActionLabel(attackBaseLabel, t("MGNE.Common.Attack"))
const result = await this.rollAbility("strength", {
label: attackLabel,
rollType: "attack",
})
if (result?.critical) {
await this.setPendingDamageBonus("profile-attack")
}
return result
}
async rollProfileDamage() {
const attackBaseLabel = normalizeGenericActionLabel(this.system.attack?.label ?? t("MGNE.Common.Attack"), t("MGNE.Common.Attack"))
const damageLabel = attackBaseLabel === t("MGNE.Common.Attack")
? t("MGNE.Common.Damage")
: formatActionLabel(attackBaseLabel, t("MGNE.Common.Damage"))
return MGNERoll.rollFlatDamage({
actor: this,
label: damageLabel,
formula: this.system.attack?.damage ?? "1",
targetActor: MGNERoll.getFirstTargetActor(),
})
}
async rollResonancePerDay({ resetUsed = true, apply = true } = {}) {
const resonanceRoll = await (new Roll("1d4")).evaluate()
const presence = this.system.abilities?.presence?.value ?? 0
const max = Math.max(1, presence + resonanceRoll.total)
const updates = { "system.resonance.max": max }
if (resetUsed) updates["system.resonance.used"] = 0
if (apply) await this.update(updates)
return { resonanceRoll, max }
}
async rollResonation(itemId) {
const item = this.items.get(itemId)
if (!item || item.type !== "resonance-core") return null
if (item.system.burnedOut || item.system.usageDie === "depleted") {
ui.notifications.warn(f("MGNE.Notification.ItemBurnedOut", { item: item.name }))
return null
}
if ((this.system.resonance?.used ?? 0) >= (this.system.resonance?.max ?? 0)) {
ui.notifications.warn(f("MGNE.Notification.ResonancePerDayReached", { actor: this.name }))
return null
}
const result = await this.rollAbility("presence", {
label: f("MGNE.Roll.InvocationLabel", { item: item.name }),
rollType: "resonance",
item,
})
if (!result) return null
await this.update({ "system.resonance.used": (this.system.resonance.used ?? 0) + 1 })
if (!result.success) {
await this.applyDamage(1, { sourceItem: item, ignoreArmor: true, chat: false })
}
return result
}
async rollMorale() {
return MGNERoll.rollMorale(this)
}
async toggleItemEquipped(itemId) {
const item = this.items.get(itemId)
if (!item) return null
if (item.system.broken) {
ui.notifications.warn(f("MGNE.Notification.ItemBroken", { item: item.name }))
return null
}
return item.update({ "system.equipped": !item.system.equipped })
}
async rollUsage(itemId) {
const item = this.items.get(itemId)
if (!item) return null
return item.rollUsage()
}
async quickRest() {
const roll = await (new Roll("1d4")).evaluate()
const hp = this.system.hp?.value ?? 0
const hpMax = this.system.hp?.max ?? hp
const healed = Math.max(0, Math.min(hpMax, hp + roll.total) - hp)
const newHp = Math.min(hpMax, hp + roll.total)
await this.update({ "system.hp.value": newHp })
await MGNERoll.createRestCard({
actor: this,
label: f("MGNE.Roll.QuickRestLabel", { actor: this.name }),
subtitle: t("MGNE.Character.QuickRestHelp"),
roll,
outcome: f("MGNE.Roll.RestoredHP", { amount: healed }),
specialText: f("MGNE.Roll.HPNowMax", { hp: newHp, max: hpMax }),
})
return { roll, healed, newHp }
}
async fullRest() {
const hp = this.system.hp?.value ?? 0
const hpMax = this.system.hp?.max ?? hp
const healRoll = await (new Roll("1d6")).evaluate()
const omenDie = this.system.omens?.die ?? "d2"
const omenRoll = await (new Roll(`1${omenDie}`)).evaluate()
const healed = Math.max(0, Math.min(hpMax, hp + healRoll.total) - hp)
const newHp = Math.min(hpMax, hp + healRoll.total)
await this.update({
"system.hp.value": newHp,
"system.omens.current": omenRoll.total,
})
await MGNERoll.createRestCard({
actor: this,
label: f("MGNE.Roll.FullRestLabel", { actor: this.name }),
subtitle: t("MGNE.Character.FullRestHelp"),
roll: healRoll,
outcome: f("MGNE.Roll.RestoredHP", { amount: healed }),
specialText: `${f("MGNE.Roll.HPNowMax", { hp: newHp, max: hpMax })} ${f("MGNE.Roll.OmensReset", {
omens: omenRoll.total,
die: omenDie.toUpperCase(),
roll: omenRoll.total,
})}`,
})
return { healRoll, omenRoll, healed, newHp, omens: omenRoll.total }
}
async syncArtifact(itemId) {
const item = this.items.get(itemId)
if (!item || item.type !== "artifact") return null
if (item.system.synchronized) {
return item.update({
"system.synchronized": false,
"system.synchronizedTo": "",
})
}
const syncLimit = Math.max(0, this.system.syncLimit ?? this.system.abilities?.toughness?.value ?? 0)
const used = this.system.artifactSync?.used ?? 0
if (used >= syncLimit) {
ui.notifications.warn(f("MGNE.Notification.CannotSyncMore", { actor: this.name }))
return null
}
await this.update({ "system.artifactSync.used": used + 1 })
return item.update({
"system.synchronized": true,
"system.synchronizedTo": this.id,
})
}
async resetDaily() {
const omenDie = this.system.omens?.die ?? "d2"
const omenRoll = await (new Roll(`1${omenDie}`)).evaluate()
const { max: resonanceMax } = await this.rollResonancePerDay({ resetUsed: false, apply: false })
await this.update({
"system.omens.current": omenRoll.total,
"system.resonance.max": resonanceMax,
"system.resonance.used": 0,
"system.artifactSync.used": 0,
"system.survival.salvationUsed": false,
})
const syncedArtifacts = this.items.filter(item => item.type === "artifact" && item.system.synchronized)
for (const artifact of syncedArtifacts) {
await artifact.update({
"system.synchronized": false,
"system.synchronizedTo": "",
})
}
}
async applyDamage(amount, { sourceActor = null, sourceItem = null, ignoreArmor = false, critical = false, chat = true } = {}) {
const defenseFumble = await this.consumePendingDefenseFumble()
const incomingDamage = defenseFumble ? amount * 2 : amount
const hp = this.system.hp?.value ?? 0
let armorRoll = null
let absorbed = 0
if (!ignoreArmor) {
const formula = this.getArmorRollFormula()
if (formula !== "0") {
armorRoll = await (new Roll(formula)).evaluate()
absorbed = armorRoll.total
}
}
let appliedDamage = Math.max(0, incomingDamage - absorbed)
let nextHp = hp - appliedDamage
let breakText = ""
let defenseFumbleText = ""
let criticalArmorText = ""
if (this.type === "character" && hp > 0 && nextHp < 0 && !(this.system.survival?.salvationUsed)) {
nextHp = 0
appliedDamage = hp
await this.update({ "system.survival.salvationUsed": true })
}
if (nextHp <= 0 && hp > 0) {
const breakRoll = await (new Roll("1d4")).evaluate()
breakText = SYSTEM.tables.breakResults[breakRoll.total] ?? ""
}
await this.update({ "system.hp.value": nextHp })
if (defenseFumble) {
const degraded = await this.degradeArmorStep()
defenseFumbleText = degraded
? f("MGNE.Roll.DefenseFumbleApplied", { item: degraded.item.name, die: degraded.nextDie.toUpperCase() })
: t("MGNE.Roll.DefenseFumbleNoArmor")
}
if (critical) {
const degraded = await this.degradeArmorStep()
criticalArmorText = degraded
? f("MGNE.Roll.ArmorDegradedCritical", { item: degraded.item.name, die: degraded.nextDie.toUpperCase() })
: t("MGNE.Roll.ArmorNothingToDegrade")
}
if (chat) {
await MGNERoll.applyDamageCard({
actor: this,
sourceActor,
sourceItem,
amount: incomingDamage,
armorRoll,
appliedDamage,
newHp: nextHp,
breakText,
defenseFumbleText,
criticalArmorText,
})
}
return {
amount: incomingDamage,
absorbed,
appliedDamage,
newHp: nextHp,
breakText,
defenseFumbleText,
criticalArmorText,
summary: f("MGNE.Chat.DamageSummary", { absorbed, appliedDamage, hp: nextHp }),
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import MGNERoll from "./roll.mjs"
import { SYSTEM_ID } from "../config/system.mjs"
const t = key => game.i18n.localize(key)
const f = (key, data = {}) => game.i18n.format(key, data)
const INITIATIVE_FLAG = "initiativeState"
const SIDE_BONUS = 20
const FLEE_BASE_TARGET = 5
const FLEE_MIN_TARGET = 2
function isPlayerSide(combatant) {
return Boolean(combatant?.actor?.hasPlayerOwner)
}
function fleeDialogContent() {
const options = Array.from({ length: FLEE_BASE_TARGET - FLEE_MIN_TARGET + 1 }, (_, index) => {
const value = index
return `<option value="${value}">${value}</option>`
}).join("")
return `
<form class="mgne-flee-dialog">
<div class="form-group">
<label>${t("MGNE.Combat.StudyActions")}</label>
<select name="studyActions">${options}</select>
<p class="notes">${t("MGNE.Combat.StudyHelp")}</p>
</div>
</form>
`
}
export default class MGNECombat extends Combat {
getFleeCombatant(combatantId = null) {
if (combatantId) return this.combatants.get(combatantId) ?? null
const controlledCombatant = canvas.tokens?.controlled?.[0]?.combatant ?? null
return this.combatant ?? controlledCombatant
}
async getInitiativeState() {
const existing = this.getFlag(SYSTEM_ID, INITIATIVE_FLAG)
if (existing && Number.isFinite(existing.sideRoll)) return existing
const sideRoll = await (new Roll("1d6")).evaluate()
const initiativeState = {
sideRoll: sideRoll.total,
playersActFirst: sideRoll.total >= 4,
}
await this.setFlag(SYSTEM_ID, INITIATIVE_FLAG, initiativeState)
await ChatMessage.create({
speaker: ChatMessage.getSpeaker(),
content: `<p>${f("MGNE.Initiative.SideRoll", { roll: initiativeState.sideRoll })}</p><p>${t(initiativeState.playersActFirst ? "MGNE.Initiative.PlayersFirst" : "MGNE.Initiative.EnemiesFirst")}</p><p>${t("MGNE.Initiative.TieBreak")}</p>`,
})
return initiativeState
}
async rollInitiative(ids, { updateTurn = true } = {}) {
const combatantIds = typeof ids === "string" ? [ids] : Array.from(ids ?? [])
if (!combatantIds.length) return this
const currentCombatantId = this.combatant?.id ?? null
const initiativeState = await this.getInitiativeState()
const updates = []
for (const id of combatantIds) {
const combatant = this.combatants.get(id)
if (!combatant) continue
const agility = combatant.actor?.system?.abilities?.agility?.value ?? 0
const roll = await (new Roll("1d6 + @agility", { agility })).evaluate()
const actsWithPriority = isPlayerSide(combatant) === initiativeState.playersActFirst
updates.push({
_id: id,
initiative: roll.total + (actsWithPriority ? SIDE_BONUS : 0),
})
}
if (updates.length) await this.updateEmbeddedDocuments("Combatant", updates)
if (updateTurn && currentCombatantId) {
const turn = this.turns.findIndex(combatant => combatant.id === currentCombatantId)
if (turn >= 0) await this.update({ turn })
}
return this
}
async rollFlee({ combatantId = null } = {}) {
const combatant = this.getFleeCombatant(combatantId)
if (!combatant?.actor) {
ui.notifications.warn(t("MGNE.Combat.FleeNoCombatant"))
return null
}
const dialogData = await foundry.applications.api.DialogV2.wait({
window: { title: t("MGNE.Combat.Flee") },
classes: ["mgne", "roll-dialog"],
content: fleeDialogContent(),
buttons: [{
label: t("MGNE.Combat.Flee"),
icon: "fa-solid fa-person-running",
callback: (_event, button) => {
const value = Number.parseInt(button.form?.elements.studyActions?.value ?? "0", 10)
return { studyActions: Math.max(0, Math.min(FLEE_BASE_TARGET - FLEE_MIN_TARGET, value || 0)) }
},
}],
rejectClose: false,
})
if (!dialogData) return null
const studyActions = dialogData.studyActions ?? 0
const target = Math.max(FLEE_MIN_TARGET, FLEE_BASE_TARGET - studyActions)
const roll = await (new Roll("1d6")).evaluate()
const escaped = roll.total >= target
if (escaped) {
await combatant.delete()
} else {
await combatant.actor.update({ "system.hp.value": 0 })
await combatant.update({ defeated: true })
}
await MGNERoll.createActionCard({
mode: "flee",
actor: combatant.actor,
label: f("MGNE.Roll.FleeLabel", { actor: combatant.name }),
subtitle: f("MGNE.Roll.FleeSubtitle", { target }),
roll,
outcome: t(escaped ? "MGNE.Roll.FleeEscaped" : "MGNE.Roll.FleeKilled"),
specialText: studyActions > 0
? f("MGNE.Roll.FleeStudyActions", { count: studyActions })
: t("MGNE.Roll.FleeNoStudyActions"),
})
return { combatant, roll, escaped, target, studyActions }
}
}
+17
View File
@@ -0,0 +1,17 @@
import MGNERoll from "./roll.mjs"
import { SYSTEM } from "../config/system.mjs"
export default class MGNEItem extends Item {
prepareBaseData() {
super.prepareBaseData()
const fallbackIcon = SYSTEM.itemTypes[this.type]?.icon
if (!fallbackIcon) return
if (!this._source.img || this._source.img === "icons/svg/item-bag.svg") {
this.updateSource({ img: fallbackIcon })
}
}
async rollUsage() {
return MGNERoll.rollUsage(this)
}
}
+340
View File
@@ -0,0 +1,340 @@
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)
const dr = (Number.parseInt(dialogData.dr ?? baseDR, 10) || baseDR) - (spendOmen ? 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 (spendOmen && (actor.system.omens?.current ?? 0) > 0) {
await actor.update({ "system.omens.current": Math.max(0, actor.system.omens.current - 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 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,
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 roll = await (new Roll(formula)).evaluate()
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") : "",
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)
}
}