Files
fvtt-machine-gods-noxian-ex…/module/documents/actor.mjs
T

409 lines
13 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 rollArmorSave() {
return MGNERoll.rollArmorSave(this)
}
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"))
// Creatures have no ability scores; ability value defaults to 0 via roll.mjs
const result = await MGNERoll.promptCheck({
actor: this,
abilityId: "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?.blocked) {
ui.notifications.warn(f("MGNE.Notification.ResonationBlocked", { actor: this.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) {
const feedbackRoll = await (new Roll("1d2")).evaluate()
await this.applyDamage(feedbackRoll.total, { sourceItem: item, ignoreArmor: true, chat: true })
await this.update({ "system.resonance.blocked": true })
ui.notifications.warn(f("MGNE.Notification.ResonationFeedbackBlocked", { actor: this.name }))
}
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 rollDurability(itemId) {
const item = this.items.get(itemId)
if (!item) return null
return MGNERoll.rollDurability(item)
}
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,
"system.resonance.blocked": false,
})
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.resonance.blocked": false,
"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 }),
}
}
}