diff --git a/.vscode/settings.json b/.vscode/settings.json index f8012687..133f4a55 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,6 @@ "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 120, "importSorter.importStringConfiguration.tabSize": 4, "importSorter.importStringConfiguration.quoteMark": "double", - "importSorter.importStringConfiguration.trailingComma": "multiLine" + "importSorter.importStringConfiguration.trailingComma": "multiLine", + "vitest.commandLine": "yarn run vitest" } diff --git a/lang/de.json b/lang/de.json index b4beb543..d8ec0911 100644 --- a/lang/de.json +++ b/lang/de.json @@ -366,5 +366,10 @@ "DS4.NewLanguageName": "Neue Sprache", "DS4.NewAlphabetName": "Neue Schriftzeichen", "DS4.NewSpecialCreatureAbilityName": "Neue Besondere Kreaturenfähigkeit", - "DS4.NewEffectLabel": "Neuer Effekt" + "DS4.NewEffectLabel": "Neuer Effekt", + + "DS4.ActiveEffectApplyToItems": "Auf Items Andwenden", + "DS4.ActiveEffectItemName": "Itemname", + "DS4.ActiveEffectItemCondition": "Bedingung", + "DS4.TooltipDisabledDueToEffects": "inaktiv, weil von Aktiven Effekten beeinflusst" } diff --git a/lang/en.json b/lang/en.json index 485e9eed..64d9b686 100644 --- a/lang/en.json +++ b/lang/en.json @@ -366,5 +366,10 @@ "DS4.NewLanguageName": "New Language", "DS4.NewAlphabetName": "New Alphabet", "DS4.NewSpecialCreatureAbilityName": "New Special Creature Ability", - "DS4.NewEffectLabel": "New Effect" + "DS4.NewEffectLabel": "New Effect", + + "DS4.ActiveEffectApplyToItems": "Apply to Items", + "DS4.ActiveEffectItemName": "Item Name", + "DS4.ActiveEffectItemCondition": "Condition", + "DS4.TooltipDisabledDueToEffects": "disabled, because affected by Active Effects" } diff --git a/scss/ds4.scss b/scss/ds4.scss index acedd839..84bf078c 100644 --- a/scss/ds4.scss +++ b/scss/ds4.scss @@ -9,6 +9,7 @@ // global @use "global/accessibility"; @use "global/fonts"; +@use "global/utils"; // shared @use "components/shared/add_button"; diff --git a/scss/global/_utils.scss b/scss/global/_utils.scss new file mode 100644 index 00000000..b5877b1f --- /dev/null +++ b/scss/global/_utils.scss @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2022 Johannes Loher + * + * SPDX-License-Identifier: MIT + */ + +.ds4-code-input { + font-family: var(--font-mono); +} + +// This is needed for higher specifity +form .ds4-code-input { + font-family: var(--font-mono); +} diff --git a/src/active-effect/active-effect-config.ts b/src/active-effect/active-effect-config.ts new file mode 100644 index 00000000..f26494e8 --- /dev/null +++ b/src/active-effect/active-effect-config.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2022 Johannes Loher +// +// SPDX-License-Identifier: MIT + +export class DS4ActiveEffectConfig extends ActiveEffectConfig { + static override get defaultOptions(): DocumentSheetOptions { + return foundry.utils.mergeObject(super.defaultOptions, { + template: "systems/ds4/templates/sheets/active-effect/active-effect-config.hbs", + }); + } + + override activateListeners(html: JQuery): void { + super.activateListeners(html); + const checkbox = html[0]?.querySelector( + 'input[name="flags.ds4.itemEffectConfig.applyToItems"]', + ); + checkbox?.addEventListener("change", () => this.toggleItemEffectConfig(checkbox.checked)); + } + + private toggleItemEffectConfig(active: boolean) { + const elements = this.element[0]?.querySelectorAll(".ds4-item-effect-config"); + elements?.forEach((element) => { + if (active) { + element.classList.remove("ds4-hidden"); + } else { + element.classList.add("ds4-hidden"); + } + }); + this.setPosition({ height: "auto" }); + } +} diff --git a/src/active-effect.ts b/src/active-effect/active-effect.ts similarity index 52% rename from src/active-effect.ts rename to src/active-effect/active-effect.ts index 0c587f96..68049d7a 100644 --- a/src/active-effect.ts +++ b/src/active-effect/active-effect.ts @@ -2,16 +2,27 @@ // // SPDX-License-Identifier: MIT -import { DS4Actor } from "./actor/actor"; -import { mathEvaluator } from "./expression-evaluation/evaluator"; -import { getGame } from "./helpers"; +import { mathEvaluator } from "../expression-evaluation/evaluator"; +import { getGame } from "../helpers"; -import type { DS4Item } from "./item/item"; +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 extends Promise ? U : T; @@ -42,7 +53,7 @@ export class DS4ActiveEffect extends ActiveEffect { * 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 DS4Actor)) { + if (!(this.parent instanceof Actor)) { return; } const itemIdRegex = /Item\.([a-zA-Z0-9]+)/; @@ -60,15 +71,17 @@ export class DS4ActiveEffect extends ActiveEffect { return this.originatingItem?.activeEffectFactor ?? 1; } - override apply(actor: DS4Actor, change: foundry.data.ActiveEffectData["changes"][number]): unknown { - change.value = Roll.replaceFormulaData(change.value, actor.data); + 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) { logger.warn(e); // this is a valid case, e.g., if the effect change simply is a string } - return super.apply(actor, change); + // 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); } /** @@ -114,4 +127,55 @@ export class DS4ActiveEffect extends ActiveEffect { } 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 = {}; + + // 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(this.factor).fill({ effect: this, change }); + }); + } } + +type EffectChangeData = foundry.data.ActiveEffectData["changes"][number]; +type EffectChangeDataWithEffect = { effect: DS4ActiveEffect; change: EffectChangeData }; diff --git a/src/actor/actor-sheet.ts b/src/actor/actor-sheet.ts index 192b802c..50224e95 100644 --- a/src/actor/actor-sheet.ts +++ b/src/actor/actor-sheet.ts @@ -5,7 +5,8 @@ // // SPDX-License-Identifier: MIT -import { DS4ActiveEffect } from "../active-effect"; +import { DS4ActiveEffect } from "../active-effect/active-effect"; +import { disableOverriddenFields } from "../apps/sheet-helpers"; import { DS4 } from "../config"; import { getCanvas, getGame } from "../helpers"; import { getDS4Settings } from "../settings"; @@ -108,6 +109,8 @@ export class DS4ActorSheet extends ActorSheet !effect.data.flags.ds4?.itemEffectConfig?.applyToItems); + } + + /** + * Get the effects of this actor that should be applied to the given item. + * @param item The item for which to get effects + * @returns The array of effects that are candidates to be applied to the item + */ + itemEffects(item: DS4Item) { + return this.effects.filter((effect) => { + const { applyToItems, itemName, condition } = effect.data.flags.ds4?.itemEffectConfig ?? {}; + + if (!applyToItems || (itemName !== undefined && itemName !== "" && itemName !== item.name)) { + return false; + } + + if (condition !== undefined && condition !== "") { + try { + const replacedCondition = Roll.replaceFormulaData(condition, { + item: item.data, + actor: this.data, + effect: effect.data, + }); + return Boolean(mathEvaluator.evaluate(replacedCondition)); + } catch (error) { + logger.warn(error); + return false; + } + } + + return true; + }); + } + /** * 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. @@ -65,7 +106,9 @@ export class DS4Actor extends Actor { applyActiveEffectsToBaseData(): void { // reset overrides because our variant of applying active effects does not set them, it only adds overrides this.overrides = {}; - this.applyActiveEffectsFiltered( + DS4ActiveEffect.applyEffetcs( + this, + this.actorEffects, (change) => !this.derivedDataProperties.includes(change.key) && !this.finalDerivedDataProperties.includes(change.key), @@ -73,43 +116,9 @@ export class DS4Actor extends Actor { } 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 }); + DS4ActiveEffect.applyEffetcs(this, this.actorEffects, (change) => + this.derivedDataProperties.includes(change.key), + ); } /** diff --git a/src/apps/sheet-helpers.ts b/src/apps/sheet-helpers.ts new file mode 100644 index 00000000..1844c254 --- /dev/null +++ b/src/apps/sheet-helpers.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2022 Johannes Loher +// +// SPDX-License-Identifier: MIT + +import { getGame } from "../helpers"; + +export function disableOverriddenFields< + Options extends FormApplicationOptions, + Data extends object, + ConcreteObject extends { overrides: Record }, +>(this: FormApplication): void { + const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"]; + const titleAddition = `(${getGame().i18n.localize("DS4.TooltipDisabledDueToEffects")})`; + for (const key of Object.keys(foundry.utils.flattenObject(this.object.overrides))) { + const elements = this.form?.querySelectorAll(`[name="${key}"]`); + elements?.forEach((element) => { + if (inputs.includes(element.tagName)) { + element.setAttribute("disabled", ""); + const title = element.getAttribute("title"); + const newTitle = title === null ? titleAddition : `${title} ${titleAddition}`; + element.setAttribute("title", newTitle); + } + }); + } +} diff --git a/src/hooks/init.ts b/src/hooks/init.ts index ee2f8870..35f22540 100644 --- a/src/hooks/init.ts +++ b/src/hooks/init.ts @@ -4,7 +4,8 @@ // // SPDX-License-Identifier: MIT -import { DS4ActiveEffect } from "../active-effect"; +import { DS4ActiveEffect } from "../active-effect/active-effect"; +import { DS4ActiveEffectConfig } from "../active-effect/active-effect-config"; import { DS4CharacterActorSheet } from "../actor/character/character-sheet"; import { DS4CreatureActorSheet } from "../actor/creature/creature-sheet"; import { DS4ActorProxy } from "../actor/proxy"; @@ -65,11 +66,16 @@ async function init() { registerSystemSettings(); - Actors.unregisterSheet("core", ActorSheet); - Actors.registerSheet("ds4", DS4CharacterActorSheet, { types: ["character"], makeDefault: true }); - Actors.registerSheet("ds4", DS4CreatureActorSheet, { types: ["creature"], makeDefault: true }); - Items.unregisterSheet("core", ItemSheet); - Items.registerSheet("ds4", DS4ItemSheet, { makeDefault: true }); + DocumentSheetConfig.unregisterSheet(Actor, "core", ActorSheet); + DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CharacterActorSheet, { + types: ["character"], + makeDefault: true, + }); + DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CreatureActorSheet, { types: ["creature"], makeDefault: true }); + DocumentSheetConfig.unregisterSheet(Item, "core", ItemSheet); + DocumentSheetConfig.registerSheet(Item, "ds4", DS4ItemSheet, { makeDefault: true }); + DocumentSheetConfig.unregisterSheet(ActiveEffect, "core", ActiveEffectConfig); + DocumentSheetConfig.registerSheet(ActiveEffect, "ds4", DS4ActiveEffectConfig, { makeDefault: true }); preloadFonts(); await registerHandlebarsPartials(); diff --git a/src/item/item-sheet.ts b/src/item/item-sheet.ts index ce6a06de..3553ec23 100644 --- a/src/item/item-sheet.ts +++ b/src/item/item-sheet.ts @@ -4,7 +4,8 @@ // // SPDX-License-Identifier: MIT -import { DS4ActiveEffect } from "../active-effect"; +import { DS4ActiveEffect } from "../active-effect/active-effect"; +import { disableOverriddenFields } from "../apps/sheet-helpers"; import { DS4 } from "../config"; import { getGame } from "../helpers"; import notifications from "../ui/notifications"; @@ -41,6 +42,16 @@ export class DS4ItemSheet extends ItemSheet return data; } + override _getSubmitData(updateData = {}) { + const data = super._getSubmitData(updateData); + // Prevent submitting overridden values + const overrides = foundry.utils.flattenObject(this.item.overrides); + for (const k of Object.keys(overrides)) { + delete data[k]; + } + return data; + } + override setPosition( options: Partial = {}, ): (Application.Position & { height: number }) | void { @@ -60,6 +71,8 @@ export class DS4ItemSheet extends ItemSheet if (!this.options.editable) return; html.find(".control-effect").on("click", this.onControlEffect.bind(this)); + + disableOverriddenFields.call(this); } /** @@ -86,8 +99,6 @@ export class DS4ItemSheet extends ItemSheet /** * Creates a new embedded effect. - * - * @param event - The originating click event */ protected onCreateEffect(): void { DS4ActiveEffect.createDefault(this.item); diff --git a/src/item/item.ts b/src/item/item.ts index 1da26310..9b3e8db4 100644 --- a/src/item/item.ts +++ b/src/item/item.ts @@ -3,6 +3,7 @@ // // SPDX-License-Identifier: MIT +import { DS4ActiveEffect } from "../active-effect/active-effect"; import { getGame } from "../helpers"; import type { ItemType } from "./item-data-source"; @@ -24,6 +25,26 @@ declare global { * The Item class for DS4 */ export class DS4Item extends Item { + /** An object that tracks the changes to the data model which were applied by active effects */ + overrides: Record = {}; + + override prepareData() { + this.data.reset(); + this.prepareBaseData(); + this.prepareEmbeddedDocuments(); + this.prepareDerivedData(); + this.applyActiveEffects(); + } + + applyActiveEffects(): void { + if (!this.actor?.initialized) { + return; + } + + this.overrides = {}; + DS4ActiveEffect.applyEffetcs(this, this.actor.itemEffects(this)); + } + override prepareDerivedData(): void { this.data.data.rollable = false; } diff --git a/templates/sheets/active-effect/active-effect-config.hbs b/templates/sheets/active-effect/active-effect-config.hbs new file mode 100644 index 00000000..8c961fa7 --- /dev/null +++ b/templates/sheets/active-effect/active-effect-config.hbs @@ -0,0 +1,178 @@ +{{!-- +SPDX-FileCopyrightText: 2022 Johannes Loher + +SPDX-License-Identifier: MIT +--}} + +
+ + +
+ +

{{ data.label }}

+
+ + + + + +
+ +
+ +
+ +
+
+ +
+ +
+ {{filePicker target="icon" type="image"}} + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + {{#if isActorEffect}} +
+ +
+ +
+
+ {{/if}} + + {{#if isItemEffect}} +
+ +
+ +
+
+ {{/if}} + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+ + + + +
+
+
+ +
+ +
+
+ +
+ +
+ + + + +
+
+
+ + +
+
+
{{ localize "EFFECT.ChangeKey" }}
+
{{ localize "EFFECT.ChangeMode" }}
+
{{ localize "EFFECT.ChangeValue" }}
+
+ +
+
+
    + {{#each data.changes as |change i|}} +
  1. +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
  2. + {{/each}} +
+
+ + +