Implemented Active Effects.

Used AE to implement exhaustion, stimulants, and rest.
This commit is contained in:
Aaron Lutomski
2026-05-29 21:07:33 -07:00
parent 67b70268b6
commit bf4ca9bf88
65 changed files with 5830 additions and 3480 deletions
+97
View File
@@ -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);
}
+45
View File
@@ -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);
}
}
}
+5 -6
View File
@@ -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;
}
+89 -8
View File
@@ -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;
+152
View File
@@ -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));
}
+24
View File
@@ -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"),
});
}
+166
View File
@@ -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);
}
}