Skip to content

Commit acb5c32

Browse files
committed
Add system changes to facilitate Whirling Swipe
1 parent 6ef3770 commit acb5c32

File tree

7 files changed

+130
-69
lines changed

7 files changed

+130
-69
lines changed

src/module/actor/creature/document.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -95,28 +95,17 @@ abstract class CreaturePF2e<
9595
*/
9696
override getReach({ action = "interact", weapon = null }: GetReachParameters = {}): number {
9797
const baseReach = this.attributes.reach.base;
98-
const weaponReach = weapon?.isOfType("melee") ? weapon.reach : null;
9998

10099
if (action === "interact" || this.type === "familiar") {
101-
return baseReach;
102-
} else if (typeof weaponReach === "number") {
103-
return weaponReach;
100+
return this.attributes.reach.base;
101+
} else if (weapon?.isOfType("melee", "weapon")) {
102+
return weapon.reach ?? baseReach;
104103
} else {
105-
const attacks: { item: ItemPF2e<ActorPF2e>; ready: boolean }[] = weapon
106-
? [{ item: weapon, ready: true }]
107-
: (this.system.actions ?? []);
108-
const readyAttacks = attacks.filter((a) => a.ready);
109-
const traitsFromItems = readyAttacks.map((a) => new Set(a.item.system.traits?.value ?? []));
110-
if (traitsFromItems.length === 0) return baseReach;
111-
112-
const reaches = traitsFromItems.map((traits): number => {
113-
if (setHasElement(traits, "reach")) return baseReach + 5;
114-
115-
const reachNPattern = /^reach-\d{1,3}$/;
116-
return Number([...traits].find((t) => reachNPattern.test(t))?.replace("reach-", "")) || baseReach;
117-
});
118-
119-
return Math.max(...reaches);
104+
const readiedItems = this.system.actions?.filter((a) => a.ready).map((a) => a.item) ?? [];
105+
return Math.max(
106+
baseReach,
107+
...readiedItems.map((i) => i.reach).filter((r): r is number => typeof r === "number"),
108+
);
120109
}
121110
}
122111

src/module/item/melee/document.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class MeleePF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ite
4949
return Number(this.system.bonus.value) || 0;
5050
}
5151

52+
/** The reach of this npc attack when they attack with it */
5253
get reach(): number | null {
5354
if (this.isRanged) return null;
5455
const reachTrait = this.system.traits.value.find((t) => /^reach-\d+$/.test(t));

src/module/item/weapon/document.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph
111111
return this.system.maxRange ?? (this.system.range ? this.system.range * 6 : null);
112112
}
113113

