Implemented Active Effects.
Used AE to implement exhaustion, stimulants, and rest.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/** @type {Record<string, { label: string, fields: Record<string, string> }>} */
|
||||
export const EFFECT_FIELD_GROUPS = {
|
||||
statistics: {
|
||||
label: "DG.ActiveEffects.Groups.Statistics",
|
||||
fields: {
|
||||
"system.statistics.str.modifier": "DG.Attributes.str",
|
||||
"system.statistics.con.modifier": "DG.Attributes.con",
|
||||
"system.statistics.dex.modifier": "DG.Attributes.dex",
|
||||
"system.statistics.int.modifier": "DG.Attributes.int",
|
||||
"system.statistics.pow.modifier": "DG.Attributes.pow",
|
||||
"system.statistics.cha.modifier": "DG.Attributes.cha",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
label: "DG.ActiveEffects.Groups.Resources",
|
||||
fields: {
|
||||
"system.health.maxBonus": "DG.ActiveEffects.Fields.MaxHP",
|
||||
"system.wp.maxBonus": "DG.ActiveEffects.Fields.MaxWP",
|
||||
"system.sanity.maxBonus": "DG.ActiveEffects.Fields.MaxSanity",
|
||||
},
|
||||
},
|
||||
rollTargets: {
|
||||
label: "DG.ActiveEffects.Groups.RollTargets",
|
||||
fields: {
|
||||
"system.rollTarget.allSkills": "DG.ActiveEffects.Fields.AllSkills",
|
||||
"system.rollTarget.sanity": "DG.ActiveEffects.Fields.SanityRolls",
|
||||
"system.rollTarget.statistics": "DG.ActiveEffects.Fields.StatisticRolls",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ROLL_TARGET_FIELD_KEYS = Object.keys(
|
||||
EFFECT_FIELD_GROUPS.rollTargets.fields,
|
||||
);
|
||||
|
||||
export const SUPPORTED_ITEM_TYPES = new Set([
|
||||
"weapon",
|
||||
"armor",
|
||||
"gear",
|
||||
"bond",
|
||||
"motivation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Record<string, { label: string, fields: Record<string, string> }>}
|
||||
*/
|
||||
export function getEffectFieldGroupsForDocument(effect) {
|
||||
const parent = effect.parent;
|
||||
|
||||
if (parent?.documentName === "Actor") {
|
||||
if (parent.type !== "agent") return {};
|
||||
return EFFECT_FIELD_GROUPS;
|
||||
}
|
||||
|
||||
if (parent?.documentName === "Item") {
|
||||
if (!SUPPORTED_ITEM_TYPES.has(parent.type)) return {};
|
||||
return EFFECT_FIELD_GROUPS;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, { label: string, fields: Record<string, string> }>} groups
|
||||
* @returns {Array<{ group: string, groupLabel: string, options: Array<{ value: string, label: string }> }>}
|
||||
*/
|
||||
export function buildEffectFieldSelectOptions(groups) {
|
||||
return Object.entries(groups).map(([groupId, group]) => ({
|
||||
group: groupId,
|
||||
groupLabel: game.i18n.localize(group.label),
|
||||
options: Object.entries(group.fields).map(([value, labelKey]) => ({
|
||||
value,
|
||||
label: game.i18n.localize(labelKey),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,35 @@
|
||||
import { refreshDerivedAfterActiveEffects } from "../utils/active-effect-derived.js";
|
||||
import {
|
||||
syncExhaustionEffect,
|
||||
updateTouchesExhaustionPhysical,
|
||||
} from "../utils/exhaustion-effect.js";
|
||||
import { pruneExpiredStimulantEffects } from "../utils/stimulant-effect.js";
|
||||
import { sanitizeActiveEffectBackedUpdateData } from "../utils/active-effect-submit.js";
|
||||
|
||||
/**
|
||||
* Extend the base Actor entity by defining a custom roll data structure which is ideal for the Simple system.
|
||||
* @extends {Actor}
|
||||
*/
|
||||
export default class DeltaGreenActor extends Actor {
|
||||
/** @override */
|
||||
prepareData() {
|
||||
super.prepareData();
|
||||
refreshDerivedAfterActiveEffects(this);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async update(data, options = {}) {
|
||||
const sanitized = sanitizeActiveEffectBackedUpdateData(
|
||||
this,
|
||||
foundry.utils.deepClone(data),
|
||||
);
|
||||
const shouldSyncExhaustion =
|
||||
this.type === "agent" && updateTouchesExhaustionPhysical(sanitized);
|
||||
const result = await super.update(sanitized, options);
|
||||
if (shouldSyncExhaustion) await syncExhaustionEffect(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static async create(data, options = {}) {
|
||||
data.prototypeToken = data.prototypeToken || {};
|
||||
@@ -160,4 +187,13 @@ export default class DeltaGreenActor extends Actor {
|
||||
|
||||
await this.createEmbeddedDocuments("Item", [weaponData]);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onUpdateEffectDurations(effects, event, context) {
|
||||
await super.onUpdateEffectDurations(effects, event, context);
|
||||
if (!game.user.isActiveGM || this.type !== "agent") return;
|
||||
if (!effects.some((effect) => effect.parent === this)) return;
|
||||
await pruneExpiredStimulantEffects(this);
|
||||
await syncExhaustionEffect(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import DG from "../config.js";
|
||||
import {
|
||||
buildEffectFieldSelectOptions,
|
||||
getEffectFieldGroupsForDocument,
|
||||
} from "../active-effect/effect-fields.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { ActiveEffectConfig } = foundry.applications.sheets;
|
||||
|
||||
const DG_CHANGE_TYPES = ["add", "subtract", "override"];
|
||||
|
||||
/** @extends {foundry.applications.sheets.ActiveEffectConfig} */
|
||||
export default class DGActiveEffectConfig extends ActiveEffectConfig {
|
||||
/** @type {Record<string, string>|null} */
|
||||
#dgChangeTypes = null;
|
||||
|
||||
/** @type {Array<object>|null} */
|
||||
#fieldGroups = null;
|
||||
|
||||
/** @returns {Record<string, string>} */
|
||||
#getDgChangeTypes() {
|
||||
if (!this.#dgChangeTypes) {
|
||||
this.#dgChangeTypes = DG_CHANGE_TYPES.reduce((types, type) => {
|
||||
const label = ActiveEffect.CHANGE_TYPES[type]?.label;
|
||||
types[type] = label ? game.i18n.localize(label) : type;
|
||||
return types;
|
||||
}, {});
|
||||
}
|
||||
return this.#dgChangeTypes;
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
#getFieldGroups() {
|
||||
if (!this.#fieldGroups) {
|
||||
const groups = getEffectFieldGroupsForDocument(this.document);
|
||||
this.#fieldGroups = buildEffectFieldSelectOptions(groups);
|
||||
}
|
||||
return this.#fieldGroups;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_processChangeSubmission(change, index) {
|
||||
super._processChangeSubmission(change, index);
|
||||
if (!change.phase) change.phase = "final";
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _renderChange(context) {
|
||||
const { change, index } = context;
|
||||
if (typeof change.value !== "string") {
|
||||
change.value = JSON.stringify(change.value);
|
||||
}
|
||||
if (!change.phase) change.phase = "final";
|
||||
|
||||
Object.assign(
|
||||
change,
|
||||
["key", "type", "value", "phase", "priority"].reduce((paths, fieldName) => {
|
||||
paths[`${fieldName}Path`] = `system.changes.${index}.${fieldName}`;
|
||||
return paths;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
return renderTemplate(
|
||||
`systems/${DG.ID}/templates/active-effect/change-row.html`,
|
||||
{
|
||||
...context,
|
||||
fieldGroups: this.#getFieldGroups(),
|
||||
dgChangeTypes: this.#getDgChangeTypes(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export default class ActorEditStatForm extends foundry.applications.api.Handleba
|
||||
return {
|
||||
...(await super._prepareContext(options)),
|
||||
actor: this.actor,
|
||||
sourceStatistics:
|
||||
this.actor._source?.system?.statistics ?? this.actor.system.statistics,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const { ArrayField, SchemaField, StringField, NumberField, AnyField } =
|
||||
foundry.data.fields;
|
||||
|
||||
/**
|
||||
* Delta Green Active Effect system data — matches core change schema with `final` as the default phase.
|
||||
* @extends {foundry.data.ActiveEffectTypeDataModel}
|
||||
*/
|
||||
export default class DGActiveEffectTypeDataModel extends foundry.data
|
||||
.ActiveEffectTypeDataModel {
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
changes: new ArrayField(
|
||||
new SchemaField({
|
||||
key: new StringField({ required: true }),
|
||||
type: new StringField({
|
||||
required: true,
|
||||
blank: false,
|
||||
initial: "add",
|
||||
validate: DGActiveEffectTypeDataModel.#validateType,
|
||||
}),
|
||||
value: new AnyField({
|
||||
required: true,
|
||||
nullable: true,
|
||||
serializable: true,
|
||||
initial: "",
|
||||
}),
|
||||
phase: new StringField({
|
||||
required: true,
|
||||
blank: false,
|
||||
initial: "final",
|
||||
}),
|
||||
priority: new NumberField(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @returns {true}
|
||||
*/
|
||||
static #validateType(type) {
|
||||
if (type.length < 3) throw new Error("must be at least three characters long");
|
||||
if (
|
||||
!/^custom\.-?\d+$/.test(type) &&
|
||||
!type.split(".").every((s) => /^[a-z0-9]+$/i.test(s))
|
||||
) {
|
||||
throw new Error(
|
||||
'A change type must either be a sequence of dot-delimited, alpha-numeric substrings or of the form "custom.{number}"',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+34
-19
@@ -2,13 +2,11 @@ import HumanSkillsActorData from "./base/human-skills.js";
|
||||
import CharacterData from "./base/character.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import {
|
||||
calculateHealthMax,
|
||||
calculateMeleeDamageBonusFormula,
|
||||
clampSanityRitualValue,
|
||||
agentResourceField,
|
||||
} from "./base/general.js";
|
||||
import {
|
||||
computeEquippedArmorProtection,
|
||||
initializeSanityIfUnset,
|
||||
prepareAgentSkillFlags,
|
||||
prepareBreakingPointHit,
|
||||
prepareSanityAdaptations,
|
||||
prepareStatisticsX5,
|
||||
removeLegacyRitualSkill,
|
||||
@@ -21,13 +19,19 @@ export default class AgentData extends CharacterData {
|
||||
static defineSchema() {
|
||||
const superData = super.defineSchema();
|
||||
|
||||
const { health: _health, wp: _wp, ...restSuper } = superData;
|
||||
|
||||
return {
|
||||
...superData,
|
||||
...restSuper,
|
||||
health: agentResourceField(10, 10),
|
||||
wp: agentResourceField(10, 10),
|
||||
...HumanSkillsActorData.defineSchema(),
|
||||
schemaVersion: new NumberField({ initial: 2.0 }),
|
||||
sanity: new SchemaField({
|
||||
value: new NumberField({ initial: 100 }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101 }),
|
||||
value: new NumberField({ initial: 100, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: 99, min: 0, integer: true }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101, integer: true }),
|
||||
maxBonus: new NumberField({ initial: 0, integer: true }),
|
||||
adaptations: new SchemaField({
|
||||
violence: new SchemaField({
|
||||
incident1: new BooleanField({ initial: false }),
|
||||
@@ -41,12 +45,18 @@ export default class AgentData extends CharacterData {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
rollTarget: new SchemaField({
|
||||
allSkills: new NumberField({ initial: 0, integer: true }),
|
||||
sanity: new NumberField({ initial: 0, integer: true }),
|
||||
statistics: new NumberField({ initial: 0, integer: true }),
|
||||
}),
|
||||
physical: new SchemaField({
|
||||
description: DGHTMLField(),
|
||||
wounds: new StringField({ initial: "" }),
|
||||
firstAidAttempted: new BooleanField({ initial: false }),
|
||||
exhausted: new BooleanField({ initial: false }),
|
||||
exhaustedPenalty: new NumberField({ initial: -20 }),
|
||||
suppressExhaustion: new BooleanField({ initial: false }),
|
||||
}),
|
||||
biography: new SchemaField({
|
||||
profession: new StringField({ initial: "" }),
|
||||
@@ -66,24 +76,29 @@ export default class AgentData extends CharacterData {
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
prepareStatisticsX5(this.statistics);
|
||||
removeLegacyRitualSkill(this);
|
||||
clampSanityRitualValue(this.sanity);
|
||||
prepareAgentSkillFlags(this.skills);
|
||||
prepareBaseData() {
|
||||
// rollTarget is modified by add-type Active Effects each prepare cycle.
|
||||
this.rollTarget.allSkills = 0;
|
||||
this.rollTarget.sanity = 0;
|
||||
this.rollTarget.statistics = 0;
|
||||
}
|
||||
|
||||
this.wp.max = this.statistics.pow.value;
|
||||
this.health.max = calculateHealthMax(this.statistics);
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
// Pre–final-AE setup only. Post-AE derived fields (max, x5 refresh,
|
||||
// breaking point, etc.) are applied in DeltaGreenActor.prepareData via
|
||||
// refreshDerivedAfterActiveEffects after the final Active Effect phase.
|
||||
prepareStatisticsX5(
|
||||
this.statistics,
|
||||
this.parent?._source?.system?.statistics,
|
||||
);
|
||||
removeLegacyRitualSkill(this);
|
||||
initializeSanityIfUnset(this.sanity, this.statistics);
|
||||
this.sanity.max = 99 - this.skills.unnatural.proficiency;
|
||||
prepareSanityAdaptations(this.sanity.adaptations);
|
||||
prepareBreakingPointHit(this.sanity);
|
||||
|
||||
this.health.protection = computeEquippedArmorProtection(
|
||||
this.parent.items,
|
||||
);
|
||||
this.statistics.str.meleeDamageBonusFormula =
|
||||
calculateMeleeDamageBonusFormula(this.statistics.str.value);
|
||||
|
||||
if (this.physical.exhaustedPenalty > 0) {
|
||||
this.physical.exhaustedPenalty =
|
||||
|
||||
@@ -3,7 +3,12 @@ const { SchemaField, NumberField, StringField, BooleanField } =
|
||||
|
||||
export function skillField(initialValue, label) {
|
||||
return new SchemaField({
|
||||
proficiency: new NumberField({ initial: initialValue }),
|
||||
proficiency: new NumberField({
|
||||
initial: initialValue,
|
||||
min: 0,
|
||||
max: 99,
|
||||
integer: true,
|
||||
}),
|
||||
label: new StringField({ initial: label }),
|
||||
failure: new BooleanField({ initial: false }),
|
||||
});
|
||||
@@ -11,15 +16,26 @@ export function skillField(initialValue, label) {
|
||||
|
||||
export function resourceField(initialValue, initialMax) {
|
||||
return new SchemaField({
|
||||
min: new NumberField({ initial: 0 }),
|
||||
value: new NumberField({ initial: initialValue }),
|
||||
max: new NumberField({ initial: initialMax }),
|
||||
min: new NumberField({ initial: 0, min: 0, integer: true }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: initialMax, min: 0, integer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent resources that support Active Effect maximum bonuses. */
|
||||
export function agentResourceField(initialValue, initialMax) {
|
||||
return new SchemaField({
|
||||
min: new NumberField({ initial: 0, min: 0, integer: true }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: initialMax, min: 0, integer: true }),
|
||||
maxBonus: new NumberField({ initial: 0, integer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export function statisticsField(initialValue) {
|
||||
return new SchemaField({
|
||||
value: new NumberField({ initial: initialValue }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
modifier: new NumberField({ initial: 0, integer: true }),
|
||||
distinguishing_feature: new StringField({ initial: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default class HumanSkillsActorData extends TypeDataModel {
|
||||
swim: skillField(20, "Swim"),
|
||||
unarmed_combat: skillField(40, "Unarmed Combat"),
|
||||
unnatural: new SchemaField({
|
||||
proficiency: new NumberField({ initial: 0 }),
|
||||
proficiency: new NumberField({ initial: 0, min: 0, max: 99, integer: true }),
|
||||
label: new StringField({ initial: "Unnatural" }),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
calculateHealthMax,
|
||||
calculateMeleeDamageBonusFormula,
|
||||
clampSanityRitualValue,
|
||||
cleanDerivedNumber,
|
||||
computeEquippedArmorProtection,
|
||||
initializeSanityIfUnset,
|
||||
prepareStatisticsX5,
|
||||
@@ -24,8 +25,9 @@ export default class NPCData extends CharacterData {
|
||||
...HumanSkillsActorData.defineSchema(),
|
||||
schemaVersion: new NumberField({ initial: 2.0 }),
|
||||
sanity: new SchemaField({
|
||||
value: new NumberField({ initial: 100 }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101 }),
|
||||
value: new NumberField({ initial: 100, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: 99, min: 0, integer: true }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101, integer: true }),
|
||||
}),
|
||||
biography: new SchemaField({
|
||||
profession: new StringField({ initial: "" }),
|
||||
@@ -38,13 +40,28 @@ export default class NPCData extends CharacterData {
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
prepareStatisticsX5(this.statistics);
|
||||
const sourceStatistics = this.parent?._source?.system?.statistics;
|
||||
prepareStatisticsX5(this.statistics, sourceStatistics);
|
||||
initializeSanityIfUnset(this.sanity, this.statistics);
|
||||
this.sanity.max = 99 - this.skills.unnatural.proficiency;
|
||||
this.wp.max = this.statistics.pow.value;
|
||||
this.health.max = calculateHealthMax(this.statistics);
|
||||
this.sanity.max = cleanDerivedNumber(
|
||||
this,
|
||||
"sanity.max",
|
||||
99 - this.skills.unnatural.proficiency,
|
||||
);
|
||||
this.wp.max = cleanDerivedNumber(
|
||||
this,
|
||||
"wp.max",
|
||||
this.statistics.pow.effectiveValue ?? this.statistics.pow.value,
|
||||
);
|
||||
this.health.max = cleanDerivedNumber(
|
||||
this,
|
||||
"health.max",
|
||||
calculateHealthMax(this.statistics, sourceStatistics),
|
||||
);
|
||||
this.statistics.str.meleeDamageBonusFormula =
|
||||
calculateMeleeDamageBonusFormula(this.statistics.str.value);
|
||||
calculateMeleeDamageBonusFormula(
|
||||
this.statistics.str.effectiveValue ?? this.statistics.str.value,
|
||||
);
|
||||
removeLegacyRitualSkill(this);
|
||||
clampSanityRitualValue(this.sanity);
|
||||
this.health.protection = computeEquippedArmorProtection(this.parent.items);
|
||||
|
||||
@@ -3,6 +3,7 @@ import CharacterData from "./base/character.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import {
|
||||
calculateHealthMax,
|
||||
cleanDerivedNumber,
|
||||
computeEquippedArmorProtection,
|
||||
prepareStatisticsX5,
|
||||
removeLegacyRitualSkill,
|
||||
@@ -33,14 +34,23 @@ export default class UnnaturalData extends CharacterData {
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
prepareStatisticsX5(this.statistics);
|
||||
const sourceStatistics = this.parent?._source?.system?.statistics;
|
||||
prepareStatisticsX5(this.statistics, sourceStatistics);
|
||||
|
||||
if (this.wp.maxNeedsUpdate) {
|
||||
this.wp.max = this.statistics.pow.value;
|
||||
this.wp.max = cleanDerivedNumber(
|
||||
this,
|
||||
"wp.max",
|
||||
this.statistics.pow.effectiveValue ?? this.statistics.pow.value,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.health.maxNeedsUpdate) {
|
||||
this.health.max = calculateHealthMax(this.statistics);
|
||||
this.health.max = cleanDerivedNumber(
|
||||
this,
|
||||
"health.max",
|
||||
calculateHealthMax(this.statistics, sourceStatistics),
|
||||
);
|
||||
}
|
||||
|
||||
removeLegacyRitualSkill(this);
|
||||
|
||||
@@ -9,6 +9,7 @@ export default class MotivationItemData extends foundry.abstract.TypeDataModel {
|
||||
disorder: new StringField({ initial: "" }),
|
||||
crossedOut: new BooleanField({ initial: false }),
|
||||
disorderCured: new BooleanField({ initial: false }),
|
||||
acuteEpisode: new BooleanField({ initial: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,17 @@ import BondItemData from "./data/item/bond.js";
|
||||
import GearItemData from "./data/item/gear.js";
|
||||
import TomeItemData from "./data/item/tome.js";
|
||||
import RitualItemData from "./data/item/ritual.js";
|
||||
import DGActiveEffect from "./documents/dg-active-effect.js";
|
||||
import DGActiveEffectConfig from "./applications/dg-active-effect-config.js";
|
||||
import DGActiveEffectTypeDataModel from "./data/active-effect/dg-active-effect-data.js";
|
||||
import { syncExhaustionEffect } from "./utils/exhaustion-effect.js";
|
||||
import {
|
||||
pruneAllAgentsExpiredStimulants,
|
||||
pruneExpiredStimulantEffects,
|
||||
} from "./utils/stimulant-effect.js";
|
||||
|
||||
const { Actors, Items } = foundry.documents.collections;
|
||||
const { DocumentSheetConfig } = foundry.applications.apps;
|
||||
|
||||
Hooks.once("init", async () => {
|
||||
Object.assign(CONFIG.Actor.dataModels, {
|
||||
@@ -60,9 +69,23 @@ Hooks.once("init", async () => {
|
||||
ritual: RitualItemData,
|
||||
});
|
||||
|
||||
CONFIG.ActiveEffect.documentClass = DGActiveEffect;
|
||||
CONFIG.ActiveEffect.dataModels.base = DGActiveEffectTypeDataModel;
|
||||
|
||||
DocumentSheetConfig.registerSheet(
|
||||
DGActiveEffect,
|
||||
DG.ID,
|
||||
DGActiveEffectConfig,
|
||||
{
|
||||
makeDefault: true,
|
||||
label: "DG.ActiveEffects.SheetLabel",
|
||||
},
|
||||
);
|
||||
|
||||
game.deltagreen = {
|
||||
DeltaGreenActor,
|
||||
DeltaGreenItem,
|
||||
DGActiveEffect,
|
||||
rollItemMacro,
|
||||
rollItemSkillCheckMacro,
|
||||
rollSkillMacro,
|
||||
@@ -129,6 +152,66 @@ Hooks.once("init", async () => {
|
||||
registerHandlebarsHelpers();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Actor|undefined}
|
||||
*/
|
||||
function getAgentFromEffect(effect) {
|
||||
const { parent } = effect;
|
||||
if (parent?.documentName === "Actor" && parent.type === "agent") {
|
||||
return parent;
|
||||
}
|
||||
if (parent?.documentName === "Item") {
|
||||
const { actor } = parent;
|
||||
if (actor?.type === "agent") return actor;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function onAgentActiveEffectChange(effect) {
|
||||
const actor = getAgentFromEffect(effect);
|
||||
if (!actor) return;
|
||||
actor.reset();
|
||||
if (game.user.isActiveGM) await pruneExpiredStimulantEffects(actor);
|
||||
await syncExhaustionEffect(actor);
|
||||
}
|
||||
|
||||
Hooks.on("createActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("updateActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("deleteActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("updateWorldTime", () => {
|
||||
if (!game.user.isActiveGM) return;
|
||||
pruneAllAgentsExpiredStimulants();
|
||||
});
|
||||
|
||||
Hooks.on("updateItem", (item, changes) => {
|
||||
const flat = foundry.utils.flattenObject(changes);
|
||||
const transferStateKeys = [
|
||||
"system.equipped",
|
||||
"system.acuteEpisode",
|
||||
"system.crossedOut",
|
||||
"system.disorderCured",
|
||||
];
|
||||
if (!transferStateKeys.some((key) => key in flat)) return;
|
||||
const { actor } = item;
|
||||
if (!actor) return;
|
||||
actor.reset();
|
||||
if (actor.sheet?.rendered) actor.sheet.render();
|
||||
});
|
||||
|
||||
Hooks.once("ready", async () => {
|
||||
await runWorldMigration();
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Item|undefined}
|
||||
*/
|
||||
function resolveTransferItem(effect) {
|
||||
const item = effect.parent;
|
||||
if (item?.documentName !== "Item") return undefined;
|
||||
|
||||
const actor = item.actor;
|
||||
if (actor?.documentName === "Actor") {
|
||||
return actor.items.get(item.id) ?? item;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @returns {{ inactive: boolean, reasonKey: string|null }}
|
||||
*/
|
||||
export function getTransferSuppressionState(item) {
|
||||
switch (item.type) {
|
||||
case "weapon":
|
||||
case "armor":
|
||||
case "gear":
|
||||
if (item.system.equipped !== true) {
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedNotEquipped",
|
||||
};
|
||||
}
|
||||
return { inactive: false, reasonKey: null };
|
||||
case "motivation":
|
||||
if (item.system.acuteEpisode !== true) {
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedAcuteEpisode",
|
||||
};
|
||||
}
|
||||
return { inactive: false, reasonKey: null };
|
||||
case "tome":
|
||||
case "ritual":
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedUnsupportedItem",
|
||||
};
|
||||
case "bond":
|
||||
return { inactive: false, reasonKey: null };
|
||||
default:
|
||||
return { inactive: false, reasonKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @returns {boolean} True when a transfer effect on this item should not apply.
|
||||
*/
|
||||
export function isTransferEffectInactive(item) {
|
||||
return getTransferSuppressionState(item).inactive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {string|null} Localization key for why a transfer effect is inactive.
|
||||
*/
|
||||
export function getTransferSuppressionReasonKey(effect) {
|
||||
const item = resolveTransferItem(effect);
|
||||
if (!item || !effect.transfer) return null;
|
||||
return getTransferSuppressionState(item).reasonKey;
|
||||
}
|
||||
|
||||
/** @extends {foundry.documents.ActiveEffect} */
|
||||
export default class DGActiveEffect extends ActiveEffect {
|
||||
/** @override */
|
||||
get isSuppressed() {
|
||||
if (this.disabled || this.duration?.expired) return true;
|
||||
if (this.system?.isSuppressed) return true;
|
||||
if (!this.transfer) return false;
|
||||
|
||||
const item = resolveTransferItem(this);
|
||||
if (!item) return false;
|
||||
|
||||
return isTransferEffectInactive(item);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,6 @@ export function rollSkillMacro(skillName) {
|
||||
{ rollType: "skill", key: skillName, actor },
|
||||
);
|
||||
return actor.sheet.processRoll({}, roll);
|
||||
// sendPercentileTestToChat(actor, translatedSkillLabel, skill.proficiency);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-24
@@ -294,7 +294,7 @@ export class DGPercentileRoll extends DGRoll {
|
||||
}
|
||||
if (typedSkillKeys.includes(this.key)) {
|
||||
const skill = actorData.typedSkills[this.key];
|
||||
target = skill.proficiency;
|
||||
target = skill.targetProficiency ?? skill.proficiency;
|
||||
localizedKey = `${skill.group} (${skill.label})`;
|
||||
skillPath = `system.typedSkills.${this.key}`;
|
||||
}
|
||||
@@ -327,19 +327,17 @@ export class DGPercentileRoll extends DGRoll {
|
||||
.toUpperCase()}]</b> ${endOfLabel}`
|
||||
: `${startOfLabel}</b><br> ${endOfLabel}%`;
|
||||
|
||||
const { isExhausted, exhaustedCheckPenalty } = this.exhausted;
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
|
||||
if (this.modifier || isExhausted) {
|
||||
if (this.modifier || rollTargetModifier) {
|
||||
label += ` (${this.target}%`;
|
||||
|
||||
if (this.modifier) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`;
|
||||
}
|
||||
|
||||
if (isExhausted) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(
|
||||
exhaustedCheckPenalty,
|
||||
)}%`;
|
||||
if (rollTargetModifier) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(rollTargetModifier)}%`;
|
||||
}
|
||||
|
||||
label += `)`;
|
||||
@@ -348,23 +346,32 @@ export class DGPercentileRoll extends DGRoll {
|
||||
return label;
|
||||
}
|
||||
|
||||
get exhausted() {
|
||||
let isExhausted = false;
|
||||
let exhaustedCheckPenalty = -20;
|
||||
/**
|
||||
* Active Effect roll-target modifier for this roll type (excludes luck).
|
||||
* @returns {number}
|
||||
*/
|
||||
get rollTargetModifier() {
|
||||
if (this.type === "luck") return 0;
|
||||
|
||||
try {
|
||||
// I suspect (but am not entirely certain) that being tired doesn't make you less lucky)
|
||||
if (this.type !== "luck") {
|
||||
isExhausted = this.actor.system.physical.exhausted;
|
||||
exhaustedCheckPenalty = this.actor.system.physical.exhaustedPenalty;
|
||||
exhaustedCheckPenalty = -1 * Math.abs(exhaustedCheckPenalty);
|
||||
}
|
||||
} catch {
|
||||
isExhausted = false;
|
||||
exhaustedCheckPenalty = -20;
|
||||
}
|
||||
const rollTarget = this.actor.system.rollTarget;
|
||||
if (!rollTarget) return 0;
|
||||
|
||||
return { isExhausted, exhaustedCheckPenalty };
|
||||
if (this.type === "sanity") return Number(rollTarget.sanity) || 0;
|
||||
if (this.type === "stat") return Number(rollTarget.statistics) || 0;
|
||||
return Number(rollTarget.allSkills) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use rollTargetModifier via Active Effects instead. */
|
||||
get exhausted() {
|
||||
const modifier = this.rollTargetModifier;
|
||||
return {
|
||||
isExhausted: modifier !== 0,
|
||||
exhaustedCheckPenalty: modifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,7 +452,7 @@ export class DGPercentileRoll extends DGRoll {
|
||||
get effectiveTarget() {
|
||||
let target = 1;
|
||||
|
||||
const { isExhausted, exhaustedCheckPenalty } = this.exhausted;
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
|
||||
if (!this.target || Number.isNaN(this.target)) {
|
||||
return null;
|
||||
@@ -453,8 +460,8 @@ export class DGPercentileRoll extends DGRoll {
|
||||
|
||||
target = parseInt(this.target);
|
||||
|
||||
if (isExhausted) {
|
||||
target += exhaustedCheckPenalty;
|
||||
if (rollTargetModifier) {
|
||||
target += rollTargetModifier;
|
||||
}
|
||||
|
||||
if (this.modifier && !Number.isNaN(this.modifier)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DG, { BASE_TEMPLATE_PATH } from "../config.js";
|
||||
import { packSkillGroupsIntoColumns } from "../utils/skill-layout.js";
|
||||
import { createAgentResourceChatMessage } from "../utils/resource-chat.js";
|
||||
import {
|
||||
createSkillImprovementChatMessage,
|
||||
evaluateSkillImprovementRolls,
|
||||
@@ -7,15 +8,25 @@ import {
|
||||
} from "../roll/skill-improvement-roll.js";
|
||||
import DGActorSheet from "./base-actor-sheet.js";
|
||||
import ActorEditStatForm from "../applications/edit-stats.js";
|
||||
import EffectsTabMixin from "./mixins/effects-tab-mixin.js";
|
||||
import {
|
||||
applyStimulantEffect,
|
||||
clearStimulantEffects,
|
||||
getEffectiveSuppressExhaustion,
|
||||
hasActiveStimulantEffect,
|
||||
} from "../utils/stimulant-effect.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @extends {DGActorSheet} */
|
||||
export default class DGAgentSheet extends DGActorSheet {
|
||||
const AgentSheetBase = EffectsTabMixin(DGActorSheet);
|
||||
|
||||
/** @extends {AgentSheetBase} */
|
||||
export default class DGAgentSheet extends AgentSheetBase {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = /** @type {const} */ ({
|
||||
classes: ["agent-sheet"],
|
||||
position: { width: 958, height: 700 },
|
||||
position: { width: 978, height: 720 },
|
||||
actions: {
|
||||
// Resets.
|
||||
clearBondDamage: DGAgentSheet._clearBondDamage,
|
||||
@@ -30,6 +41,15 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
this._syncCustomSkillsEditModeUi();
|
||||
return this.render();
|
||||
},
|
||||
createEffect: AgentSheetBase.createEffect,
|
||||
openEffect: AgentSheetBase.openEffect,
|
||||
deleteEffect: AgentSheetBase.deleteEffect,
|
||||
toggleEffect: AgentSheetBase.toggleEffect,
|
||||
openEffectOrigin: AgentSheetBase.openEffectOrigin,
|
||||
toggleAcuteEpisode: DGAgentSheet._toggleAcuteEpisode,
|
||||
exhaustAgent: DGAgentSheet._exhaustAgent,
|
||||
restAgent: DGAgentSheet._restAgent,
|
||||
takeStimulants: DGAgentSheet._takeStimulants,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,16 +67,27 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
{ id: "motivations" },
|
||||
{ id: "gear" },
|
||||
{ id: "personal" },
|
||||
{ id: "effects" },
|
||||
{ id: "about", icon: "fas fa-question-circle", label: "" },
|
||||
],
|
||||
},
|
||||
leftBarRestSanity: {
|
||||
initial: "rest",
|
||||
labelPrefix: "DG.Navigation.Agent.LeftBar",
|
||||
tabs: [{ id: "rest" }, { id: "sanity" }],
|
||||
},
|
||||
});
|
||||
|
||||
/** @override */
|
||||
static PARTS = /** @type {const} */ ({
|
||||
leftBar: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/left-bar.html`,
|
||||
templates: [`${this.TEMPLATE_PATH}/partials/sanity-partial.html`],
|
||||
templates: [
|
||||
`${this.TEMPLATE_PATH}/partials/sanity-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-exhaustion-section.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-rest-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-sanity-tab.html`,
|
||||
],
|
||||
},
|
||||
tabs: this.BASE_PARTS.tabs,
|
||||
rightBar: {
|
||||
@@ -67,6 +98,7 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
`${this.TEMPLATE_PATH}/parts/motivations-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/gear-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/personal-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/effects-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/about-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/custom-skills-partial-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/bonds-section-partial.html`,
|
||||
@@ -92,7 +124,9 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
_prepareTabs(group) {
|
||||
const tabs = super._prepareTabs(group);
|
||||
|
||||
if (!this.actor.limited || this.actor.type !== "agent") return tabs;
|
||||
if (!this.actor.limited || this.actor.type !== "agent" || group !== "primary") {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
return {
|
||||
personal: { ...tabs.personal, active: true, cssClass: "active" },
|
||||
@@ -103,14 +137,33 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
|
||||
context.tabs = this._prepareTabs("primary");
|
||||
context.leftBarRestSanityTabs = this._prepareTabs("leftBarRestSanity");
|
||||
|
||||
if (this.actor.type !== "agent") return context;
|
||||
|
||||
context.typedSkillColumns = this._prepareTypedSkillColumns();
|
||||
context.customSkillsEditMode = this._customSkillsEditMode;
|
||||
context.physicalUi = this._preparePhysicalUi();
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
_preparePhysicalUi() {
|
||||
const { physical } = this.actor.system;
|
||||
const isExhausted = DGAgentSheet._isActorExhausted(this.actor);
|
||||
const stimulantActive = hasActiveStimulantEffect(this.actor);
|
||||
const suppressExhaustion = getEffectiveSuppressExhaustion(this.actor);
|
||||
return {
|
||||
isExhausted,
|
||||
displayPenalty: isExhausted ? physical.exhaustedPenalty : 0,
|
||||
suppressExhaustionInactive: !isExhausted,
|
||||
suppressExhaustionInputDisabled: !isExhausted || stimulantActive,
|
||||
suppressExhaustionChecked: isExhausted && suppressExhaustion,
|
||||
};
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_sortCustomSkills() {
|
||||
// Agent sheet uses typedSkillColumns instead of sortedCustomSkills.
|
||||
@@ -130,6 +183,21 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async _toggleAcuteEpisode(event, target) {
|
||||
event.preventDefault();
|
||||
const itemId =
|
||||
target.dataset.itemId ?? target.closest("[data-item-id]")?.dataset?.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
if (!item || item.type !== "motivation") return;
|
||||
await item.update({
|
||||
"system.acuteEpisode": !item.system.acuteEpisode,
|
||||
});
|
||||
}
|
||||
|
||||
static _clearBondDamage() {
|
||||
for (const bond of this.actor.itemTypes.bond) {
|
||||
if (!bond.system.hasBeenDamagedSinceLastHomeScene) continue;
|
||||
@@ -145,8 +213,11 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
* @returns {void}
|
||||
*/
|
||||
static _resetBreakingPoint() {
|
||||
const pow =
|
||||
this.actor.system.statistics.pow.effectiveValue ??
|
||||
this.actor.system.statistics.pow.value;
|
||||
const currentBreakingPoint = Math.max(
|
||||
this.actor.system.sanity.value - this.actor.system.statistics.pow.value,
|
||||
this.actor.system.sanity.value - pow,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -247,6 +318,181 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
form.render(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} raw
|
||||
* @returns {number}
|
||||
*/
|
||||
static _normalizeExhaustionPenalty(raw) {
|
||||
const value = Number(raw);
|
||||
if (Number.isNaN(value) || value === 0) return -20;
|
||||
return value > 0 ? -1 * Math.abs(value) : value;
|
||||
}
|
||||
|
||||
static async _exhaustAgent() {
|
||||
const actor = this.actor;
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/exhaust-agent.html`,
|
||||
{
|
||||
penalty: actor.system.physical.exhaustedPenalty ?? -20,
|
||||
},
|
||||
);
|
||||
|
||||
let penalty = null;
|
||||
const confirmed = await DialogV2.wait({
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.ExhaustDialogTitle"),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
action: "rollWpLoss",
|
||||
label: game.i18n.localize("DG.Physical.RollWillpowerLoss"),
|
||||
default: true,
|
||||
icon: "fas fa-dice",
|
||||
callback: (_event, _button, dialog) => {
|
||||
const input = dialog.element.querySelector('[name="exhaustionPenalty"]');
|
||||
penalty = DGAgentSheet._normalizeExhaustionPenalty(input?.value);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!confirmed || penalty === null) return;
|
||||
|
||||
const wpRoll = await new Roll("1d6").evaluate();
|
||||
const loss = wpRoll.total;
|
||||
const currentWp = Number(actor.system.wp.value) || 0;
|
||||
const maxWp = Number(actor.system.wp.max) || 0;
|
||||
const newWp = Math.max(0, currentWp - loss);
|
||||
|
||||
await actor.update({
|
||||
"system.physical.exhausted": true,
|
||||
"system.physical.exhaustedPenalty": penalty,
|
||||
"system.wp.value": newWp,
|
||||
});
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
contentKey: "DG.Physical.Chat.Exhausted",
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
loss,
|
||||
current: newWp,
|
||||
max: maxWp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async _restAgent() {
|
||||
const actor = this.actor;
|
||||
|
||||
if (getEffectiveSuppressExhaustion(actor)) {
|
||||
ui.notifications.warn(
|
||||
game.i18n.format("DG.Physical.RestBlockedWhileSuppressed", {
|
||||
name: actor.name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wpRoll = await new Roll("1d6").evaluate();
|
||||
const gain = wpRoll.total;
|
||||
const currentWp = Number(actor.system.wp.value) || 0;
|
||||
const maxWp = Number(actor.system.wp.max) || 0;
|
||||
const newWp = Math.min(maxWp, currentWp + gain);
|
||||
|
||||
await clearStimulantEffects(actor);
|
||||
|
||||
await actor.update({
|
||||
"system.physical.exhausted": false,
|
||||
"system.physical.suppressExhaustion": false,
|
||||
"system.wp.value": newWp,
|
||||
});
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
contentKey: "DG.Physical.Chat.Rested",
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
gain,
|
||||
current: newWp,
|
||||
max: maxWp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static _isActorExhausted(actor) {
|
||||
return Boolean(actor._source?.system?.physical?.exhausted);
|
||||
}
|
||||
|
||||
static async _takeStimulants() {
|
||||
const actor = this.actor;
|
||||
|
||||
if (!DGAgentSheet._isActorExhausted(actor)) {
|
||||
ui.notifications.info(
|
||||
game.i18n.format("DG.Physical.StimulantsNotExhaustedWarning", {
|
||||
name: actor.name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/take-stimulants.html`,
|
||||
{},
|
||||
);
|
||||
|
||||
const choice = await DialogV2.wait({
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.StimulantsDialogTitle"),
|
||||
},
|
||||
classes: ["stimulants-dialog-app"],
|
||||
buttons: [
|
||||
{
|
||||
action: "regular",
|
||||
label: game.i18n.localize("DG.Physical.StimulantsRegular"),
|
||||
default: true,
|
||||
callback: () => "regular",
|
||||
},
|
||||
{
|
||||
action: "hard",
|
||||
label: game.i18n.localize("DG.Physical.StimulantsHard"),
|
||||
callback: () => "hard",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!choice) return;
|
||||
|
||||
const formula = choice === "hard" ? "2d6" : "1d6";
|
||||
const hoursRoll = await new Roll(formula).evaluate();
|
||||
const hours = hoursRoll.total;
|
||||
const contentKey =
|
||||
choice === "hard"
|
||||
? "DG.Physical.Chat.StimulantsHard"
|
||||
: "DG.Physical.Chat.StimulantsRegular";
|
||||
|
||||
const appliedHours = await applyStimulantEffect(actor, hours);
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
contentKey,
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
hours: appliedHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs through the whole process of improving skills,
|
||||
* i.e., prompting the user, rolling, and creating the chat card.
|
||||
|
||||
@@ -5,10 +5,12 @@ import RollSheetMixin from "./mixins/roll-sheet-mixin.js";
|
||||
import SkillPrepMixin from "./mixins/skill-prep-mixin.js";
|
||||
import SpecialTrainingMixin from "./mixins/special-training-mixin.js";
|
||||
import TypedSkillMixin from "./mixins/typed-skill-mixin.js";
|
||||
import AeInputMixin from "./mixins/ae-input-mixin.js";
|
||||
|
||||
const { ActorSheetV2 } = foundry.applications.sheets;
|
||||
|
||||
const ComposedActorSheetBase = composeMixins(
|
||||
AeInputMixin,
|
||||
SpecialTrainingMixin,
|
||||
TypedSkillMixin,
|
||||
ItemListMixin,
|
||||
@@ -125,8 +127,6 @@ export default class DGActorSheet extends ComposedActorSheetBase {
|
||||
if (!this.actor.limited) return;
|
||||
|
||||
options.parts = ["header"];
|
||||
|
||||
if (this.actor.type === "agent") options.parts.push("tabs", "bio");
|
||||
}
|
||||
|
||||
/** @override */
|
||||
@@ -135,15 +135,6 @@ export default class DGActorSheet extends ComposedActorSheetBase {
|
||||
this._setRightClickListeners();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_prepareTabs(group) {
|
||||
const tabs = super._prepareTabs(group);
|
||||
|
||||
if (!this.actor.limited || this.actor.type !== "agent") return tabs;
|
||||
|
||||
return { bio: { ...tabs.bio, active: true, cssClass: "active" } };
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _onRender(context, options) {
|
||||
await super._onRender(context, options);
|
||||
@@ -156,6 +147,7 @@ export default class DGActorSheet extends ComposedActorSheetBase {
|
||||
|
||||
this._setupItemDragListeners();
|
||||
this._tooltipsSettings(element);
|
||||
this._bindAeBackedInputs(element);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
|
||||
@@ -1,60 +1,83 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config.js";
|
||||
import DGSheetMixin from "./base-sheet.js";
|
||||
import EffectsTabMixin from "./mixins/effects-tab-mixin.js";
|
||||
import { createDGRollFromDataset, processDGRoll } from "../roll/roll.js";
|
||||
|
||||
const { ItemSheetV2 } = foundry.applications.sheets;
|
||||
const ITEM_PARTS_PATH = `${BASE_TEMPLATE_PATH}/item/parts`;
|
||||
const TAB_NAV_TEMPLATE = "templates/generic/tab-navigation.hbs";
|
||||
|
||||
/** Tab group id used by item sheet templates (`data-group="primary"`). */
|
||||
const ITEM_TAB_GROUP = "primary";
|
||||
|
||||
const EFFECTS_TAB = /** @type {const} */ ({
|
||||
id: "effects",
|
||||
label: "DG.Navigation.Item.effects",
|
||||
});
|
||||
|
||||
const EFFECTS_PART = /** @type {const} */ ({
|
||||
effects: {
|
||||
template: `${ITEM_PARTS_PATH}/effects-tab.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
});
|
||||
|
||||
const TABS_PART = /** @type {const} */ ({
|
||||
tabs: { template: TAB_NAV_TEMPLATE },
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {string} config.header
|
||||
* @param {string} config.description
|
||||
* @param {string} config.descriptionLabel
|
||||
* @returns {object}
|
||||
*/
|
||||
function activeEffectItemLayout({ header, description, descriptionLabel }) {
|
||||
return {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
tabs: [
|
||||
{ id: "description", label: descriptionLabel },
|
||||
EFFECTS_TAB,
|
||||
],
|
||||
},
|
||||
parts: {
|
||||
header: { template: header },
|
||||
...TABS_PART,
|
||||
description: { template: description, scrollable: [""] },
|
||||
...EFFECTS_PART,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Tab and part layout per item type. */
|
||||
const ITEM_SHEET_LAYOUT = /** @type {const} */ ({
|
||||
weapon: {
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/weapon-header.html` },
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/weapon-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
gear: {
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/gear-header.html` },
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/gear-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
armor: {
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/armor-header.html` },
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/armor-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
bond: {
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/bond-header.html` },
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/bond-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
motivation: {
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/motivation-header.html` },
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/motivation-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
weapon: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/weapon-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/weapon-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Weapons.Description",
|
||||
}),
|
||||
gear: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/gear-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/gear-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Gear.Description",
|
||||
}),
|
||||
armor: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/armor-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/armor-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Armor.Description",
|
||||
}),
|
||||
bond: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/bond-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/bond-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Bonds.Description",
|
||||
}),
|
||||
motivation: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/motivation-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/motivation-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Motivations.Description",
|
||||
}),
|
||||
tome: {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
@@ -69,7 +92,7 @@ const ITEM_SHEET_LAYOUT = /** @type {const} */ ({
|
||||
},
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/tome-header.html` },
|
||||
tabs: { template: "templates/generic/tab-navigation.hbs" },
|
||||
...TABS_PART,
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/tome-description.html`,
|
||||
scrollable: [""],
|
||||
@@ -94,7 +117,7 @@ const ITEM_SHEET_LAYOUT = /** @type {const} */ ({
|
||||
},
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/ritual-header.html` },
|
||||
tabs: { template: "templates/generic/tab-navigation.hbs" },
|
||||
...TABS_PART,
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/ritual-description.html`,
|
||||
scrollable: [""],
|
||||
@@ -107,10 +130,12 @@ const ITEM_SHEET_LAYOUT = /** @type {const} */ ({
|
||||
},
|
||||
});
|
||||
|
||||
const ItemSheetBase = EffectsTabMixin(DGSheetMixin(ItemSheetV2));
|
||||
|
||||
/**
|
||||
* @extends {DGSheetMixin(ItemSheetV2)}
|
||||
* @extends {ItemSheetBase}
|
||||
*/
|
||||
export default class DGItemSheet extends DGSheetMixin(ItemSheetV2) {
|
||||
export default class DGItemSheet extends ItemSheetBase {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = /** @type {const} */ ({
|
||||
classes: ["item"],
|
||||
@@ -118,6 +143,10 @@ export default class DGItemSheet extends DGSheetMixin(ItemSheetV2) {
|
||||
actions: {
|
||||
roll: DGItemSheet._onRoll,
|
||||
toggleTomeRevealed: DGItemSheet._toggleTomeRevealed,
|
||||
createEffect: ItemSheetBase.createEffect,
|
||||
openEffect: ItemSheetBase.openEffect,
|
||||
deleteEffect: ItemSheetBase.deleteEffect,
|
||||
toggleEffect: ItemSheetBase.toggleEffect,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Copy typographic layout from the real input so the overlay matches except color. */
|
||||
function syncAeDisplayTypography(input, display) {
|
||||
const cs = getComputedStyle(input);
|
||||
display.style.inset = "auto";
|
||||
display.style.top = cs.borderTopWidth;
|
||||
display.style.left = cs.borderLeftWidth;
|
||||
display.style.right = cs.borderRightWidth;
|
||||
display.style.bottom = cs.borderBottomWidth;
|
||||
display.style.font = cs.font;
|
||||
display.style.lineHeight = cs.lineHeight;
|
||||
display.style.padding = cs.padding;
|
||||
display.style.textAlign = cs.textAlign;
|
||||
display.style.letterSpacing = cs.letterSpacing;
|
||||
display.style.boxSizing = cs.boxSizing;
|
||||
}
|
||||
|
||||
/** @type {WeakMap<HTMLElement, ResizeObserver>} */
|
||||
const hostObservers = new WeakMap();
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function AeInputMixin(Base) {
|
||||
return class AeInputHost extends Base {
|
||||
/** @param {HTMLElement} root */
|
||||
_bindAeBackedInputs(root) {
|
||||
for (const host of root.querySelectorAll(".ae-input-host")) {
|
||||
hostObservers.get(host)?.disconnect();
|
||||
|
||||
const input = host.querySelector("input");
|
||||
const display = host.querySelector(".ae-input-display");
|
||||
if (!input || !display) continue;
|
||||
|
||||
const modifier = Number(display.dataset.aeModifier) || 0;
|
||||
|
||||
const syncTypography = () => syncAeDisplayTypography(input, display);
|
||||
|
||||
const syncDisplay = () => {
|
||||
if (host.matches(":focus-within")) return;
|
||||
const base = Number(input.value);
|
||||
if (Number.isFinite(base)) {
|
||||
const effective = Math.max(0, Math.round(base + modifier));
|
||||
display.textContent = String(effective);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("blur", () => {
|
||||
syncTypography();
|
||||
syncDisplay();
|
||||
});
|
||||
|
||||
syncTypography();
|
||||
requestAnimationFrame(syncTypography);
|
||||
syncDisplay();
|
||||
|
||||
const observer = new ResizeObserver(syncTypography);
|
||||
observer.observe(input);
|
||||
hostObservers.set(host, observer);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { SUPPORTED_ITEM_TYPES } from "../../active-effect/effect-fields.js";
|
||||
import { getTransferSuppressionReasonKey } from "../../documents/dg-active-effect.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function EffectsTabMixin(Base) {
|
||||
return class EffectsTabHost extends Base {
|
||||
/** @returns {Actor|Item} */
|
||||
get _effectsParent() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
_getEffectDocument(target) {
|
||||
const effectId = target.closest("[data-effect-id]")?.dataset?.effectId;
|
||||
if (!effectId) return undefined;
|
||||
|
||||
const parent = this._effectsParent;
|
||||
if (parent.documentName === "Actor") {
|
||||
if (target.closest("[data-parent-id]")?.dataset?.parentId) {
|
||||
const itemId = target.closest("[data-parent-id]").dataset.parentId;
|
||||
return parent.items.get(itemId)?.effects.get(effectId);
|
||||
}
|
||||
return parent.effects.get(effectId);
|
||||
}
|
||||
|
||||
return parent.effects.get(effectId);
|
||||
}
|
||||
|
||||
/** @returns {Promise<{ temporary: object[], permanent: object[] }>} */
|
||||
async _prepareSheetEffects() {
|
||||
const temporary = [];
|
||||
const permanent = [];
|
||||
const parent = this._effectsParent;
|
||||
|
||||
const effects =
|
||||
parent.documentName === "Actor"
|
||||
? parent.allApplicableEffects()
|
||||
: parent.effects;
|
||||
|
||||
for (const effect of effects) {
|
||||
const entry = {
|
||||
id: effect.id,
|
||||
name: effect.name,
|
||||
img: effect.img,
|
||||
disabled: effect.disabled,
|
||||
active: effect.active,
|
||||
transfer: effect.transfer,
|
||||
suppressionReasonKey: getTransferSuppressionReasonKey(effect),
|
||||
duration: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
if (effect.parent !== parent && parent.documentName === "Actor") {
|
||||
entry.source = {
|
||||
id: effect.parent.id,
|
||||
name: effect.parent.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (effect.isTemporary) {
|
||||
entry.duration = {
|
||||
label: effect.duration?.label ?? "",
|
||||
};
|
||||
temporary.push(entry);
|
||||
} else {
|
||||
permanent.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { temporary, permanent };
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
if (this._shouldPrepareSheetEffects()) {
|
||||
context.sheetEffects = await this._prepareSheetEffects();
|
||||
context.canManageEffects = this.isEditable;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
_shouldPrepareSheetEffects() {
|
||||
const parent = this._effectsParent;
|
||||
if (parent.documentName === "Actor") {
|
||||
return parent.type === "agent";
|
||||
}
|
||||
return SUPPORTED_ITEM_TYPES.has(parent.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async createEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const parent = sheet._effectsParent;
|
||||
const documentClass = getDocumentClass("ActiveEffect");
|
||||
|
||||
const docData = {
|
||||
name: game.i18n.localize("DG.ActiveEffects.DefaultName"),
|
||||
img: "systems/deltagreen/assets/icons/magic-shield.svg",
|
||||
transfer: SUPPORTED_ITEM_TYPES.has(parent.type),
|
||||
};
|
||||
|
||||
await documentClass.create(docData, {
|
||||
parent,
|
||||
renderSheet: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static openEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
sheet._getEffectDocument(target)?.sheet?.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async deleteEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
await sheet._getEffectDocument(target)?.deleteDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async toggleEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const effect = sheet._getEffectDocument(target);
|
||||
if (!effect) return;
|
||||
await effect.update({ disabled: !effect.disabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static openEffectOrigin(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const itemId = target.closest("[data-parent-id]")?.dataset?.parentId;
|
||||
if (!itemId || sheet._effectsParent.documentName !== "Actor") return;
|
||||
const item = sheet._effectsParent.items.get(itemId);
|
||||
item?.sheet?.render({ force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -164,7 +164,9 @@ export default function ItemListMixin(Base) {
|
||||
};
|
||||
|
||||
if (type === "bond") {
|
||||
itemData.system.score = this.actor.system.statistics.cha.value;
|
||||
itemData.system.score =
|
||||
this.actor.system.statistics.cha.effectiveValue ??
|
||||
this.actor.system.statistics.cha.value;
|
||||
}
|
||||
|
||||
return this.actor.createEmbeddedDocuments("Item", [itemData]);
|
||||
|
||||
@@ -42,14 +42,6 @@ export default function SkillPrepMixin(Base) {
|
||||
skill.sortLabel = skill.key;
|
||||
}
|
||||
|
||||
if (this.actor.type === "agent") {
|
||||
skill.hasPendingImprovement =
|
||||
skill.proficiency !== skill.targetProficiency;
|
||||
skill.proficiencyInputClass = skill.hasPendingImprovement
|
||||
? "target-proficiency-mod"
|
||||
: "";
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
(this.actor.type === "npc" || this.actor.type === "unnatural") &&
|
||||
|
||||
@@ -7,5 +7,9 @@ export default async function preloadHandlebarsTemplates() {
|
||||
return foundry.applications.handlebars.loadTemplates([
|
||||
"systems/deltagreen/templates/dialog/modify-percentile-roll.html",
|
||||
"systems/deltagreen/templates/partials/prose-mirror-host.html",
|
||||
"systems/deltagreen/templates/actor/parts/effects-tab.html",
|
||||
"systems/deltagreen/templates/item/parts/effects-tab.html",
|
||||
"systems/deltagreen/templates/active-effect/change-row.html",
|
||||
"systems/deltagreen/templates/actor/partials/ae-backed-input.html",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
applyAgentResourceMaxBonuses,
|
||||
calculateHealthMax,
|
||||
setSkillTargetProficiencies,
|
||||
calculateMeleeDamageBonusFormula,
|
||||
clampSanityRitualValue,
|
||||
prepareAgentSkillFlags,
|
||||
prepareBreakingPointHit,
|
||||
prepareStatisticsX5,
|
||||
} from "./derived-actor-data.js";
|
||||
|
||||
/**
|
||||
* @param {number} current
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function getActiveEffectModificationClass(current, base) {
|
||||
if (current == null || base == null || current === base) return "";
|
||||
return current > base ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach sheet display classes comparing prepared data to persisted source (pre–AE).
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentActiveEffectDisplay(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
const source = actor._source?.system;
|
||||
if (!source) return;
|
||||
|
||||
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
|
||||
const baseValue = source.statistics?.[key]?.value;
|
||||
const effective = stat.effectiveValue ?? stat.value;
|
||||
stat.valueDisplayClass = getActiveEffectModificationClass(
|
||||
effective,
|
||||
baseValue,
|
||||
);
|
||||
stat.statisticAeModifier = stat.valueDisplayClass
|
||||
? effective - (baseValue ?? 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const persistedStats = source.statistics ?? system.statistics;
|
||||
const healthFormulaMax = calculateHealthMax(persistedStats, persistedStats);
|
||||
const wpFormulaMax = persistedStats.pow?.value ?? 0;
|
||||
const persistedUnnatural =
|
||||
source.skills?.unnatural?.proficiency ?? system.skills?.unnatural?.proficiency ?? 0;
|
||||
const sanityFormulaMax = 99 - persistedUnnatural;
|
||||
|
||||
const healthBaseMax = healthFormulaMax + (source.health?.maxBonus ?? 0);
|
||||
system.health.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.health.max,
|
||||
healthBaseMax,
|
||||
);
|
||||
|
||||
const wpBaseMax = wpFormulaMax + (source.wp?.maxBonus ?? 0);
|
||||
system.wp.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.wp.max,
|
||||
wpBaseMax,
|
||||
);
|
||||
|
||||
const sanityBaseMax = sanityFormulaMax + (source.sanity?.maxBonus ?? 0);
|
||||
system.sanity.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.sanity.max,
|
||||
sanityBaseMax,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute agent fields that depend on Active Effects (final phase) and refresh sheet display.
|
||||
* Single post-AE pass for agents — do not mirror this logic in AgentData.prepareDerivedData.
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function refreshDerivedAfterActiveEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
|
||||
const sourceStatistics = actor._source?.system?.statistics;
|
||||
prepareStatisticsX5(system.statistics, sourceStatistics);
|
||||
applyAgentResourceMaxBonuses(system, sourceStatistics);
|
||||
setSkillTargetProficiencies(system.skills);
|
||||
setSkillTargetProficiencies(system.typedSkills);
|
||||
prepareAgentSkillFlags(system.skills);
|
||||
clampSanityRitualValue(system.sanity);
|
||||
prepareBreakingPointHit(system.sanity);
|
||||
system.statistics.str.meleeDamageBonusFormula =
|
||||
calculateMeleeDamageBonusFormula(
|
||||
system.statistics.str.effectiveValue ?? system.statistics.str.value,
|
||||
);
|
||||
|
||||
prepareAgentActiveEffectDisplay(actor);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import DG from "../config.js";
|
||||
import { getStatisticEffectiveValue } from "./derived-actor-data.js";
|
||||
|
||||
/**
|
||||
* Strip form values that match post–AE prepared numbers so persisted data stays at base values.
|
||||
* @param {Actor} actor
|
||||
* @param {object} data Update payload (mutated in place).
|
||||
* @returns {object}
|
||||
*/
|
||||
export function sanitizeActiveEffectBackedUpdateData(actor, data) {
|
||||
if (!data?.system) return data;
|
||||
|
||||
const source = actor._source?.system;
|
||||
if (!source) return data;
|
||||
|
||||
sanitizeStatisticValues(actor, data, source);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {object} data
|
||||
* @param {object} source
|
||||
*/
|
||||
function sanitizeStatisticValues(actor, data, source) {
|
||||
for (const key of DG.statistics) {
|
||||
const submitted = foundry.utils.getProperty(
|
||||
data,
|
||||
`system.statistics.${key}.value`,
|
||||
);
|
||||
if (submitted === undefined) continue;
|
||||
|
||||
const sourceStat = source.statistics?.[key];
|
||||
const preparedStat = actor.system.statistics?.[key];
|
||||
if (!sourceStat || !preparedStat) continue;
|
||||
|
||||
const base = sourceStat.value;
|
||||
const effective = getStatisticEffectiveValue(preparedStat, sourceStat);
|
||||
|
||||
if (submitted === effective && submitted !== base) {
|
||||
foundry.utils.setProperty(data, `system.statistics.${key}.value`, base);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,12 @@
|
||||
* @returns {void}
|
||||
*/
|
||||
export function flagUnnaturalResourceMaxUpdates(submittedData, actor) {
|
||||
const sourceStats = actor._source?.system?.statistics;
|
||||
|
||||
submittedData.system.wp.maxNeedsUpdate =
|
||||
submittedData.system?.statistics.pow.value !==
|
||||
actor.system.statistics.pow.value;
|
||||
submittedData.system?.statistics?.pow?.value !== sourceStats?.pow?.value;
|
||||
|
||||
submittedData.system.health.maxNeedsUpdate =
|
||||
submittedData.system?.statistics.str.value !==
|
||||
actor.system.statistics.str.value ||
|
||||
submittedData.system?.statistics.con.value !==
|
||||
actor.system.statistics.con.value;
|
||||
submittedData.system?.statistics?.str?.value !== sourceStats?.str?.value ||
|
||||
submittedData.system?.statistics?.con?.value !== sourceStats?.con?.value;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
/**
|
||||
* Shared derived-data helpers for actor TypeDataModels.
|
||||
*
|
||||
* Active Effect validation layers (do not duplicate clamp logic elsewhere):
|
||||
* - Schema `NumberField` min/max: enforced when values pass through `field.clean()`
|
||||
* (document updates, Active Effect `applyChange` on registered paths).
|
||||
* - `cleanDerivedNumber`: use when assigning derived fields in code (e.g. `health.max`,
|
||||
* `sanity.max`); plain assignment bypasses schema cleaners unless the path has a NumberField.
|
||||
* - `clampStatisticValue`: effective ratings after statistic modifier AEs (min 0).
|
||||
* - `applyAgentResourceMaxBonuses`: recompute resource max after AE;
|
||||
* agents call these only from `refreshDerivedAfterActiveEffects` (post–final phase).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -28,20 +37,86 @@ export function calculateMeleeDamageBonusFormula(strengthValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampStatisticValue(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.round(numeric));
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective statistic rating: persisted value plus AE modifier (minimum 0).
|
||||
* @param {object} stat Prepared statistic.
|
||||
* @param {object} [sourceStat] Persisted statistic from `_source`.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getStatisticEffectiveValue(stat, sourceStat) {
|
||||
const base = clampStatisticValue(sourceStat?.value ?? stat?.value);
|
||||
const modifier = Number(stat?.modifier) || 0;
|
||||
return clampStatisticValue(base + modifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} statistics
|
||||
* @param {object} [sourceStatistics] Persisted statistics from `_source`.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareStatisticsX5(statistics) {
|
||||
for (const statistic of Object.values(statistics)) {
|
||||
statistic.x5 = statistic.value * 5;
|
||||
export function prepareStatisticsX5(statistics, sourceStatistics) {
|
||||
for (const [key, statistic] of Object.entries(statistics ?? {})) {
|
||||
statistic.effectiveValue = getStatisticEffectiveValue(
|
||||
statistic,
|
||||
sourceStatistics?.[key],
|
||||
);
|
||||
statistic.x5 = statistic.effectiveValue * 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} skills
|
||||
* Clamp a derived numeric value through the TypeDataModel field definition when available.
|
||||
* @param {foundry.abstract.TypeDataModel} model
|
||||
* @param {string} path Dot-delimited path (e.g. "health.max")
|
||||
* @param {number} raw
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cleanDerivedNumber(model, path, raw) {
|
||||
const field = model.getFieldForProperty?.(path);
|
||||
const numeric = Number(raw);
|
||||
if (!field) {
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0;
|
||||
}
|
||||
return field.clean(numeric, { persisted: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {foundry.abstract.TypeDataModel} model Agent system TypeDataModel.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applyAgentResourceMaxBonuses(model, sourceStatistics) {
|
||||
const sourceStats = sourceStatistics ?? model._source?.statistics;
|
||||
model.health.max = cleanDerivedNumber(
|
||||
model,
|
||||
"health.max",
|
||||
calculateHealthMax(model.statistics, sourceStats) +
|
||||
(model.health.maxBonus ?? 0),
|
||||
);
|
||||
model.wp.max = cleanDerivedNumber(
|
||||
model,
|
||||
"wp.max",
|
||||
getStatisticEffectiveValue(model.statistics.pow, sourceStats?.pow) +
|
||||
(model.wp.maxBonus ?? 0),
|
||||
);
|
||||
model.sanity.max = cleanDerivedNumber(
|
||||
model,
|
||||
"sanity.max",
|
||||
99 -
|
||||
model.skills.unnatural.proficiency +
|
||||
(model.sanity.maxBonus ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function setSkillTargetProficiencies(skills) {
|
||||
for (const skill of Object.values(skills)) {
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
@@ -56,7 +131,9 @@ export function setSkillTargetProficiencies(skills) {
|
||||
export function initializeSanityIfUnset(sanity, statistics) {
|
||||
if (sanity.value >= 100) {
|
||||
sanity.value = statistics.pow.x5;
|
||||
sanity.currentBreakingPoint = sanity.value - statistics.pow.value;
|
||||
sanity.currentBreakingPoint =
|
||||
sanity.value -
|
||||
(statistics.pow.effectiveValue ?? statistics.pow.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +184,6 @@ export function prepareAgentSkillFlags(skills) {
|
||||
skill.cannotBeImprovedByFailure =
|
||||
key === "unnatural" || key === "luck" || key === "ritual";
|
||||
skill.isCalculatedValue = key === "ritual";
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +203,15 @@ export function removeLegacyRitualSkill(system) {
|
||||
* @param {object} statistics
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateHealthMax(statistics) {
|
||||
export function calculateHealthMax(statistics, sourceStatistics) {
|
||||
try {
|
||||
return Math.ceil(
|
||||
(statistics.con.value + statistics.str.value) / 2,
|
||||
(getStatisticEffectiveValue(
|
||||
statistics.con,
|
||||
sourceStatistics?.con,
|
||||
) +
|
||||
getStatisticEffectiveValue(statistics.str, sourceStatistics?.str)) /
|
||||
2,
|
||||
);
|
||||
} catch {
|
||||
return 10;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import DG from "../config.js";
|
||||
import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js";
|
||||
import { getEffectiveSuppressExhaustion } from "./stimulant-effect.js";
|
||||
|
||||
/** @type {WeakMap<Actor, Promise<void>>} */
|
||||
const syncLocks = new WeakMap();
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getExhaustionEffects(actor) {
|
||||
return actor.effects?.filter((effect) => effect.getFlag(DG.ID, "exhaustion")) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
function getExhaustionEffect(actor) {
|
||||
return getExhaustionEffects(actor)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} penalty
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function buildExhaustionChanges(penalty) {
|
||||
const value = String(penalty);
|
||||
return ROLL_TARGET_FIELD_KEYS.map((key) => ({
|
||||
key,
|
||||
type: "add",
|
||||
value,
|
||||
phase: "final",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @param {number} penalty
|
||||
* @param {boolean} suppressExhaustion
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function effectMatchesState(effect, penalty, suppressExhaustion) {
|
||||
if (Boolean(effect.disabled) !== Boolean(suppressExhaustion)) return false;
|
||||
const changes = effect.system?.changes ?? [];
|
||||
if (changes.length !== ROLL_TARGET_FIELD_KEYS.length) return false;
|
||||
return changes.every(
|
||||
(change) =>
|
||||
ROLL_TARGET_FIELD_KEYS.includes(change.key) &&
|
||||
change.type === "add" &&
|
||||
String(change.value) === String(penalty),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function pruneDuplicateExhaustionEffects(actor) {
|
||||
const effects = getExhaustionEffects(actor);
|
||||
if (effects.length <= 1) return;
|
||||
for (const effect of effects.slice(1)) {
|
||||
await effect.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function syncExhaustionEffectInner(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
await pruneDuplicateExhaustionEffects(actor);
|
||||
|
||||
const { exhausted, exhaustedPenalty } = actor.system.physical;
|
||||
const suppressExhaustion = getEffectiveSuppressExhaustion(actor);
|
||||
const effect = getExhaustionEffect(actor);
|
||||
const penalty = exhausted
|
||||
? -1 * Math.abs(Number(exhaustedPenalty) || 0)
|
||||
: 0;
|
||||
|
||||
if (!exhausted) {
|
||||
if (effect) await effect.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = buildExhaustionChanges(penalty);
|
||||
const disabled = Boolean(suppressExhaustion);
|
||||
|
||||
if (effect) {
|
||||
if (effectMatchesState(effect, penalty, suppressExhaustion)) return;
|
||||
await effect.update({ changes, disabled });
|
||||
return;
|
||||
}
|
||||
|
||||
const documentClass = getDocumentClass("ActiveEffect");
|
||||
await documentClass.create(
|
||||
{
|
||||
name: game.i18n.localize("DG.Physical.ExhaustionEffectName"),
|
||||
img: "systems/deltagreen/assets/icons/magic-shield.svg",
|
||||
transfer: false,
|
||||
disabled,
|
||||
changes,
|
||||
flags: { [DG.ID]: { exhaustion: true } },
|
||||
},
|
||||
{ parent: actor },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the embedded exhaustion Active Effect aligned with physical exhaustion data.
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function syncExhaustionEffect(actor) {
|
||||
const pending = syncLocks.get(actor);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = syncExhaustionEffectInner(actor).finally(() => {
|
||||
if (syncLocks.get(actor) === promise) syncLocks.delete(actor);
|
||||
});
|
||||
syncLocks.set(actor, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data Actor update payload.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function updateTouchesExhaustionPhysical(data) {
|
||||
if (!data) return false;
|
||||
|
||||
const physical = foundry.utils.getProperty(data, "system.physical");
|
||||
if (physical) {
|
||||
if (
|
||||
"exhausted" in physical ||
|
||||
"exhaustedPenalty" in physical ||
|
||||
"suppressExhaustion" in physical
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"system.physical.exhausted",
|
||||
"system.physical.exhaustedPenalty",
|
||||
"system.physical.suppressExhaustion",
|
||||
].some((key) => foundry.utils.hasProperty(data, key));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {Actor} params.actor
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {string} params.contentKey i18n key for message body.
|
||||
* @param {Record<string, string|number>} params.i18nData
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createAgentResourceChatMessage({
|
||||
actor,
|
||||
token = null,
|
||||
contentKey,
|
||||
i18nData,
|
||||
}) {
|
||||
return ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({
|
||||
actor,
|
||||
token,
|
||||
alias: actor.name,
|
||||
}),
|
||||
content: game.i18n.format(contentKey, i18nData),
|
||||
messageMode: game.settings.get("core", "messageMode"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import DG from "../config.js";
|
||||
import { syncExhaustionEffect } from "./exhaustion-effect.js";
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getStimulantEffects(actor) {
|
||||
return (
|
||||
actor.effects?.filter((effect) => effect.getFlag(DG.ID, "stimulant")) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
function getStimulantEffect(actor) {
|
||||
return getStimulantEffects(actor).find((effect) => !effect.isSuppressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasActiveStimulantEffect(actor) {
|
||||
return getStimulantEffects(actor).some((effect) => !effect.isSuppressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {number}
|
||||
*/
|
||||
function getStimulantRemainingHours(effect) {
|
||||
effect.updateDuration();
|
||||
const { remaining, secondsRemaining, units } = effect.duration ?? {};
|
||||
if (Number.isFinite(remaining) && remaining > 0 && units === "hours") {
|
||||
return Math.ceil(remaining);
|
||||
}
|
||||
if (Number.isFinite(secondsRemaining) && secondsRemaining > 0) {
|
||||
const { calendar } = game.time;
|
||||
const components = calendar.difference(
|
||||
game.time.worldTime + secondsRemaining,
|
||||
game.time.worldTime,
|
||||
);
|
||||
const hours = calendar.componentsToUnit(components, "hour", {
|
||||
roundFn: "ceil",
|
||||
});
|
||||
return Math.max(0, Math.ceil(Math.abs(hours)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted manual suppress or an active stimulant AE.
|
||||
* @param {Actor} actor
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getEffectiveSuppressExhaustion(actor) {
|
||||
const persisted =
|
||||
actor._source?.system?.physical?.suppressExhaustion ?? false;
|
||||
return Boolean(persisted) || hasActiveStimulantEffect(actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} hours
|
||||
* @returns {object}
|
||||
*/
|
||||
function buildStimulantEffectData(hours) {
|
||||
return {
|
||||
name: game.i18n.localize("DG.Physical.StimulantsEffectName"),
|
||||
img: "systems/deltagreen/assets/icons/tablet.svg",
|
||||
transfer: false,
|
||||
disabled: false,
|
||||
start: { time: game.time.worldTime },
|
||||
duration: {
|
||||
value: hours,
|
||||
units: "hours",
|
||||
},
|
||||
changes: [],
|
||||
flags: { [DG.ID]: { stimulant: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or refresh the stimulant AE; duration is max(remaining, newHours).
|
||||
* @param {Actor} actor
|
||||
* @param {number} newHours
|
||||
* @returns {Promise<number>} Hours applied on the stimulant AE.
|
||||
*/
|
||||
export async function applyStimulantEffect(actor, newHours) {
|
||||
if (actor.type !== "agent") return 0;
|
||||
|
||||
const rolledHours = Math.max(1, Math.ceil(Number(newHours) || 0));
|
||||
const existing = getStimulantEffect(actor);
|
||||
const remainingHours = existing ? getStimulantRemainingHours(existing) : 0;
|
||||
const hours = Math.max(remainingHours, rolledHours);
|
||||
|
||||
const data = buildStimulantEffectData(hours);
|
||||
|
||||
if (existing) {
|
||||
await existing.update({
|
||||
start: data.start,
|
||||
duration: data.duration,
|
||||
changes: data.changes,
|
||||
disabled: false,
|
||||
});
|
||||
return hours;
|
||||
}
|
||||
|
||||
const documentClass = getDocumentClass("ActiveEffect");
|
||||
await documentClass.create(data, { parent: actor });
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getExpiredStimulantEffects(actor) {
|
||||
return getStimulantEffects(actor).filter(
|
||||
(effect) => effect.duration?.expired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stimulant AEs that have expired.
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pruneExpiredStimulantEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
const expired = getExpiredStimulantEffects(actor);
|
||||
if (!expired.length) return;
|
||||
await actor.deleteEmbeddedDocuments(
|
||||
"ActiveEffect",
|
||||
expired.map((effect) => effect.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all stimulant AEs on the actor (e.g. after a full rest).
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function clearStimulantEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
const effects = getStimulantEffects(actor);
|
||||
if (!effects.length) return;
|
||||
await actor.deleteEmbeddedDocuments(
|
||||
"ActiveEffect",
|
||||
effects.map((effect) => effect.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pruneAllAgentsExpiredStimulants() {
|
||||
if (!game.user.isActiveGM) return;
|
||||
for (const actor of game.actors) {
|
||||
if (actor.type !== "agent") continue;
|
||||
await pruneExpiredStimulantEffects(actor);
|
||||
await syncExhaustionEffect(actor);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user