Merge branch 'item-effects' into 'main'

Add effects for owned items

See merge request dungeonslayers/ds4!203
This commit is contained in:
Johannes Loher 2022-11-03 21:20:49 +00:00
commit 4ac29f8f95
14 changed files with 433 additions and 59 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -9,6 +9,7 @@
// global
@use "global/accessibility";
@use "global/fonts";
@use "global/utils";
// shared
@use "components/shared/add_button";

14
scss/global/_utils.scss Normal file
View file

@ -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);
}

View file

@ -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<HTMLElement>): void {
super.activateListeners(html);
const checkbox = html[0]?.querySelector<HTMLInputElement>(
'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" });
}
}

View file

@ -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> = T extends Promise<infer U> ? 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<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 };

View file

@ -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<ActorSheet.Options, DS4ActorSheetD
html.find(".rollable-check").on("click", this.onRollCheck.bind(this));
html.find(".sort-items").on("click", this.onSortItems.bind(this));
disableOverriddenFields.call(this);
}
/**

View file

@ -3,8 +3,11 @@
//
// SPDX-License-Identifier: MIT
import { DS4ActiveEffect } from "../active-effect/active-effect";
import { DS4 } from "../config";
import { mathEvaluator } from "../expression-evaluation/evaluator";
import { getGame } from "../helpers";
import logger from "../logger";
import { createCheckRoll } from "../rolls/check-factory";
import { isAttribute, isTrait } from "./actor-data-source-base";
@ -25,7 +28,10 @@ declare global {
* The Actor class for DS4
*/
export class DS4Actor extends Actor {
initialized: boolean | undefined;
override prepareData(): void {
this.initialized = true;
this.data.reset();
this.prepareBaseData();
this.prepareEmbeddedDocuments();
@ -54,6 +60,41 @@ export class DS4Actor extends Actor {
);
}
private get actorEffects() {
return this.effects.filter((effect) => !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<string, unknown> = {};
// 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);
},
[],
DS4ActiveEffect.applyEffetcs(this, this.actorEffects, (change) =>
this.derivedDataProperties.includes(change.key),
);
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 });
}
/**

25
src/apps/sheet-helpers.ts Normal file
View file

@ -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<string, unknown> },
>(this: FormApplication<Options, Data, ConcreteObject>): 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);
}
});
}
}

View file

@ -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();

View file

@ -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<ItemSheet.Options, DS4ItemSheetData>
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> = {},
): (Application.Position & { height: number }) | void {
@ -60,6 +71,8 @@ export class DS4ItemSheet extends ItemSheet<ItemSheet.Options, DS4ItemSheetData>
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<ItemSheet.Options, DS4ItemSheetData>
/**
* Creates a new embedded effect.
*
* @param event - The originating click event
*/
protected onCreateEffect(): void {
DS4ActiveEffect.createDefault(this.item);

View file

@ -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<string, unknown> = {};
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;
}

View file