114+
/** The reach of this weapon when using it. */
115+
get reach(): number | null {
116+
if (!this.isMelee) return null;
117+
118+
const actor = this.actor;
119+
const baseReach = actor?.isOfType("creature") ? (actor.attributes.reach?.base ?? 5) : 5;
120+
const traits = this.system.traits.value;
121+
const reachIncrease = traits.includes("reach") ? 5 : 0;
122+
return baseReach + reachIncrease;
123+
}
124+
114125
/** A single object containing range increment and maximum */
115126
get range(): RangeData | null {
116127
const rangeIncrement = this.system.range;

src/module/rules/rule-element/base.ts

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -256,28 +256,50 @@ abstract class RuleElementPF2e<TSchema extends RuleElementSchema = RuleElementSc
256256
"rule",
257257
...Object.keys(injectables).filter((i) => /^[a-z][a-z]+$/g.test(i)),
258258
];
259-
const pattern = new RegExp(String.raw`{(${injectableKeys.join("|")})\|(.*?)}`, "g");
259+
260+
const injectablePattern = new RegExp(String.raw`^(${injectableKeys.join("|")})\|(.*?)$`, "g");
260261
const allInjectables: Record<string, object> = {
261262
actor: this.actor,
262263
item: this.item,
263264
rule: this,
264265
...injectables,
265266
};
266-
return source.replace(pattern, (_match, key: string, prop: string) => {
267-
const data = allInjectables[key];
268-
const value = (() => {
269-
// In case of formerly deprecated paths upstream now throws on
270-
try {
271-
return fu.getProperty(data, prop);
272-
} catch {
273-
return undefined;
267+
268+
const bracketPattern = /{([^{}]+)}/g;
269+
return source.replace(bracketPattern, (match, value) => {
270+
// Support {@actor.level} type stuff
271+
if (value.includes("@")) {
272+
const result = this.#saferEval(
273+
Roll.replaceFormulaData(value, {
274+
...this.item.getRollData(),
275+
rule: this,
276+
...injectables,
277+
}),
278+
{ warn, defaultValue: match },
279+
);
280+
return String(result);
281+
}
282+
283+
const injectableMatch = injectablePattern.exec(value);
284+
if (injectableMatch) {
285+
const [_, key, prop] = injectableMatch;
286+
const data = allInjectables[key];
287+
const value = (() => {
288+
// In case of formerly deprecated paths upstream now throws on
289+
try {
290+
return fu.getProperty(data, prop);
291+
} catch {
292+
return undefined;
293+
}
294+
})();
295+
if (value === undefined) {
296+
this.ignored = true;
297+
if (warn) this.failValidation(`Failed to resolve injected property "${prop}" in "${source}"`);
274298
}
275-
})();
276-
if (value === undefined) {
277-
this.ignored = true;
278-
if (warn) this.failValidation(`Failed to resolve injected property "${prop}" in "${source}"`);
299+
return String(value);
279300
}
280-
return String(value);
301+
302+
return match;
281303
});
282304
}
283305

@@ -308,7 +330,7 @@ abstract class RuleElementPF2e<TSchema extends RuleElementSchema = RuleElementSc
308330
if (typeof value === "number" || typeof value === "boolean" || value === null) {
309331
return value;
310332
}
311-
value = this.resolveInjectedProperties(value, { warn });
333+
value = this.resolveInjectedProperties(value, { warn, injectables: resolvables });
312334

313335
if (Array.isArray(value)) return value;
314336

@@ -324,48 +346,51 @@ abstract class RuleElementPF2e<TSchema extends RuleElementSchema = RuleElementSc
324346
}
325347

326348
if (typeof resolvedFromBracket === "string") {
327-
const saferEval = (formula: string): number => {
328-
try {
329-
// If any resolvables were not provided for this formula, return the default value
330-
const unresolveds = formula.match(/@[a-z0-9.]+/gi) ?? [];
331-
// Allow failure of "@target" and "@actor.conditions" with no warning
332-
if (unresolveds.length > 0) {
333-
const shouldWarn =
334-
warn &&
335-
!unresolveds.every((u) => u.startsWith("@target.") || u.startsWith("@actor.conditions."));
336-
this.ignored = true;
337-
if (shouldWarn) {
338-
this.failValidation(`unable to resolve formula, "${formula}"`);
339-
}
340-
return Number(defaultValue);
341-
}
342-
return Roll.safeEval(formula);
343-
} catch {
344-
this.failValidation(`unable to evaluate formula, "${formula}"`);
345-
return 0;
346-
}
347-
};
348-
349349
// Include worn armor as resolvable for PCs since there is guaranteed to be no more than one
350350
if (this.actor.isOfType("character")) {
351351
resolvables.armor = this.actor.wornArmor;
352352
}
353353

354354
const trimmed = resolvedFromBracket.trim();
355355
return (trimmed.includes("@") || /^-?\d+$/.test(trimmed)) && evaluate
356-
? saferEval(
356+
? this.#saferEval(
357357
Roll.replaceFormulaData(trimmed, {
358358
...this.actor.getRollData(),
359359
item: this.item,
360360
...resolvables,
361361
}),
362+
{ warn, defaultValue },
362363
)
363364
: trimmed;
364365
}
365366

366367
return defaultValue;
367368
}
368369

370+
#saferEval(
371+
formula: string,
372+
{ warn, defaultValue }: { warn: boolean; defaultValue: Exclude<RuleValue, BracketedValue> | null },
373+
): number {
374+
try {
375+
// If any resolvables were not provided for this formula, return the default value
376+
const unresolveds = formula.match(/@[a-z0-9.]+/gi) ?? [];
377+
// Allow failure of "@target" and "@actor.conditions" with no warning
378+
if (unresolveds.length > 0) {
379+
const shouldWarn =
380+
warn && !unresolveds.every((u) => u.startsWith("@target.") || u.startsWith("@actor.conditions."));
381+
this.ignored = true;
382+
if (shouldWarn) {
383+
this.failValidation(`unable to resolve formula, "${formula}"`);
384+
}
385+
return Number(defaultValue);
386+
}
387+
return Roll.safeEval(formula);
388+
} catch {
389+
this.failValidation(`unable to evaluate formula, "${formula}"`);
390+
return 0;
391+
}
392+
}
393+
369394
protected isBracketedValue(value: unknown): value is BracketedValue {
370395
return isBracketedValue(value);
371396
}

