Skip to content

Commit

Permalink
Allow SpecialStatistic to create item-spellcasting abilities
Browse files Browse the repository at this point in the history
  • Loading branch information
stwlam committed Jan 22, 2024
1 parent d483b48 commit 992a43b
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 43 deletions.
47 changes: 23 additions & 24 deletions src/module/item/consumable/document.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { ActorPF2e } from "@actor";
import { TrickMagicItemPopup } from "@actor/sheet/trick-magic-item-popup.ts";
import type { WeaponPF2e } from "@item";
import { PhysicalItemPF2e, SpellcastingEntryPF2e, SpellPF2e } from "@item";
import type { SpellPF2e, WeaponPF2e } from "@item";
import { ItemProxyPF2e, PhysicalItemPF2e } from "@item";
import { ItemSummaryData } from "@item/base/data/index.ts";
import { TrickMagicItemEntry } from "@item/spellcasting-entry/trick.ts";
import type { SpellcastingEntry } from "@item/spellcasting-entry/types.ts";
import { ValueAndMax } from "@module/data.ts";
import type { RuleElementPF2e } from "@module/rules/index.ts";
import type { UserPF2e } from "@module/user/document.ts";
import { DamageRoll } from "@system/damage/roll.ts";
import type { Statistic } from "@system/statistic/statistic.ts";
import { ErrorPF2e, setHasElement } from "@util";
import * as R from "remeda";
import { ConsumableSource, ConsumableSystemData } from "./data.ts";
Expand Down Expand Up @@ -35,10 +37,10 @@ class ConsumablePF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extend
if (!this.actor) throw ErrorPF2e(`No owning actor found for "${this.name}" (${this.id})`);
if (!this.system.spell) return null;

return new SpellPF2e(fu.deepClone(this.system.spell), {
return new ItemProxyPF2e(fu.deepClone(this.system.spell), {
parent: this.actor,
fromConsumable: true,
}) as SpellPF2e<ActorPF2e>;
}) as SpellPF2e<NonNullable<TParent>>;
}

override prepareBaseData(): void {
Expand Down Expand Up @@ -205,33 +207,30 @@ class ConsumablePF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extend
}

