ds4/src/active-effect/active-effect.ts

181 lines
6.5 KiB
TypeScript
Raw Normal View History

// SPDX-FileCopyrightText: 2021 Johannes Loher
//
// SPDX-License-Identifier: MIT
import { mathEvaluator } from "../expression-evaluation/evaluator";
import { getGame } from "../helpers";
import type { DS4Actor } from "../actor/actor";
import type { DS4Item } from "../item/item";
declare global {
interface DocumentClassConfig {
ActiveEffect: typeof DS4ActiveEffect;
}
interface FlagConfig {
ActiveEffect: {
ds4?: {
itemEffectConfig?: {
applyToItems?: boolean;
itemName?: string;
condition?: string;
};
};
};
}
}
type PromisedType<T> = T extends Promise<infer U> ? U : T;
export class DS4ActiveEffect extends ActiveEffect {
/**
* A fallback icon that can be used if no icon is defined for the effect.
*/
static FALLBACK_ICON = "icons/svg/aura.svg";
/**
* A cached reference to the source document to avoid recurring database lookups
*/
protected source: PromisedType<ReturnType<typeof fromUuid>> | undefined = undefined;
/**
* Whether or not this effect is currently surpressed.
*/
get isSurpressed(): boolean {
const originatingItem = this.originatingItem;
if (!originatingItem) {
return false;
}
return originatingItem.isNonEquippedEuipable();
}
/**
* The item which this effect originates from if it has been transferred from an item to an actor.
*/
get originatingItem(): DS4Item | undefined {
if (!(this.parent instanceof Actor)) {
return;
}
const itemIdRegex = /Item\.([a-zA-Z0-9]+)/;
const itemId = this.data.origin?.match(itemIdRegex)?.[1];
if (!itemId) {
return;
}
return this.parent.items.get(itemId);
}
/**
* The number of times this effect should be applied.
*/
get factor(): number {
return this.originatingItem?.activeEffectFactor ?? 1;
}
override apply(document: DS4Actor | DS4Item, change: EffectChangeData): unknown {
change.value = Roll.replaceFormulaData(change.value, document.data);
try {
change.value = DS4ActiveEffect.safeEval(change.value).toString();
} catch (e) {
// this is a valid case, e.g., if the effect change simply is a string
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error In the types and foundry's documentation, only actors are allowed, but the implementation actually works for all kinds of documents
return super.apply(document, change);
}
/**
* Gets the current source name based on the cached source object.
*/
async getCurrentSourceName(): Promise<string> {
const game = getGame();
const origin = await this.getSource();
if (origin === null) return game.i18n.localize("None");
return origin.name ?? game.i18n.localize("Unknown");
}
/**
* Gets the source document for this effect. Uses the cached {@link DS4ActiveEffect#source} if it has already been
* set.
*/
protected async getSource(): ReturnType<typeof fromUuid> {
if (this.source === undefined) {
this.source = this.data.origin !== undefined ? await fromUuid(this.data.origin) : null;
}
return this.source;
}
/**
* Create a new {@link DS4ActiveEffect} using default data.
*
* @param parent The parent {@link DS4Actor} or {@link DS4Item} of the effect.
* @returns A promise that resolved to the created effect or udifined of the creation was prevented.
*/
static async createDefault(parent: DS4Actor | DS4Item): Promise<DS4ActiveEffect | undefined> {
const createData = {
label: getGame().i18n.localize(`DS4.NewEffectLabel`),
icon: this.FALLBACK_ICON,
};
return this.create(createData, { parent, pack: parent.pack ?? undefined });
}
static safeEval(expression: string): number | `${number | boolean}` {
const result = mathEvaluator.evaluate(expression);
if (!Number.isNumeric(result)) {
throw new Error(`mathEvaluator.evaluate produced a non-numeric result from expression "${expression}"`);
}
return result as number | `${number | boolean}`;
}
/**
* Apply the given effects to the gicen Actor or item.
* @param document The Actor or Item to which to apply the effects
* @param effetcs The effects to apply
* @param predicate Apply only changes that fullfill this predicate
*/
static applyEffetcs(
document: DS4Actor | DS4Item,
effetcs: DS4ActiveEffect[],
predicate: (change: EffectChangeData) => boolean = () => true,
): void {
const overrides: Record<string, unknown> = {};
// Organize non-disabled and -surpressed effects by their application priority
const changesWithEffect = effetcs.flatMap((e) => e.getFactoredChangesWithEffect(predicate));
changesWithEffect.sort((a, b) => (a.change.priority ?? 0) - (b.change.priority ?? 0));
// Apply all changes
for (const changeWithEffect of changesWithEffect) {
const result = changeWithEffect.effect.apply(document, changeWithEffect.change);
if (result !== null) overrides[changeWithEffect.change.key] = result;
}
// Expand the set of final overrides
document.overrides = foundry.utils.expandObject({
...foundry.utils.flattenObject(document.overrides),
...overrides,
});
}
/**
* Get the array of changes for this effect, considering the {@link DS4ActiveEffect#factor}.
* @param predicate An optional predicate to filter which changes should be considered
* @returns The array of changes from this effect, considering the factor.
*/
protected getFactoredChangesWithEffect(
predicate: (change: EffectChangeData) => boolean = () => true,
): EffectChangeDataWithEffect[] {
if (this.data.disabled || this.isSurpressed) {
return [];
}
return this.data.changes.filter(predicate).flatMap((change) => {
change.priority = change.priority ?? change.mode * 10;
return Array<EffectChangeDataWithEffect>(this.factor).fill({ effect: this, change });
});
}
}
type EffectChangeData = foundry.data.ActiveEffectData["changes"][number];
type EffectChangeDataWithEffect = { effect: DS4ActiveEffect; change: EffectChangeData };