// SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver RÜmpelein // // SPDX-License-Identifier: MIT import { ModifiableDataBaseTotal } from "../common/common-data"; import { DS4 } from "../config"; import { getGame } from "../helpers"; import { DS4Item } from "../item/item"; import { DS4ArmorDataProperties, DS4ShieldDataProperties } from "../item/item-data-properties"; import { ItemType } from "../item/item-data-source"; import { createCheckRoll } from "../rolls/check-factory"; import { Check } from "./actor-data-properties-base"; import { isAttribute, isTrait } from "./actor-data-source-base"; declare global { interface DocumentClassConfig { Actor: typeof DS4Actor; } } /** * The Actor class for DS4 */ export class DS4Actor extends Actor { /** @override */ prepareData(): void { this.data.reset(); this.prepareBaseData(); this.prepareEmbeddedDocuments(); this.applyActiveEffectsToBaseData(); this.prepareDerivedData(); this.applyActiveEffectsToDerivedData(); this.prepareFinalDerivedData(); } /** @override */ prepareBaseData(): void { const data = this.data; data.data.rolling = { minimumFumbleResult: 20, maximumCoupResult: 1, }; const attributes = data.data.attributes; Object.values(attributes).forEach( (attribute: ModifiableDataBaseTotal) => (attribute.total = attribute.base + attribute.mod), ); const traits = data.data.traits; Object.values(traits).forEach( (trait: ModifiableDataBaseTotal) => (trait.total = trait.base + trait.mod), ); } /** * @override * We override this with an empty implementation because we have our own custom way of applying * {@link ActiveEffect}s and {@link Actor#prepareEmbeddedDocuments} calls this. */ applyActiveEffects(): void { return; } applyActiveEffectsToBaseData(): void { // reset overrides because our variant of applying active effects does not set them, it only adds overrides this.overrides = {}; this.applyActiveEffectsFiltered( (change) => !this.derivedDataProperties.includes(change.key) && !this.finalDerivedDataProperties.includes(change.key), ); } applyActiveEffectsToDerivedData(): void { this.applyActiveEffectsFiltered((change) => this.derivedDataProperties.includes(change.key)); } /** * Apply ActiveEffectChanges to the Actor data which are caused by ActiveEffects and satisfy the given predicate. * * @param predicate - The predicate that ActiveEffectChanges need to satisfy in order to be applied */ applyActiveEffectsFiltered(predicate: (change: foundry.data.ActiveEffectData["changes"][number]) => boolean): void { const overrides: Record = {}; // Organize non-disabled and -surpressed effects by their application priority const changes: (foundry.data.ActiveEffectData["changes"][number] & { effect: ActiveEffect })[] = this.effects.reduce( (changes: (foundry.data.ActiveEffectData["changes"][number] & { effect: ActiveEffect })[], e) => { if (e.data.disabled || e.isSurpressed) return changes; const newChanges = e.data.changes.filter(predicate).flatMap((c) => { const changeSource = c.toObject(); changeSource.priority = changeSource.priority ?? changeSource.mode * 10; return Array(e.factor).fill({ ...changeSource, effect: e }); }); return changes.concat(newChanges); }, [], ); changes.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); // Apply all changes for (const change of changes) { const result = change.effect.apply(this, change); if (result !== null) overrides[change.key] = result; } // Expand the set of final overrides this.overrides = foundry.utils.expandObject({ ...foundry.utils.flattenObject(this.overrides), ...overrides }); } /** * Apply transformations to the Actor data after effects have been applied to the base data. * @override */ prepareDerivedData(): void { this.prepareCombatValues(); this.prepareChecks(); } /** * The list of properties that are derived from others, given in dot notation. */ get derivedDataProperties(): Array { const combatValueProperties = Object.keys(DS4.i18n.combatValues).map( (combatValue) => `data.combatValues.${combatValue}.total`, ); const checkProperties = Object.keys(DS4.i18n.checks) .filter((check) => check !== "defend") .map((check) => `data.checks.${check}`); return combatValueProperties.concat(checkProperties); } /** * Apply final transformations to the Actor data after all effects have been applied. */ prepareFinalDerivedData(): void { Object.values(this.data.data.attributes).forEach( (attribute: ModifiableDataBaseTotal) => (attribute.total = Math.ceil(attribute.total)), ); Object.values(this.data.data.traits).forEach( (trait: ModifiableDataBaseTotal) => (trait.total = Math.ceil(trait.total)), ); Object.entries(this.data.data.combatValues) .filter(([key]) => key !== "movement") .map(([, value]) => value) .forEach( (combatValue: ModifiableDataBaseTotal) => (combatValue.total = Math.ceil(combatValue.total)), ); (Object.keys(this.data.data.checks) as (keyof typeof this.data.data.checks)[]).forEach((key) => { this.data.data.checks[key] = Math.ceil(this.data.data.checks[key]); }); this.data.data.combatValues.hitPoints.max = this.data.data.combatValues.hitPoints.total; this.data.data.checks.defend = this.data.data.combatValues.defense.total; } /** * The list of properties that are completely derived (i.e. {@link ActiveEffect}s cannot be applied to them), * given in dot notation. */ get finalDerivedDataProperties(): string[] { return ["data.combatValues.hitPoints.max", "data.checks.defend"]; } /** * The list of item types that can be owned by this actor. */ get ownableItemTypes(): Array { return ["weapon", "armor", "shield", "equipment", "loot", "spell"]; } /** * Checks whether or not the given item type can be owned by the actor. * @param itemType - The item type to check */ canOwnItemType(itemType: ItemType): boolean { return this.ownableItemTypes.includes(itemType); } /** * Prepares the combat values of the actor. */ protected prepareCombatValues(): void { const data = this.data.data; const armorValueOfEquippedItems = this.calculateArmorValueOfEquippedItems(); const spellMalusOfEquippedItems = this.calculateSpellMaluesOfEquippedItems(); data.combatValues.hitPoints.base = data.attributes.body.total + data.traits.constitution.total + 10; data.combatValues.defense.base = data.attributes.body.total + data.traits.constitution.total + armorValueOfEquippedItems; data.combatValues.initiative.base = data.attributes.mobility.total + data.traits.agility.total; data.combatValues.movement.base = data.attributes.mobility.total / 2 + 1; data.combatValues.meleeAttack.base = data.attributes.body.total + data.traits.strength.total; data.combatValues.rangedAttack.base = data.attributes.mobility.total + data.traits.dexterity.total; data.combatValues.spellcasting.base = data.attributes.mind.total + data.traits.aura.total - spellMalusOfEquippedItems; data.combatValues.targetedSpellcasting.base = data.attributes.mind.total + data.traits.dexterity.total - spellMalusOfEquippedItems; Object.values(data.combatValues).forEach( (combatValue: ModifiableDataBaseTotal) => (combatValue.total = combatValue.base + combatValue.mod), ); } /** * Calculates the total armor value of the equipped items. */ protected calculateArmorValueOfEquippedItems(): number { return this.getEquippedItemsWithArmor() .map((item) => item.data.data.armorValue) .reduce((a, b) => a + b, 0); } /** * Calculates the spell malus from equipped items. */ protected calculateSpellMaluesOfEquippedItems(): number { return this.getEquippedItemsWithArmor() .filter( (item) => !(item.data.type === "armor" && ["cloth", "natural"].includes(item.data.data.armorMaterialType)), ) .map((item) => item.data.data.armorValue) .reduce((a, b) => a + b, 0); } private getEquippedItemsWithArmor() { return this.items .filter( (item): item is DS4Item & { data: DS4ArmorDataProperties | DS4ShieldDataProperties } => item.data.type === "armor" || item.data.type === "shield", ) .filter((item) => item.data.data.equipped); } /** * Prepares the check target numbers of checks for the actor. */ protected prepareChecks(): void { const data = this.data.data; data.checks = { appraise: data.attributes.mind.total + data.traits.intellect.total, changeSpell: data.attributes.mind.total + data.traits.intellect.total, climb: data.attributes.mobility.total + data.traits.strength.total, communicate: data.attributes.mind.total + data.traits.dexterity.total, decipherScript: data.attributes.mind.total + data.traits.intellect.total, defend: 0, // assigned in prepareFinalDerivedData as it must always match data.combatValues.defense.total and is not changeable by effects defyPoison: data.attributes.body.total + data.traits.constitution.total, disableTraps: data.attributes.mind.total + data.traits.dexterity.total, featOfStrength: data.attributes.body.total + data.traits.strength.total, flirt: data.attributes.mind.total + data.traits.aura.total, haggle: data.attributes.mind.total + Math.max(data.traits.intellect.total, data.traits.intellect.total), hide: data.attributes.mobility.total + data.traits.agility.total, identifyMagic: data.attributes.mind.total + data.traits.intellect.total, jump: data.attributes.mobility.total + data.traits.agility.total, knowledge: data.attributes.mind.total + data.traits.intellect.total, openLock: data.attributes.mind.total + data.traits.dexterity.total, perception: Math.max(data.attributes.mind.total + data.traits.intellect.total, 8), pickPocket: data.attributes.mobility.total + data.traits.dexterity.total, readTracks: data.attributes.mind.total + data.traits.intellect.total, resistDisease: data.attributes.body.total + data.traits.constitution.total, ride: data.attributes.mobility.total + Math.max(data.traits.agility.total, data.traits.aura.total), search: Math.max(data.attributes.mind.total + data.traits.intellect.total, 8), senseMagic: data.attributes.mind.total + data.traits.aura.total, sneak: data.attributes.mobility.total + data.traits.agility.total, startFire: data.attributes.mind.total + data.traits.dexterity.total, swim: data.attributes.mobility.total + data.traits.strength.total, wakeUp: data.attributes.mind.total + data.traits.intellect.total, workMechanism: data.attributes.mind.total + Math.max(data.traits.dexterity.total, data.traits.intellect.total), }; } /** * Handle how changes to a Token attribute bar are applied to the Actor. * This only differs from the base implementation by also allowing negative values. * @override */ async modifyTokenAttribute( attribute: string, value: number, isDelta = false, isBar = true, ): Promise { const current = foundry.utils.getProperty(this.data.data, attribute); // Determine the updates to make to the actor data let updates: Record; if (isBar) { if (isDelta) value = Math.min(Number(current.value) + value, current.max); updates = { [`data.${attribute}.value`]: value }; } else { if (isDelta) value = Number(current) + value; updates = { [`data.${attribute}`]: value }; } // Call a hook to handle token resource bar updates const allowed = Hooks.call("modifyTokenAttribute", { attribute, value, isDelta, isBar }, updates); return allowed !== false ? this.update(updates) : this; } /** * Roll for a given check. * @param check - The check to perform * @param options - Additional options to customize the roll */ async rollCheck( check: Check, options: { speaker?: { token?: TokenDocument; alias?: string } } = {}, ): Promise { const speaker = ChatMessage.getSpeaker({ actor: this, ...options.speaker }); await createCheckRoll(this.data.data.checks[check], { rollMode: getGame().settings.get("core", "rollMode"), maximumCoupResult: this.data.data.rolling.maximumCoupResult, minimumFumbleResult: this.data.data.rolling.minimumFumbleResult, flavor: "DS4.ActorCheckFlavor", flavorData: { actor: speaker.alias ?? this.name, check: DS4.i18nKeys.checks[check] }, speaker, }); } /** * Roll a generic check. A dialog is presented to select the combination of * Attribute and Trait to perform the check against. * @param options - Additional options to customize the roll */ async rollGenericCheck(options: { speaker?: { token?: TokenDocument; alias?: string } } = {}): Promise { const attributeAndTrait = await this.selectAttributeAndTrait(); if (!attributeAndTrait) { return; } const { attribute, trait } = attributeAndTrait; const checkTargetNumber = this.data.data.attributes[attribute].total + this.data.data.traits[trait].total; const speaker = ChatMessage.getSpeaker({ actor: this, ...options.speaker }); await createCheckRoll(checkTargetNumber, { rollMode: getGame().settings.get("core", "rollMode"), maximumCoupResult: this.data.data.rolling.maximumCoupResult, minimumFumbleResult: this.data.data.rolling.minimumFumbleResult, flavor: "DS4.ActorGenericCheckFlavor", flavorData: { actor: speaker.alias ?? this.name, attribute: DS4.i18n.attributes[attribute], trait: DS4.i18n.traits[trait], }, speaker, }); } protected async selectAttributeAndTrait(): Promise<{ attribute: keyof typeof DS4.i18n.attributes; trait: keyof typeof DS4.i18n.traits; } | null> { const attributeIdentifier = "attribute-trait-selection-attribute"; const traitIdentifier = "attribute-trait-selection-trait"; return Dialog.prompt({ title: getGame().i18n.localize("DS4.DialogAttributeTraitSelection"), content: await renderTemplate("systems/ds4/templates/dialogs/simple-select-form.hbs", { selects: [ { label: getGame().i18n.localize("DS4.Attribute"), identifier: attributeIdentifier, options: Object.fromEntries( (Object.entries(DS4.i18n.attributes) as [keyof typeof DS4.i18n.attributes, string][]).map( ([attribute, translation]) => [ attribute, `${translation} (${this.data.data.attributes[attribute].total})`, ], ), ), }, { label: getGame().i18n.localize("DS4.Trait"), identifier: traitIdentifier, options: Object.fromEntries( (Object.entries(DS4.i18n.traits) as [keyof typeof DS4.i18n.traits, string][]).map( ([trait, translation]) => [ trait, `${translation} (${this.data.data.traits[trait].total})`, ], ), ), }, ], }), label: getGame().i18n.localize("DS4.GenericOkButton"), callback: (html) => { const selectedAttribute = html.find(`#${attributeIdentifier}`).val(); if (!isAttribute(selectedAttribute)) { throw new Error( getGame().i18n.format("DS4.ErrorUnexpectedAttribute", { actualAttribute: selectedAttribute, expectedTypes: Object.keys(DS4.i18n.attributes) .map((attribute) => `'${attribute}'`) .join(", "), }), ); } const selectedTrait = html.find(`#${traitIdentifier}`).val(); if (!isTrait(selectedTrait)) { throw new Error( getGame().i18n.format("DS4.ErrorUnexpectedTrait", { actualTrait: selectedTrait, expectedTypes: Object.keys(DS4.i18n.traits) .map((attribute) => `'${attribute}'`) .join(", "), }), ); } return { attribute: selectedAttribute, trait: selectedTrait, }; }, rejectClose: false, }); } }