async castEmbeddedSpell(trickMagicItemData?: TrickMagicItemEntry): Promise<void> {
const { actor } = this;
const actor = this.actor;
const spell = this.embeddedSpell;
if (!actor || !spell) return;

// Find the best spellcasting entry to cast this consumable
const entry = (() => {
const entry = ((): SpellcastingEntry<ActorPF2e> | null => {
if (trickMagicItemData) return trickMagicItemData;
return actor.spellcasting
.filter(
(e): e is SpellcastingEntryPF2e<ActorPF2e> =>
e instanceof SpellcastingEntryPF2e && e.canCast(spell, { origin: this }),
)
.reduce((previous, current) => {
const previousDC = previous.statistic.dc.value;
const currentDC = current.statistic.dc.value;
return currentDC > previousDC ? current : previous;
});
return (
actor.spellcasting
.filter(
(e): e is SpellcastingEntry<ActorPF2e> & { statistic: Statistic } =>
!!e.statistic && e.canCast(spell, { origin: this }),
)
.reduce((previous, current) => {
if (!previous && current?.statistic) return current;
if (!current?.statistic && previous?.statistic) return previous;
const previousDC = previous?.statistic?.dc.value ?? -Infinity;
const currentDC = current?.statistic?.dc.value ?? -Infinity;
return currentDC > previousDC ? current : previous;
}) ?? null
);
})();

if (entry) {
const systemData = spell.system;
if (entry instanceof SpellcastingEntryPF2e) {
systemData.location.value = entry.id;
}

entry.cast(spell, { consume: false });
}
return entry?.cast(spell, { consume: false });
}

protected override _preUpdate(
Expand Down
12 changes: 9 additions & 3 deletions src/module/item/spellcasting-entry/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ class SpellcastingEntryPF2e<TParent extends ActorPF2e | null = ActorPF2e | null>
return false;
}

get isEphemeral(): false {
return false;
}

get highestRank(): ZeroToTen {
return this.spells?.highestRank ?? 0;
}
Expand Down Expand Up @@ -224,7 +228,7 @@ class SpellcastingEntryPF2e<TParent extends ActorPF2e | null = ActorPF2e | null>
return this.actor?.itemTypes.spell.filter((i) => i.system.location.value === this.id) ?? [];
}

/** Returns if the spell is valid to cast by this spellcasting entry */
/** Whether the spell is valid to cast by this spellcasting entry */
canCast(spell: SpellPF2e, { origin }: { origin?: PhysicalItemPF2e } = {}): boolean {
// For certain collection-less modes, the spell must come from an item
if (this.system.prepared.value === "items") {
Expand All @@ -249,13 +253,14 @@ class SpellcastingEntryPF2e<TParent extends ActorPF2e | null = ActorPF2e | null>
return matchesTradition || isInSpellList;
}

/** Casts the given spell as if it was part of this spellcasting entry */
/** Cast the given spell as if it was part of this spellcasting entry. */
async cast(spell: SpellPF2e<ActorPF2e>, options: SpellcastingEntryPF2eCastOptions = {}): Promise<void> {
const consume = options.consume ?? true;
const message = options.message ?? true;
const rank = options.rank ?? spell.rank;
const valid = !consume || spell.atWill || (await this.consume(spell, rank, options.slotId));
if (message && valid) {
spell.system.location.value ??= this.id;
const castRank = spell.computeCastRank(rank);
await spell.toMessage(null, { rollMode: options.rollMode, data: { castRank } });
}
Expand Down Expand Up @@ -380,7 +385,8 @@ class SpellcastingEntryPF2e<TParent extends ActorPF2e | null = ActorPF2e | null>
isFlexible: this.isFlexible,
isInnate: this.isInnate,
isFocusPool: this.isFocusPool,
isRitual: this.isRitual,
isRitual: false,
isEphemeral: false,
hasCollection: !!this.spells,
usesSpellProficiency: !this.system.proficiency.slug,
showSlotlessRanks: this.showSlotlessRanks,
Expand Down
134 changes: 134 additions & 0 deletions src/module/item/spellcasting-entry/items-spellcasting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { CreaturePF2e } from "@actor";
import { AttributeString } from "@actor/types.ts";
import type { PhysicalItemPF2e, SpellPF2e } from "@item";
import type { PredicatePF2e } from "@system/predication.ts";
import type { Statistic } from "@system/statistic/statistic.ts";
import * as R from "remeda";
import { SpellCollection } from "./collection.ts";
import type { CastOptions, SpellcastingEntry, SpellcastingSheetData } from "./types.ts";

/** An in-memory spellcasting entry for items-only spellcasting */
class ItemsSpellcasting<TActor extends CreaturePF2e = CreaturePF2e> implements SpellcastingEntry<TActor> {
id: string;

name: string;

actor: TActor;

statistic: Statistic;

/** A predicate to test against a physical item to determine whether its contained spell can be cast */
castPredicate: PredicatePF2e;

constructor({ id, name, actor, statistic, castPredicate }: ItemsSpellcastingConstructorParams<TActor>) {
this.id = id;
this.name = name;
this.actor = actor;
this.statistic = statistic;
this.castPredicate = castPredicate;
}

get attribute(): AttributeString {
return this.statistic.attribute ?? "cha";
}

get category(): "items" {
return "items";
}

get tradition(): null {
return null;
}

get sort(): number {
return Math.max(0, ...this.actor.itemTypes.spellcastingEntry.map((e) => e.sort)) + 10;
}

get spells(): SpellCollection<TActor, this> {
return new SpellCollection(this);
}

get isFlexible(): false {
return false;
}

get isFocusPool(): false {
return false;
}

get isInnate(): false {
return false;
}

get isPrepared(): false {
return false;
}

get isSpontaneous(): false {
return false;
}

get isRitual(): false {
return false;
}

get isEphemeral(): true {
return true;
}

canCast(spell: SpellPF2e, { origin }: { origin?: PhysicalItemPF2e } = {}): boolean {
if (!origin || !spell.actor?.isOfType("creature")) return false;
const rollOptions = new Set(
R.compact([
...this.actor.getRollOptions(),
...spell.getRollOptions("spell"),
...origin.getRollOptions("activatable"),
]),
);
return this.castPredicate.test(rollOptions);
}

async cast(spell: SpellPF2e, options: CastOptions = {}): Promise<void> {
const consume = options.consume ?? true;
const message = options.message ?? true;
const valid = !consume || spell.atWill;
if (message && valid) {
spell.system.location.value = this.id;
await spell.toMessage(null, { rollMode: options.rollMode, data: { castRank: spell.rank } });
}
}

async getSheetData(): Promise<SpellcastingSheetData> {
return {
...R.pick(this, [
"id",
"name",
"category",
"tradition",
"sort",
"isFlexible",
"isFocusPool",
"isInnate",
"isPrepared",
"isRitual",
"isSpontaneous",
"isEphemeral",
]),
statistic: this.statistic.getChatData(),
hasCollection: false,
usesSpellProficiency: false,
groups: [],
prepList: R.mapToObj(Array.fromRange(10, 1), (n) => [n, []]),
};
}
}

interface ItemsSpellcastingConstructorParams<TActor extends CreaturePF2e> {
id: string;
name: string;
actor: TActor;
statistic: Statistic;
castPredicate: PredicatePF2e;
}

export { ItemsSpellcasting };
7 changes: 6 additions & 1 deletion src/module/item/spellcasting-entry/rituals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class RitualSpellcasting<TActor extends ActorPF2e> implements BaseSpellca
}

get sort(): number {
return Math.max(0, ...this.actor.itemTypes.spellcastingEntry.map((e) => e.sort)) + 10;
return Math.max(0, ...this.actor.itemTypes.spellcastingEntry.map((e) => e.sort)) + 20;
}

get category(): "ritual" {
Expand Down Expand Up @@ -62,6 +62,10 @@ export class RitualSpellcasting<TActor extends ActorPF2e> implements BaseSpellca
return false;
}

get isEphemeral(): true {
return true;
}

canCast(spell: SpellPF2e): boolean {
return spell.isRitual;
}
Expand All @@ -80,6 +84,7 @@ export class RitualSpellcasting<TActor extends ActorPF2e> implements BaseSpellca
tradition: null,
category: this.category,
isRitual: true,
isEphemeral: true,
hasCollection: true,
sort: this.sort,
usesSpellProficiency: false,
Expand Down
5 changes: 5 additions & 0 deletions src/module/item/spellcasting-entry/trick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ class TrickMagicItemEntry<TActor extends ActorPF2e = ActorPF2e> implements Spell
return false;
}

get isEphemeral(): true {
return true;
}

/** Currently no checks for whether a magic item can be tricked */
canCast(): boolean {
return true;
Expand All @@ -174,6 +178,7 @@ class TrickMagicItemEntry<TActor extends ActorPF2e = ActorPF2e> implements Spell
groups: [],
usesSpellProficiency: false,
prepList: null,
isEphemeral: true,
};
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/module/item/spellcasting-entry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface BaseSpellcastingEntry<TActor extends ActorPF2e | null = ActorPF2e | nu
isPrepared: boolean;
isRitual: boolean;
isSpontaneous: boolean;
isEphemeral: boolean;
statistic?: Statistic | null;
tradition: MagicTradition | null;
spells: SpellCollection<NonNullable<TActor>, this> | null;
Expand All @@ -45,6 +46,7 @@ interface CastOptions {
slotId?: number;
/** The rank at which to cast the spell */
rank?: OneToTen;
consume?: boolean;
message?: boolean;
rollMode?: RollMode;
}
Expand Down
Loading

0 comments on commit 992a43b

Please sign in to comment.