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