ds4/src/module/actor/actor.ts

380 lines
16 KiB
TypeScript
Raw Normal View History

2021-06-26 22:02:00 +02:00
// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver RÜmpelein
//
// SPDX-License-Identifier: MIT
import { ModifiableDataBaseTotal } from "../common/common-data";
2021-01-25 01:09:51 +01:00
import { DS4 } from "../config";
2021-07-07 19:22:35 +02:00
import { getGame } from "../helpers";
2021-06-30 03:53:52 +02:00
import { DS4ArmorDataProperties, DS4ShieldDataProperties } from "../item/item-data-properties";
2021-07-07 19:22:35 +02:00
import { ItemType } from "../item/item-data-source";
import { createCheckRoll } from "../rolls/check-factory";
2021-06-30 02:17:54 +02:00
import { Check } from "./actor-data-properties";
2021-07-07 19:22:35 +02:00
import { isAttribute, isTrait } from "./actor-data-source";
2021-06-30 03:53:52 +02:00
declare global {
interface DocumentClassConfig {
Actor: typeof DS4Actor;
}
}
2020-12-23 18:23:26 +01:00
2021-02-07 11:51:36 +01:00
/**
* The Actor class for DS4
*/
2021-06-30 02:17:54 +02:00
export class DS4Actor extends Actor {
2021-01-25 01:09:51 +01:00
/** @override */
prepareData(): void {
2021-06-30 02:17:54 +02:00
this.data.reset();
2021-01-25 01:09:51 +01:00
this.prepareBaseData();
this.prepareEmbeddedEntities();
2021-02-16 16:34:23 +01:00
this.applyActiveEffectsToBaseData();
2021-01-25 01:09:51 +01:00
this.prepareDerivedData();
this.applyActiveEffectsToDerivedData();
this.prepareFinalDerivedData();
2021-01-25 01:09:51 +01:00
}
/** @override */
prepareBaseData(): void {
const data = this.data;
2021-02-16 16:34:23 +01:00
data.data.rolling = {
minimumFumbleResult: 20,
maximumCoupResult: 1,
};
const attributes = data.data.attributes;
Object.values(attributes).forEach(
(attribute: ModifiableDataBaseTotal<number>) => (attribute.total = attribute.base + attribute.mod),
);
const traits = data.data.traits;
Object.values(traits).forEach(
(trait: ModifiableDataBaseTotal<number>) => (trait.total = trait.base + trait.mod),
);
}
2021-06-30 02:17:54 +02:00
/**
* @override
* We override this with an empty implementation because we have our own custom way of applying
* {@link ActiveEffect}s and {@link Actor#prepareEmbeddedEntities} calls this.
*/
applyActiveEffects(): void {
return;
}
2021-02-16 16:34:23 +01:00
applyActiveEffectsToBaseData(): void {
2021-02-18 13:24:52 +01:00
// 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),
);
2021-01-25 01:09:51 +01:00
}
applyActiveEffectsToDerivedData(): void {
this.applyActiveEffectsFiltered((change) => this.derivedDataProperties.includes(change.key));
2021-01-25 01:09:51 +01:00
}
/**
* Apply ActiveEffectChanges to the Actor data which are caused by ActiveEffects and satisfy the given predicate.
*
2021-02-08 03:38:50 +01:00
* @param predicate - The predicate that ActiveEffectChanges need to satisfy in order to be applied
2021-01-25 01:09:51 +01:00
*/
2021-06-30 02:17:54 +02:00
applyActiveEffectsFiltered(predicate: (change: foundry.data.ActiveEffectData["changes"][number]) => boolean): void {
const overrides: Record<string, unknown> = {};
2021-01-25 01:09:51 +01:00
// Organize non-disabled and -surpressed effects by their application priority
2021-06-30 02:17:54 +02:00
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;
2021-06-30 02:17:54 +02:00
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 });
2021-06-30 02:17:54 +02:00
});
return changes.concat(newChanges);
},
[],
);
changes.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
2021-01-25 01:09:51 +01:00
// 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 });
2021-01-25 01:09:51 +01:00
}
/**
* Apply transformations to the Actor data after effects have been applied to the base data.
* @override
*/
2020-12-23 18:23:26 +01:00
prepareDerivedData(): void {
this._prepareCombatValues();
this._prepareChecks();
2020-12-23 16:52:20 +01:00
}
/**
* The list of properties that are derived from others, given in dot notation.
*/
2021-01-25 01:09:51 +01:00
get derivedDataProperties(): Array<string> {
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);
2021-01-25 01:09:51 +01:00
}
/**
* Apply final transformations to the Actor data after all effects have been applied.
*/
prepareFinalDerivedData(): void {
this.data.data.combatValues.hitPoints.max = this.data.data.combatValues.hitPoints.total;
this.data.data.checks.defend = this.data.data.combatValues.defense.total;
2021-05-13 15:41:00 +02:00
if (this.data.type === "character") {
this.data.data.slayerPoints.max = 3;
}
}
/**
2021-02-16 16:34:23 +01:00
* 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[] {
2021-05-13 15:41:00 +02:00
return ["data.combatValues.hitPoints.max", "data.checks.defend"].concat(
this.data.type === "character" ? ["data.slayerPoints.max"] : [],
);
}
/**
* The list of item types that can be owned by this actor.
*/
get ownableItemTypes(): Array<ItemType> {
switch (this.data.type) {
case "character":
return [
"weapon",
"armor",
"shield",
"equipment",
2021-02-21 03:40:54 +01:00
"loot",
"spell",
"talent",
"racialAbility",
"language",
"alphabet",
];
case "creature":
2021-02-21 03:40:54 +01:00
return ["weapon", "armor", "shield", "equipment", "loot", "spell", "specialCreatureAbility"];
}
}
/**
* Checks whether or not the given item type can be owned by the actor.
2021-02-07 11:51:36 +01:00
* @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();
2021-02-16 16:34:23 +01:00
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 - armorValueOfEquippedItems;
data.combatValues.targetedSpellcasting.base =
data.attributes.mind.total + data.traits.dexterity.total - armorValueOfEquippedItems;
Object.values(data.combatValues).forEach(
(combatValue: ModifiableDataBaseTotal<number>) => (combatValue.total = combatValue.base + combatValue.mod),
);
}
/**
* Calculates the total armor value of all equipped items.
*/
protected _calculateArmorValueOfEquippedItems(): number {
return this.items
.map((item) => item.data)
.filter(
2021-06-30 02:17:54 +02:00
(data): data is foundry.data.ItemData & (DS4ArmorDataProperties | DS4ShieldDataProperties) =>
data.type === "armor" || data.type === "shield",
)
.filter((data) => data.data.equipped)
.map((data) => data.data.armorValue)
.reduce((a, b) => a + b, 0);
}
/**
* 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,
2021-04-13 20:19:25 +02:00
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),
2021-04-13 20:19:25 +02:00
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
*/
2021-06-30 02:17:54 +02:00
async modifyTokenAttribute(
attribute: string,
value: number,
isDelta = false,
isBar = true,
): Promise<this | undefined> {
const current = foundry.utils.getProperty(this.data.data, attribute);
// Determine the updates to make to the actor data
let updates: Record<string, number>;
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.
2021-03-24 09:24:14 +01:00
* @param check - The check to perform
*/
async rollCheck(check: Check): Promise<void> {
await createCheckRoll(this.data.data.checks[check], {
2021-07-07 19:22:35 +02:00
rollMode: getGame().settings.get("core", "rollMode"),
maximumCoupResult: this.data.data.rolling.maximumCoupResult,
minimumFumbleResult: this.data.data.rolling.minimumFumbleResult,
2021-07-07 19:22:35 +02:00
flavor: getGame().i18n.format("DS4.ActorCheckFlavor", { actor: this.name, check: DS4.i18n.checks[check] }),
});
}
2021-05-13 21:39:42 +02:00
/**
* Roll a generic check. A dialog is presented to select the combination of
* Attribute and Trait to perform the check against.
*/
async rollGenericCheck(): Promise<void> {
const { attribute, trait } = await this.selectAttributeAndTrait();
const checkTargetNumber = this.data.data.attributes[attribute].total + this.data.data.traits[trait].total;
await createCheckRoll(checkTargetNumber, {
2021-07-07 19:22:35 +02:00
rollMode: getGame().settings.get("core", "rollMode"),
2021-05-13 21:39:42 +02:00
maximumCoupResult: this.data.data.rolling.maximumCoupResult,
minimumFumbleResult: this.data.data.rolling.minimumFumbleResult,
2021-07-07 19:22:35 +02:00
flavor: getGame().i18n.format("DS4.ActorGenericCheckFlavor", {
2021-05-13 21:39:42 +02:00
actor: this.name,
attribute: DS4.i18n.attributes[attribute],
trait: DS4.i18n.traits[trait],
}),
});
}
protected async selectAttributeAndTrait(): Promise<{
attribute: keyof typeof DS4.i18n.attributes;
trait: keyof typeof DS4.i18n.traits;
}> {
const attributeIdentifier = "attribute-trait-selection-attribute";
const traitIdentifier = "attribute-trait-selection-trait";
return Dialog.prompt({
2021-07-07 19:22:35 +02:00
title: getGame().i18n.localize("DS4.DialogAttributeTraitSelection"),
2021-05-13 21:39:42 +02:00
content: await renderTemplate("systems/ds4/templates/dialogs/simple-select-form.hbs", {
selects: [
{
2021-07-07 19:22:35 +02:00
label: getGame().i18n.localize("DS4.Attribute"),
2021-05-13 21:39:42 +02:00
identifier: attributeIdentifier,
options: DS4.i18n.attributes,
},
{
2021-07-07 19:22:35 +02:00
label: getGame().i18n.localize("DS4.Trait"),
2021-05-13 21:39:42 +02:00
identifier: traitIdentifier,
options: DS4.i18n.traits,
},
],
}),
2021-07-07 19:22:35 +02:00
label: getGame().i18n.localize("DS4.GenericOkButton"),
2021-05-13 21:39:42 +02:00
callback: (html) => {
const selectedAttribute = html.find(`#${attributeIdentifier}`).val();
if (!isAttribute(selectedAttribute)) {
2021-05-13 21:39:42 +02:00
throw new Error(
2021-07-07 19:22:35 +02:00
getGame().i18n.format("DS4.ErrorUnexpectedAttribute", {
2021-05-13 21:39:42 +02:00
actualAttribute: selectedAttribute,
expectedTypes: Object.keys(DS4.i18n.attributes)
.map((attribute) => `'${attribute}'`)
.join(", "),
2021-05-13 21:39:42 +02:00
}),
);
}
const selectedTrait = html.find(`#${traitIdentifier}`).val();
if (!isTrait(selectedTrait)) {
2021-05-13 21:39:42 +02:00
throw new Error(
2021-07-07 19:22:35 +02:00
getGame().i18n.format("DS4.ErrorUnexpectedTrait", {
actualTrait: selectedTrait,
expectedTypes: Object.keys(DS4.i18n.traits)
.map((attribute) => `'${attribute}'`)
.join(", "),
2021-05-13 21:39:42 +02:00
}),
);
}
return {
attribute: selectedAttribute,
trait: selectedTrait,
};
},
});
}
}