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
+77
View File
@@ -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),
})),
}));
}
+36
View File
@@ -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(),
},
);
}
}
+2
View File
@@ -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
View File
@@ -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 =
+21 -5
View File
@@ -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: "" }),
});
}
+1 -1
View File
@@ -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" }),
}),
}),
+24 -7
View File
@@ -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);
+13 -3
View File
@@ -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);
+1
View File
@@ -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 }),
};
}
}
+83
View File
@@ -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();
+84
View File
@@ -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);
}
}
-1
View File
@@ -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
View File
@@ -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)) {
+252 -6
View File
@@ -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.
+3 -11
View File
@@ -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 */
+78 -49
View File
@@ -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,
},
});
+61
View File
@@ -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);
}
}
};
}
+157
View File
@@ -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 });
}
};
}
+3 -1
View File
@@ -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]);
-8
View File
@@ -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") &&
+4
View File
@@ -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",
]);
}
+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);
}
}