@ -0,0 +1,178 @@
{{!--
SPDX-FileCopyrightText: 2022 Johannes Loher
SPDX-License-Identifier: MIT
--}}
<form autocomplete="off">
<!-- Effect Header -->
<header class="sheet-header">
<img class="effect-icon" src="{{ data.icon }}" data-edit="icon">
<h1 class="effect-title">{{ data.label }}</h1>
</header>
<!-- Effect Configuration Tabs -->
<nav class="sheet-tabs tabs">
<a class="item" data-tab="details"><i class="fas fa-book"></i> {{localize "EFFECT.TabDetails"}}</a>
<a class="item" data-tab="duration"><i class="fas fa-clock"></i> {{localize "EFFECT.TabDuration"}}</a>
<a class="item" data-tab="effects"><i class="fas fa-cogs"></i> {{localize "EFFECT.TabEffects"}}</a>
</nav>
<!-- Details Tab -->
<section class="tab" data-tab="details">
<div class="form-group">
<label>{{ localize "EFFECT.Label" }}</label>
<div class="form-fields">
<input type="text" name="label" value="{{ data.label }}" />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.Icon" }}</label>
<div class="form-fields">
{{filePicker target="icon" type="image"}}
<input class="image" type="text" name="icon" placeholder="path/image.png" value="{{data.icon}}" />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.IconTint" }}</label>
<div class="form-fields">
<input class="color" type="text" name="tint" value="{{data.tint}}" />
<input type="color" value="{{data.tint}}" data-edit="tint" />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.Disabled" }}</label>
<div class="form-fields">
<input type="checkbox" name="disabled" {{ checked data.disabled }} />
</div>
</div>
{{#if isActorEffect}}
<div class="form-group">
<label>{{ localize "EFFECT.Origin" }}</label>
<div class="form-fields">
<input type="text" name="origin" value="{{ data.origin }}" disabled />
</div>
</div>
{{/if}}
{{#if isItemEffect}}
<div class="form-group">
<label>{{ localize "EFFECT.Transfer" }}</label>
<div class="form-fields">
<input type="checkbox" name="transfer" {{checked data.transfer}} />
</div>
</div>
{{/if}}
<div class="form-group">
<label>{{ localize "DS4.ActiveEffectApplyToItems" }}</label>
<div class="form-fields">
<input type="checkbox" name="flags.ds4.itemEffectConfig.applyToItems" {{checked
data.flags.ds4.itemEffectConfig.applyToItems}} />
</div>
</div>
<div
class="form-group ds4-item-effect-config{{#unless data.flags.ds4.itemEffectConfig.applyToItems}} ds4-hidden{{/unless}}">
<label>{{ localize "DS4.ActiveEffectItemName" }}</label>
<div class="form-fields">
<input type="text" name="flags.ds4.itemEffectConfig.itemName"
value="{{ data.flags.ds4.itemEffectConfig.itemName }}" />
</div>
</div>
<div
class="form-group ds4-item-effect-config{{#unless data.flags.ds4.itemEffectConfig.applyToItems}} ds4-hidden{{/unless}}">
<label>{{ localize "DS4.ActiveEffectItemCondition" }}</label>
<div class="form-fields">
<input class="ds4-code-input" type="text" name="flags.ds4.itemEffectConfig.condition"
value="{{ data.flags.ds4.itemEffectConfig.condition }}" />
</div>
</div>
</section>
<!-- Duration Tab -->
<section class="tab" data-tab="duration">
<div class="form-group">
<label>{{ localize "EFFECT.DurationSecs" }}</label>
<div class="form-fields">
<input type="number" name="duration.seconds" value="{{ data.duration.seconds }}" />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.StartTime" }}</label>
<div class="form-fields">
<input type="number" name="duration.startTime" value="{{ data.duration.startTime }}" />
</div>
</div>
<hr />
<div class="form-group">
<label>{{ localize "EFFECT.DurationTurns" }}</label>
<div class="form-fields">
<label>{{ localize "COMBAT.Rounds" }}</label>
<input type="number" name="duration.rounds" value="{{ data.duration.rounds }}" />
<label>{{ localize "COMBAT.Turns" }}</label>
<input type="number" name="duration.turns" value="{{ data.duration.turns }}" />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.Combat" }}</label>
<div class="form-fields">
<input type="text" name="duration.combat" value="{{ data.duration.combat }}" disabled />
</div>
</div>
<div class="form-group">
<label>{{ localize "EFFECT.StartTurns" }}</label>
<div class="form-fields">
<label>{{ localize "COMBAT.Round" }}</label>
<input type="number" name="duration.startRound" value="{{ data.duration.startRound }}" />
<label>{{ localize "COMBAT.Turn" }}</label>
<input type="number" name="duration.startTurn" value="{{ data.duration.startTurn }}" />
</div>
</div>
</section>
<!-- Effects Tab -->
<section class="tab" data-tab="effects">
<header class="effect-change effects-header flexrow">
<div class="key">{{ localize "EFFECT.ChangeKey" }}</div>
<div class="mode">{{ localize "EFFECT.ChangeMode" }}</div>
<div class="value">{{ localize "EFFECT.ChangeValue" }}</div>
<div class="effect-controls">
<a class="effect-control" data-action="add"><i class="far fa-plus-square"></i></a>
</div>
</header>
<ol class="changes-list">
{{#each data.changes as |change i|}}
<li class="effect-change flexrow" data-index="{{i}}">
<div class="key">
<input type="text" name="changes.{{i}}.key" value="{{change.key}}" />
</div>
<div class="mode">
<select name="changes.{{i}}.mode" data-dtype="Number">
{{selectOptions ../modes selected=change.mode}}
</select>
</div>
<div class="value">
<input type="text" name="changes.{{i}}.value" value="{{change.value}}" />
</div>
<div class="effect-controls">
<a class="effect-control" data-action="delete"><i class="fas fa-trash"></i></a>
</div>
</li>
{{/each}}
</ol>
</section>
<footer class="sheet-footer">
<button type="submit"><i class="fas fa-save"></i> {{localize submitText}}</button>
</footer>
</form>