src/module/rules/rule-element/item-alteration/alteration.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ class ItemAlteration extends foundry.abstract.DataModel<RuleElementPF2e, ItemAlt
4848
alteration: {
4949
mode: this.mode,
5050
itemType: item.type,
51-
value: (this.value = this.parent.resolveValue(this.value, fallbackValue)),
51+
value: (this.value = this.parent.resolveValue(this.value, fallbackValue, {
52+
resolvables: { targetItem: item },
53+
})),
5254
},
5355
});
5456
}

src/module/rules/rule-element/item-alteration/handlers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { DAMAGE_DICE_FACES } from "@system/damage/values.ts";
1616
import { PredicateField, SlugField, StrictNumberField } from "@system/schema-data-fields.ts";
1717
import { objectHasKey, setHasElement, tupleHasValue } from "@util";
1818
import * as R from "remeda";
19-
import { AELikeRuleElement, type AELikeChangeMode } from "../ae-like.ts";
19+
import { AELikeDataPrepPhase, AELikeRuleElement, type AELikeChangeMode } from "../ae-like.ts";
2020
import { RuleElementPF2e } from "../index.ts";
2121
import { adjustCreatureShieldData, getNewInterval, itemHasCounterBadge } from "./helper.ts";
2222
import fields = foundry.data.fields;
@@ -33,6 +33,8 @@ class ItemAlterationHandler<TSchema extends AlterationSchema> extends fields.Sch
3333

3434
operableOnSource: boolean;
3535

36+
phases: AELikeDataPrepPhase[];
37+
3638
/** A registered handler function for the item alteration. The validation should be performed inside */
3739
handle: (data: AlterationApplicationData) => void;
3840

@@ -42,6 +44,7 @@ class ItemAlterationHandler<TSchema extends AlterationSchema> extends fields.Sch
4244
this.handle = options.handle.bind(this);
4345
this.operableOnInstances = options.operableOnInstances ?? true;
4446
this.operableOnSource = options.operableOnSource ?? true;
47+
this.phases = options.phases ?? ["applyAEs"];
4548
}
4649

4750
/**
@@ -606,6 +609,7 @@ const ITEM_ALTERATION_HANDLERS = {
606609
initial: undefined,
607610
} as const),
608611
},
612+
phases: ["afterDerived"],
609613
validateForItem(item): validation.DataModelValidationFailure | void {
610614
if (item.system.slug !== "persistent-damage") {
611615
return new validation.DataModelValidationFailure({
@@ -971,7 +975,7 @@ const ITEM_ALTERATION_HANDLERS = {
971975
initial: undefined,
972976
}),
973977
},
974-
978+
phases: ["applyAEs", "beforeDerived"],
975979
validateForItem: (item, alteration): validation.DataModelValidationFailure | void => {
976980
const documentClasses: Record<string, typeof ItemPF2e> = CONFIG.PF2E.Item.documentClasses;
977981
const validTraits = documentClasses[item.type].validTraits;
@@ -1016,6 +1020,8 @@ interface AlterationFieldOptions<
10161020
operableOnInstances?: boolean;
10171021
/** Whether this alteration can be used with item source data */
10181022
operableOnSource?: boolean;
1023+
/** Valid AELike prep phase for this alteration. By default its "applyAEs" only */
1024+
phases?: AELikeDataPrepPhase[];
10191025
handle: (this: ItemAlterationHandler<TSchema>, data: AlterationApplicationData) => void;
10201026
}
10211027

src/module/rules/rule-element/item-alteration/rule-element.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import type { ActorPF2e } from "@actor";
22
import type { ItemPF2e, PhysicalItemPF2e } from "@item";
33
import type { ItemType } from "@item/base/data/index.ts";
44
import { PHYSICAL_ITEM_TYPES } from "@item/physical/values.ts";
5+
import { objectHasKey } from "@util";
56
import * as R from "remeda";
6-
import { AELikeRuleElement } from "../ae-like.ts";
7+
import { AELikeDataPrepPhase, AELikeRuleElement } from "../ae-like.ts";
78
import { RuleElementPF2e } from "../base.ts";
89
import type { ModelPropsFromRESchema, RuleElementSchema } from "../data.ts";
910
import { ItemAlteration, ItemAlterationSchema } from "./alteration.ts";
11+
import { ITEM_ALTERATION_HANDLERS } from "./handlers.ts";
1012
import fields = foundry.data.fields;
1113

