387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
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 }),
|
|
}
|
|
}
|
|
}
|