1214
class ItemAlterationRuleElement extends RuleElementPF2e<ItemAlterationRuleSchema> {
@@ -34,6 +36,17 @@ class ItemAlterationRuleElement extends RuleElementPF2e<ItemAlterationRuleSchema
3436
choices: itemTypeChoices,
3537
initial: undefined,
3638
}),
39+
phase: new fields.StringField({
40+
required: true,
41+
choices: ["applyAEs", "beforeDerived", "afterDerived"],
42+
initial: (data: unknown) => {
43+
const property = R.isPlainObject(data) ? data["property"] : null;
44+
const handler = objectHasKey(ITEM_ALTERATION_HANDLERS, property)
45+
? ITEM_ALTERATION_HANDLERS[property]
46+
: null;
47+
return (handler?.phases ?? ["applyAEs"])[0];
48+
},
49+
}),
3750
...ItemAlteration.defineSchema(),
3851
};
3952
}
@@ -44,10 +57,14 @@ class ItemAlterationRuleElement extends RuleElementPF2e<ItemAlterationRuleSchema
4457
if (!data.itemId && !data.itemType) {
4558
throw Error("one of itemId and itemType must be defined");
4659
}
47-
}
4860

49-
/** Alteration properties that should be processed at the end of data preparation */
50-
static #DELAYED_PROPERTIES = ["pd-recovery-dc"];
61+
// Validate that the phase is supported by the property
62+
const { property, phase } = data;
63+
const handler = objectHasKey(ITEM_ALTERATION_HANDLERS, property) ? ITEM_ALTERATION_HANDLERS[property] : null;
64+
if (phase && handler && !handler.phases.includes(phase)) {
65+
throw Error(`phase: ${phase} is not a valid choice, must be one of ${handler.phases.join(", ")}`);
66+
}
67+
}
5168

5269
/** Alteration properties that should only be processed when requested directly */
5370
static #LAZY_PROPERTIES = ["description"];
@@ -93,16 +110,22 @@ class ItemAlterationRuleElement extends RuleElementPF2e<ItemAlterationRuleSchema
93110
}
94111

95112
override onApplyActiveEffects(): void {
96-
this.actor.synthetics.itemAlterations.push(this);
97-
const isDelayed = this.constructor.#DELAYED_PROPERTIES.includes(this.property);
98-
if (!this.isLazy && !isDelayed) {
113+
if (!this.isLazy && this.phase === "applyAEs") {
114+
this.actor.synthetics.itemAlterations.push(this);
115+
this.applyAlteration();
116+
}
117+
}
118+
119+
override beforePrepareData(): void {
120+
if (!this.isLazy && this.phase === "beforeDerived") {
121+
this.actor.synthetics.itemAlterations.push(this);
99122
this.applyAlteration();
100123
}
101124
}
102125

103126
override afterPrepareData(): void {
104-
const isDelayed = this.constructor.#DELAYED_PROPERTIES.includes(this.property);
105-
if (!this.isLazy && isDelayed) {
127+
if (!this.isLazy && this.phase === "afterDerived") {
128+
this.actor.synthetics.itemAlterations.push(this);
106129
this.applyAlteration();
107130
}
108131
}
@@ -175,12 +198,16 @@ interface ItemAlterationRuleElement
175198
constructor: typeof ItemAlterationRuleElement;
176199
}
177200

201+
type ItemAlterationPrepPhase = Exclude<AELikeDataPrepPhase, "beforeRoll">;
202+
178203
type ItemAlterationRuleSchema = RuleElementSchema &
179204
ItemAlterationSchema & {
180205
/** The type of items to alter */
181206
itemType: fields.StringField<ItemType, ItemType, false, false, false>;
182207
/** As an alternative to specifying item types, an exact item ID can be provided */
183208
itemId: fields.StringField<string, string, false, false, false>;
209+
/** The phase to run the alteration in. Most only support applyAEs */
210+
phase: fields.StringField<ItemAlterationPrepPhase, ItemAlterationPrepPhase, true, false, true>;
184211
};
185212

186213
interface ApplyAlterationOptions {

0 commit comments

Comments
 (0)