diff --git a/src/module/actor/character/apps/attack-popout.ts b/src/module/actor/character/apps/attack-popout.ts index 1840d725ab6..aef320bedee 100644 --- a/src/module/actor/character/apps/attack-popout.ts +++ b/src/module/actor/character/apps/attack-popout.ts @@ -2,16 +2,16 @@ import type { ApplicationV1HeaderButton } from "@client/appv1/api/application-v1 import type { ActorSheetOptions } from "@client/appv1/sheets/actor-sheet.d.mts"; import type { EffectTrait } from "@item/abstract-effect/types.ts"; import { ErrorPF2e, htmlClosest, htmlQuery } from "@util"; -import type { CharacterStrike } from "../data.ts"; +import type { CharacterAttack } from "../data.ts"; import type { CharacterPF2e } from "../document.ts"; import type { ElementalBlastConfig } from "../elemental-blast.ts"; import { CharacterSheetPF2e, type CharacterSheetData } from "../sheet.ts"; class AttackPopout extends CharacterSheetPF2e { - type: "strike" | "blast" = "strike"; - #strikeItemId = ""; - #strikeSlug = ""; - #strike?: CharacterStrike; + type: AttackPopoutOptions["type"] = "strike"; + #itemId = ""; + #slug = ""; + #attack?: CharacterAttack; #elementTrait?: EffectTrait; #blasts: ElementalBlastConfig[] = []; @@ -21,9 +21,9 @@ class AttackPopout extends CharacterSheetPF2e extends CharacterSheetPF2e extends CharacterSheetPF2e extends CharacterSheetPF2e t.domain === "elemental-blast") ?? []; } else { base.elementalBlasts = []; - if (this.#strikeSlug && this.#strikeItemId) { - this.#strike = base.data.actions.find( - (a) => a.item.id === this.#strikeItemId && a.slug === this.#strikeSlug, - ); + if (this.#slug && this.#itemId) { + this.#attack = base.data.actions.find((a) => a.item.id === this.#itemId && a.slug === this.#slug); } } return { ...base, - strike: this.#strike, - strikeIndex: base.data.actions.findIndex((a) => a === this.#strike), + attack: this.#attack, + index: base.data.actions.findIndex((a) => a === this.#attack), popoutType: this.type, }; } @@ -120,9 +118,9 @@ interface BaseAttackPopoutOptions extends Partial { } interface StrikePopoutOptions extends BaseAttackPopoutOptions { - type: "strike"; - strikeSlug?: string; - strikeItemId?: string; + type: "strike" | "area-fire" | "auto-fire"; + slug?: string; + itemId?: string; } interface BlastPopoutOptions extends BaseAttackPopoutOptions { @@ -133,8 +131,8 @@ interface BlastPopoutOptions extends BaseAttackPopoutOptions { type AttackPopoutOptions = StrikePopoutOptions | BlastPopoutOptions; interface AttackPopoutData extends CharacterSheetData { - strike?: CharacterStrike; - strikeIndex?: number; + attack?: CharacterAttack; + index?: number; popoutType: AttackPopoutOptions["type"]; } diff --git a/src/module/actor/character/data.ts b/src/module/actor/character/data.ts index 48878d52f2e..2e39e9a524f 100644 --- a/src/module/actor/character/data.ts +++ b/src/module/actor/character/data.ts @@ -18,6 +18,7 @@ import { CreatureInitiativeSource, Language } from "@actor/creature/index.ts"; import { ActorAttributesSource, ActorFlagsPF2e, + AreaAttack, AttributeBasedTraceData, HitPointsStatistic, InitiativeData, @@ -268,7 +269,7 @@ interface CharacterSystemData extends Omit; /** Special strikes which the character can take. */ - actions: CharacterStrike[]; + actions: CharacterAttack[]; resources: CharacterResources; @@ -388,22 +389,33 @@ interface ClassDCData extends Required { primary: boolean; } -/** The full data for a character strike */ -interface CharacterStrike extends StrikeData { - item: WeaponPF2e; +interface BasicAttackData { /** Whether this attack is visible on the sheet */ visible: boolean; + auxiliaryActions: WeaponAuxiliaryAction[]; + weaponTraits: TraitViewData[]; +} + +/** The full data for a character strike */ +interface CharacterStrike extends StrikeData, BasicAttackData { + item: WeaponPF2e; /** Domains/selectors from which modifiers are drawn */ domains: string[]; /** Whether the character has sufficient hands available to wield this weapon or use this unarmed attack */ handsAvailable: boolean; - altUsages: CharacterStrike[]; - auxiliaryActions: WeaponAuxiliaryAction[]; - weaponTraits: TraitViewData[]; + altUsages: CharacterAttack[]; doubleBarrel: { selected: boolean } | null; versatileOptions: VersatileWeaponOption[]; } +interface CharacterAreaAttack extends AreaAttack, BasicAttackData { + item: WeaponPF2e; + /** Whether this attack is visible on the sheet */ + altUsages: CharacterAttack[]; +} + +type CharacterAttack = CharacterStrike | CharacterAreaAttack; + interface VersatileWeaponOption { value: DamageType; selected: boolean; @@ -504,6 +516,8 @@ export type { BaseWeaponProficiencyKey, CategoryProficiencies, CharacterAbilities, + CharacterAreaAttack, + CharacterAttack, CharacterAttributes, CharacterAttributesSource, CharacterBiography, diff --git a/src/module/actor/character/document.ts b/src/module/actor/character/document.ts index a13f337fb40..c3c6ef9eff6 100644 --- a/src/module/actor/character/document.ts +++ b/src/module/actor/character/document.ts @@ -7,6 +7,8 @@ import { ActorSizePF2e } from "@actor/data/size.ts"; import { MultipleAttackPenaltyData, calculateMAPs, + createAreaAttackMessage, + createDamageRollFunctions, getAttackDamageDomains, getStrikeAttackDomains, isReallyPC, @@ -41,8 +43,9 @@ import type { import { WeaponPF2e } from "@item"; import type { AbilityTrait } from "@item/ability/types.ts"; import { ARMOR_CATEGORIES } from "@item/armor/values.ts"; +import { ActionCost } from "@item/base/data/index.ts"; import { getPropertyRuneDegreeAdjustments, getPropertyRuneStrikeAdjustments } from "@item/physical/runes.ts"; -import type { ItemType } from "@item/types.ts"; +import type { EffectAreaShape, ItemType } from "@item/types.ts"; import type { WeaponSource } from "@item/weapon/data.ts"; import { processTwoHandTrait } from "@item/weapon/helpers.ts"; import type { WeaponCategory } from "@item/weapon/types.ts"; @@ -65,13 +68,15 @@ import { WeaponDamagePF2e } from "@system/damage/weapon.ts"; import { Predicate } from "@system/predication.ts"; import { AttackRollParams, DamageRollParams, RollParameters } from "@system/rolls.ts"; import { ArmorStatistic, PerceptionStatistic, Statistic } from "@system/statistic/index.ts"; -import { ErrorPF2e, setHasElement, signedInteger, sluggify } from "@util/misc.ts"; +import { ErrorPF2e, getActionGlyph, setHasElement, signedInteger, sluggify } from "@util/misc.ts"; import { traitSlugToObject } from "@util/tags.ts"; import * as R from "remeda"; import { CharacterCrafting } from "./crafting/index.ts"; import { BaseWeaponProficiencyKey, CharacterAbilities, + CharacterAreaAttack, + CharacterAttack, CharacterAttributes, CharacterFlags, CharacterSkillData, @@ -624,7 +629,7 @@ class CharacterPF2e s.ready).map((s) => s.item.system.damage.dice), 0, @@ -1016,7 +1021,7 @@ class CharacterPF2e this.prepareStrike(w, { categories: offensiveCategories, handsReallyFree, ammos })) + const attacks = weapons + .map((w) => + w.baseType === "grenade" || w.system.traits.config.area + ? this.prepareAreaAttack(w, { ammos }) + : this.prepareStrike(w, { categories: offensiveCategories, handsReallyFree, ammos }), + ) .sort((a, b) => a.label .toLocaleLowerCase(game.i18n.lang) @@ -1103,23 +1113,167 @@ class CharacterPF2e (a.item.isHeld === b.item.isHeld ? 0 : a.item.isHeld ? -1 : 1)) .sort((a, b) => (a.ready === b.ready ? 0 : a.ready ? -1 : 1)); + // Create alt usages for each strike, based on traits and such + for (const attack of attacks) { + const weapon = attack.item; + attack.altUsages.push( + ...weapon + .getAltUsages() + .map((w) => this.prepareStrike(w, { categories: offensiveCategories, handsReallyFree })), + ); + if (attack.type === "area-fire" && weapon.baseType !== "grenade") { + // If this was an area fire, add the normal strike as a secondary usage at the front + attack.altUsages.unshift( + this.prepareStrike(weapon, { categories: offensiveCategories, handsReallyFree }), + ); + } else if (weapon.system.traits.value.includes("automatic")) { + // If this is an automatic weapon, add the area usage at the very end + attack.altUsages.push(this.prepareAreaAttack(weapon)); + } + } + // Finally, position subitem weapons directly below their parents - for (const subitemStrike of strikes.filter((s) => s.item.parentItem)) { + for (const subitemStrike of attacks.filter((s) => s.item.parentItem)) { const subitem = subitemStrike.item; - const parentStrike = strikes.find((s) => (s.item.shield ?? s.item) === subitem.parentItem); + const parentStrike = attacks.find((s) => (s.item.shield ?? s.item) === subitem.parentItem); if (parentStrike) { - strikes.splice(strikes.indexOf(subitemStrike), 1); - strikes.splice(strikes.indexOf(parentStrike) + 1, 0, subitemStrike); + attacks.splice(attacks.indexOf(subitemStrike), 1); + attacks.splice(attacks.indexOf(parentStrike) + 1, 0, subitemStrike); } } - return strikes; + return attacks; + } + + private prepareAreaAttack( + weapon: WeaponPF2e, + { ammos = [] }: { ammos?: (ConsumablePF2e | WeaponPF2e)[] } = {}, + ): CharacterAreaAttack { + const actor = weapon.actor; + const isAutomatic = weapon.system.traits.value.includes("automatic"); + const action = isAutomatic ? "auto-fire" : "area-fire"; + + const classDC = actor.getStatistic("class"); + if (!classDC) throw ErrorPF2e("Statistic is required for actors without a class dc"); + + const tracking = weapon.system.traits.config?.tracking; + const domains = ["all", `${action}-save`]; + const statistic = classDC.extend({ + modifiers: tracking + ? [ + new Modifier({ + slug: "tracking", + label: "PF2E.Item.Weapon.Tracking", + type: "item", + modifier: tracking, + adjustments: extractModifierAdjustments( + actor.synthetics.modifierAdjustments, + domains, + "tracking", + ), + }), + ] + : [], + }); + + const area = ((): { type: EffectAreaShape; value: number } => { + // Handle grenades + if (weapon.baseType === "grenade") { + const description = weapon.system.description.value; + const areaMatch = description.match(/Template\[burst\|distance:(?\d+)\]/); + const value = Number(areaMatch?.groups?.distance ?? 5); + return { type: "burst", value }; + } + + const itemRange = weapon.system.range || actor.getReach({ weapon: weapon }); + + // Handle automatic weapons + if (weapon.system.traits.value.includes("automatic")) { + return { + type: "cone", + value: Math.max(5, Math.floor(itemRange / 2) - (Math.floor(itemRange / 2) % 5)), + }; + } + + // Handle area weapons + const areaAnnotation = weapon.system.traits.config.area; + if (!areaAnnotation) throw ErrorPF2e(`Unable to calculate area for weapon ${weapon.uuid}`); + const type = areaAnnotation.type; + return { type, value: areaAnnotation.value || (type === "burst" ? 5 : itemRange) }; + })(); + + const actionLabel = `PF2E.Actions.${sluggify(action, { camel: "bactrian" })}.Title`; + const weaponSlug = weapon.slug ?? sluggify(weapon.name); + const meleeOrRanged = weapon.isMelee ? "melee" : "ranged"; + const actionCost: ActionCost = { type: "action", value: weapon.baseType === "grenade" ? 1 : 2 }; + const weaponRollOptions = new Set(weapon.getRollOptions("item")); + const proficiencyRank = getItemProficiencyRank(actor, weapon, weaponRollOptions); + const options = [ + `self:action:slug:${action}`, + meleeOrRanged, + "area-damage", + "area-effect", + ...weaponRollOptions, + ]; + + const identifier = `${weapon.id}.${weaponSlug}.${action}`; + const hiddenCauseStowed = weapon.isStowed && this.flags.pf2e.hideStowed; + const hiddenCauseUnarmed = weapon.slug === "basic-unarmed" && !this.flags.pf2e.showBasicUnarmed; + + return { + slug: identifier, + type: action, + attackRollType: actionLabel, + label: weapon.name, + visible: !(hiddenCauseStowed || hiddenCauseUnarmed), + glyph: getActionGlyph(actionCost), + description: weapon.description, + ready: true, + canAttack: true, + altUsages: [], + auxiliaryActions: getWeaponAuxiliaryActions(weapon), + modifiers: [], + item: weapon, + statistic, + weaponTraits: weapon.system.traits.value + .map((t) => traitSlugToObject(t, CONFIG.PF2E.npcAttackTraits)) + .sort((a, b) => a.label.localeCompare(b.label)), + variants: [ + { + label: game.i18n.format("PF2E.ActionWithDC", { + label: game.i18n.localize(actionLabel), + dc: statistic.dc.value, + }), + roll: () => { + createAreaAttackMessage({ + actor, + item: weapon, + statistic, + action, + identifier, + actionCost, + domains, + options, + area, + }); + }, + }, + ], + ...createDamageRollFunctions(weapon, { + action, + statistic, + baseOptions: options, + actionTraits: ["attack"], + proficiencyRank, + }), + ammunition: getAttackAmmo(weapon, { ammos }), + }; } /** Prepare a strike action from a weapon */ private prepareStrike( weapon: WeaponPF2e, - { categories, handsReallyFree, ammos = [] }: PrepareStrikeOptions, + { handsReallyFree, ammos = [] }: PrepareStrikeOptions, ): CharacterStrike { const synthetics = this.synthetics; const modifiers: Modifier[] = []; @@ -1247,7 +1401,7 @@ class CharacterPF2e this.prepareStrike(w, { categories, handsReallyFree })); + const versatileLabel = (damageType: DamageType): string => { switch (damageType) { case "bludgeoning": @@ -1311,8 +1465,8 @@ class CharacterPF2e a.label.localeCompare(b.label)), variants: [], selectedAmmoId: weapon.system.selectedAmmoId, - canStrike: true, - altUsages, + canAttack: true, + altUsages: [], auxiliaryActions: getWeaponAuxiliaryActions(weapon), doubleBarrel, versatileOptions, diff --git a/src/module/actor/character/helpers.ts b/src/module/actor/character/helpers.ts index f11f51b9e29..3ba753a221b 100644 --- a/src/module/actor/character/helpers.ts +++ b/src/module/actor/character/helpers.ts @@ -1,6 +1,6 @@ import type { ActorPF2e, CharacterPF2e } from "@actor"; import { AttackTraitHelpers } from "@actor/creature/helpers.ts"; -import { AttackAmmunitionData } from "@actor/data/base.ts"; +import type { AttackAmmunitionData } from "@actor/data/base.ts"; import { Modifier } from "@actor/modifiers.ts"; import { AbilityItemPF2e, ArmorPF2e, ConditionPF2e, ConsumablePF2e, ItemProxyPF2e, WeaponPF2e } from "@item"; import type { ZeroToFour } from "@module/data.ts"; diff --git a/src/module/actor/data/base.ts b/src/module/actor/data/base.ts index 24fe9d84b76..005d7522836 100644 --- a/src/module/actor/data/base.ts +++ b/src/module/actor/data/base.ts @@ -3,7 +3,6 @@ import type { DexterityModifierCapData } from "@actor/character/types.ts"; import type { Abilities } from "@actor/creature/data.ts"; import type { InitiativeTraceData } from "@actor/initiative.ts"; import type { Modifier, StatisticModifier } from "@actor/modifiers.ts"; -import type { NPCAreaAttack } from "@actor/npc/data.ts"; import type { ActorAlliance, AttributeString, SkillSlug } from "@actor/types.ts"; import type { Rolled } from "@client/dice/roll.d.mts"; import type { DocumentFlags, DocumentFlagsSource } from "@common/data/_module.d.mts"; @@ -15,6 +14,7 @@ import type { AttackRollParams, DamageRollParams, RollParameters } from "@module import type { CheckRoll } from "@system/check/roll.ts"; import type { DamageRoll } from "@system/damage/roll.ts"; import type { StatisticTraceData } from "@system/statistic/data.ts"; +import type { Statistic } from "@system/statistic/statistic.ts"; import type { Immunity, ImmunitySource, Resistance, ResistanceSource, Weakness, WeaknessSource } from "./iwr.ts"; import type { ActorSizePF2e } from "./size.ts"; @@ -233,9 +233,11 @@ interface BasicAttackAction { * strike is equipped) */ ready: boolean; + /** Whether striking itself, independent of the auxiliary actions, is possible */ + canAttack: boolean; /** The weapon or melee item--possibly ephemeral--being used for the strike */ item: WeaponPF2e | MeleePF2e; - altUsages?: StrikeData[]; + altUsages?: AttackAction[]; /** Roll normal (non-critical) damage for this weapon. */ damage?: DamageRollFunction; /** Roll critical damage for this weapon. */ @@ -267,19 +269,27 @@ interface StrikeData extends StatisticModifier, BasicAttackAction { traits: TraitViewData[]; /** Any options always applied to this strike */ options: string[]; - /** Whether striking itself, independent of the auxiliary actions, is possible */ - canStrike: boolean; /** Alias for `attack`. */ roll?: RollFunction; /** Roll to attack with the given strike (with no MAP; see `variants` for MAPs.) */ attack?: RollFunction; /** Alternative usages of a strike weapon: thrown, combination-melee, etc. */ - altUsages?: StrikeData[]; + altUsages?: AttackAction[]; /** A list of attack variants which apply the Multiple Attack Penalty. */ variants: { label: string; roll: RollFunction }[]; } -type AttackAction = StrikeData | NPCAreaAttack; +interface AreaAttack extends BasicAttackAction { + type: "area-fire" | "auto-fire"; + item: MeleePF2e | WeaponPF2e; + /** The type of attack as a localization string */ + attackRollType: string; + statistic: Statistic; + /** A list of buttons to show. In practice there is only one */ + variants: { label: string; roll: () => void }[]; +} + +type AttackAction = StrikeData | AreaAttack; /** Any skill or similar which provides a roll option for rolling this save. */ interface Rollable { @@ -317,6 +327,7 @@ export type { ActorSystemSource, ActorTraitsData, ActorTraitsSource, + AreaAttack, ArmorClassData, AttackAction, AttackAmmunitionData, diff --git a/src/module/actor/helpers.ts b/src/module/actor/helpers.ts index 29564f6ec09..3d94a5a66c1 100644 --- a/src/module/actor/helpers.ts +++ b/src/module/actor/helpers.ts @@ -8,6 +8,7 @@ import { createEffectAreaLabel } from "@item/helpers.ts"; import { NPC_ATTACK_ACTIONS } from "@item/melee/values.ts"; import { getPropertyRuneStrikeAdjustments } from "@item/physical/runes.ts"; import { EffectAreaShape } from "@item/types.ts"; +import type { AreaAttackContextFlag } from "@module/chat-message/data.ts"; import { ChatMessagePF2e } from "@module/chat-message/document.ts"; import type { ZeroToFour, ZeroToTwo } from "@module/data.ts"; import { MigrationList, MigrationRunner } from "@module/migration/index.ts"; @@ -509,7 +510,7 @@ function strikeFromMeleeItem(item: MeleePF2e): NPCStrike { additionalEffects, item, weapon: item, - canStrike: true, + canAttack: true, options: Array.from(baseOptions), traits: [ actionTraits.map((t) => traitSlugToObject(t, CONFIG.PF2E.actionTraits)), @@ -635,7 +636,13 @@ function strikeFromMeleeItem(item: MeleePF2e): NPCStrike { })); strike.roll = strike.attack = strike.variants[0].roll; - const damageRoll = createDamageRollFunctions(item, { statistic: strike, baseOptions, actionTraits }); + const damageRoll = createDamageRollFunctions(item, { + action: "strike", + statistic: strike, + baseOptions, + actionTraits, + proficiencyRank: 1, + }); strike.damage = damageRoll.damage; strike.critical = damageRoll.critical; @@ -649,6 +656,7 @@ function areaFireFromMeleeItem(item: MeleePF2e): NPCAreaAttack { const actor = item.actor; const attackSlug = item.slug ?? sluggify(item.name); const meleeOrRanged = item.isMelee ? "melee" : "ranged"; + const domains = ["all", `${action}-save`]; const baseOptions = [ `self:action:slug:${action}`, meleeOrRanged, @@ -656,7 +664,6 @@ function areaFireFromMeleeItem(item: MeleePF2e): NPCAreaAttack { "area-damage", "area-effect", ]; - const domains = ["all"]; const synthetics = actor.synthetics; const modifiers = [ @@ -677,6 +684,7 @@ function areaFireFromMeleeItem(item: MeleePF2e): NPCAreaAttack { }); const actionCost: ActionCost = { type: "action", value: item.system.traits.value.includes("consumable") ? 1 : 2 }; + const identifier = `${item.id}.${attackSlug}.${action}`; const statistic = new Statistic(actor, { slug: attackSlug, label: item.name, @@ -684,13 +692,14 @@ function areaFireFromMeleeItem(item: MeleePF2e): NPCAreaAttack { }); return { - slug: attackSlug, + slug: identifier, type: action, attackRollType: NPC_ATTACK_ACTIONS[action], label: item.name, glyph: getActionGlyph(actionCost), description: item.description, ready: true, + canAttack: true, modifiers: [], item, statistic, @@ -703,34 +712,56 @@ function areaFireFromMeleeItem(item: MeleePF2e): NPCAreaAttack { }), roll: () => { if (!item.system.area) throw ErrorPF2e("Unexpected missing area data"); - createAreaFireMessage({ + createAreaAttackMessage({ actor, item, statistic, action, + identifier, actionCost, + domains, + options: baseOptions, area: item.system.area, }); }, }, ], - ...createDamageRollFunctions(item, { statistic, baseOptions, actionTraits: ["attack"] }), + ...createDamageRollFunctions(item, { + action, + statistic, + baseOptions, + actionTraits: ["attack"], + proficiencyRank: 1, + }), }; } +/** + * Helper function that creates damage roll functions for character and npc attacks. + * While it used for character area/auto fire, its not used for character strikes yet. + * + */ function createDamageRollFunctions( - item: MeleePF2e, + item: MeleePF2e | WeaponPF2e, { + action, statistic, actionTraits, baseOptions, - }: { statistic: NPCStrike | Statistic; actionTraits: AbilityTrait[]; baseOptions: Iterable }, -) { + proficiencyRank, + }: { + action: "strike" | "area-fire" | "auto-fire"; + statistic: NPCStrike | Statistic; + actionTraits: AbilityTrait[]; + baseOptions: Iterable; + proficiencyRank: ZeroToFour; + }, +): { damage: DamageRollFunction; critical: DamageRollFunction } { const actor = item.actor; const createDamageRoll = (outcome: "success" | "criticalSuccess"): DamageRollFunction => async (params: DamageRollParams = {}): Promise | string | null> => { - const domains = getAttackDamageDomains(item, actor.isOfType("npc") ? 1 : null); + const domains = getAttackDamageDomains(item, proficiencyRank); const targetToken = (params.target ?? game.user.targets.first())?.document ?? null; const context = await new DamageContext({ viewOnly: params.getFormula ?? false, @@ -751,7 +782,7 @@ function createDamageRollFunctions( const damageContext: DamageDamageContext = { type: "damage-roll", - sourceType: "attack", + sourceType: action === "strike" ? "attack" : "save", self: context.origin, target: context.target, outcome, @@ -770,11 +801,18 @@ function createDamageRollFunctions( if (params.getFormula) damageContext.skipDialog = true; - const damage = await WeaponDamagePF2e.fromNPCAttack({ - attack: context.origin.item, - actor: context.origin.actor, - context: damageContext, - }); + const damage = item.isOfType("melee") + ? await WeaponDamagePF2e.fromNPCAttack({ + attack: item, + actor: context.origin.actor, + context: damageContext, + }) + : await WeaponDamagePF2e.calculate({ + weapon: item, + actor: context.origin.actor, + weaponPotency: item.flags.pf2e.attackItemBonus, + context: damageContext, + }); if (!damage) return null; if (params.getFormula) { @@ -791,22 +829,31 @@ function createDamageRollFunctions( }; } +interface AreaAttackOptions { + action: "area-fire" | "auto-fire"; + actor: ActorPF2e; + item: WeaponPF2e | MeleePF2e; + // The statistic to area fire with. If omitted, will attempt to compute one if its a PC + statistic: Statistic; + identifier: string; + actionCost: ActionCost; + domains: string[]; + options: string[]; + area: { type: EffectAreaShape; value: number }; +} + /** Creates an area fire message with buttons to roll saves and damage */ -async function createAreaFireMessage({ +async function createAreaAttackMessage({ + action, actor, item, - action, statistic, + identifier, actionCost, + domains, + options, area, -}: { - actor: ActorPF2e; - item: WeaponPF2e | MeleePF2e; - statistic: Statistic; - action: "area-fire" | "auto-fire"; - actionCost: ActionCost; - area: { type: EffectAreaShape; value: number }; -}): Promise { +}: AreaAttackOptions): Promise { const dc = statistic.dc; const key = sluggify(action, { camel: "bactrian" }); @@ -832,12 +879,21 @@ async function createAreaFireMessage({ saveBreakdown: dc.breakdown, }); + const context: AreaAttackContextFlag = { + type: action, + area, + identifier, + domains, + options, + }; + await ChatMessagePF2e.create({ flavor, content, speaker, flags: { pf2e: { + context, origin: item.getOriginData(), }, }, @@ -994,6 +1050,8 @@ export { calculateRangePenalty, checkAreaEffects, createActorGroupUpdate, + createAreaAttackMessage, + createDamageRollFunctions, createEncounterRollOptions, createEnvironmentRollOptions, getAttackDamageDomains, diff --git a/src/module/actor/npc/data.ts b/src/module/actor/npc/data.ts index 58b3ced5ada..c16c5b23214 100644 --- a/src/module/actor/npc/data.ts +++ b/src/module/actor/npc/data.ts @@ -22,8 +22,8 @@ import type { import type { ActorAttributesSource, ActorFlagsPF2e, + AreaAttack, AttributeBasedTraceData, - BasicAttackAction, HitPointsStatistic, StrikeData, } from "@actor/data/base.ts"; @@ -33,7 +33,6 @@ import type { ActorAlliance, SaveType, SkillSlug } from "@actor/types.ts"; import type { MeleePF2e } from "@item"; import type { PublicationData, ValueAndMax } from "@module/data.ts"; import type { RawPredicate } from "@system/predication.ts"; -import type { Statistic } from "@system/statistic/index.ts"; type NPCSource = BaseCreatureSource<"npc", NPCSystemSource> & { flags: DeepPartial; @@ -220,16 +219,11 @@ interface NPCStrike extends StrikeData { altUsages?: never; } -interface NPCAreaAttack extends BasicAttackAction { - type: "area-fire" | "auto-fire"; +interface NPCAreaAttack extends AreaAttack { item: MeleePF2e; - /** The type of attack as a localization string */ - attackRollType: string; - altUsages?: never; - statistic: Statistic; + /** Additional effects from a successful strike, like "Grab" */ additionalEffects: { tag: string; label: string }[]; - /** A list of buttons to show. In practice there is only one */ - variants: { label: string; roll: () => void }[]; + altUsages?: never; } type NPCAttackAction = NPCStrike | NPCAreaAttack; diff --git a/src/module/actor/sheet/base.ts b/src/module/actor/sheet/base.ts index c9202fe7e72..a338cf92749 100644 --- a/src/module/actor/sheet/base.ts +++ b/src/module/actor/sheet/base.ts @@ -320,12 +320,8 @@ abstract class ActorSheetPF2e extends fav1.sheets.Acto protected getAttackActionFromDOM(button: HTMLElement, readyOnly = false): AttackAction | null { const actionIndex = Number(htmlClosest(button, "[data-action-index]")?.dataset.actionIndex ?? "NaN"); const rootAction = this.actor.system.actions?.at(actionIndex) ?? null; - const altUsage = tupleHasValue(["thrown", "melee"], button?.dataset.altUsage) ? button?.dataset.altUsage : null; - - const strike = altUsage - ? (rootAction?.altUsages?.find((s) => (altUsage === "thrown" ? s.item.isThrown : s.item.isMelee)) ?? null) - : rootAction; - + const altUsage = "altUsage" in button?.dataset ? Number(button?.dataset.altUsage) : null; + const strike = typeof altUsage === "number" ? (rootAction?.altUsages?.at(altUsage) ?? null) : rootAction; return strike?.ready || !readyOnly ? strike : null; } diff --git a/src/module/chat-message/data.ts b/src/module/chat-message/data.ts index df7aff4a9b3..1f9e02949ef 100644 --- a/src/module/chat-message/data.ts +++ b/src/module/chat-message/data.ts @@ -5,7 +5,7 @@ import type { RollMode } from "@common/constants.d.mts"; import type { ChatMessageFlags } from "@common/documents/chat-message.d.mts"; import type { SpellSource } from "@item/base/data/index.ts"; import type { MagicTradition } from "@item/spell/types.ts"; -import type { ItemType } from "@item/types.ts"; +import type { EffectAreaShape, ItemType } from "@item/types.ts"; import type { ZeroToTwo } from "@module/data.ts"; import type { RollNoteSource } from "@module/notes.ts"; import type { CheckCheckContext } from "@system/check/index.ts"; @@ -47,7 +47,8 @@ type ChatContextFlag = | DamageDamageContextFlag | SpellCastContextFlag | SelfEffectContextFlag - | DamageTakenContextFlag; + | DamageTakenContextFlag + | AreaAttackContextFlag; interface DamageRollFlag { outcome: DegreeOfSuccessString; @@ -124,6 +125,15 @@ interface SpellCastContextFlag { rollMode?: RollMode; } +interface AreaAttackContextFlag { + type: "area-fire" | "auto-fire"; + area: { type: EffectAreaShape; value: number }; + identifier: string; + domains: string[]; + options: string[]; + outcome?: never; +} + interface SelfEffectContextFlag { type: "self-effect"; item: string; @@ -154,6 +164,7 @@ interface AppliedDamageFlag { export type { ActorTokenFlag, AppliedDamageFlag, + AreaAttackContextFlag, ChatContextFlag, ChatMessageFlagsPF2e, ChatMessageSourcePF2e, diff --git a/src/module/chat-message/document.ts b/src/module/chat-message/document.ts index fa45834a44a..26b8ba7f30e 100644 --- a/src/module/chat-message/document.ts +++ b/src/module/chat-message/document.ts @@ -1,5 +1,5 @@ import type { ActorPF2e } from "@actor"; -import type { StrikeData } from "@actor/data/base.ts"; +import type { AttackAction, StrikeData } from "@actor/data/base.ts"; import type { Rolled } from "@client/dice/roll.d.mts"; import type { DataModelConstructionContext } from "@common/abstract/_module.d.mts"; import type { @@ -133,9 +133,16 @@ class ChatMessagePF2e extends ChatMessage { return item; } - /** If this message was for a strike, return the strike. Strikes will change in a future release */ get _strike(): StrikeData | null { + // todo: deprecation warning + const attack = this._attack; + return attack?.type === "strike" ? attack : null; + } + + /** If this message was for a strike, return the strike. Strikes will change in a future release */ + get _attack(): AttackAction | null { const actor = this.speakerActor; + if (!actor) return null; // Handle in-memory unarmed attacks const origin = this.flags.pf2e.origin; @@ -145,18 +152,27 @@ class ChatMessagePF2e extends ChatMessage { } // Get strike data from the roll identifier + const context = this.flags.pf2e.context; const roll = this.rolls.find((r): r is Rolled => r instanceof CheckRoll); const identifier = roll?.options.identifier ?? + (context && "identifier" in context ? context.identifier : null) ?? htmlQuery(document.body, `li.message[data-message-id="${this.id}"] [data-identifier]`)?.dataset.identifier; + + // First check for an exact match with the identifier, which is a bit more specific (this catches area/auto fire rn, expand later) + for (const action of actor.system.actions ?? []) { + if (action.slug === identifier) return action; + const altUsageMatch = action.altUsages?.find((u) => u.slug === identifier); + if (altUsageMatch) return action; + } + + // Parse the identifier and attempt to figure it out based on weapon type matching const [itemId, slug, meleeOrRanged] = identifier?.split(".") ?? [null, null, null]; if (!meleeOrRanged || !["melee", "ranged"].includes(meleeOrRanged)) { return null; } - const strikeData = actor?.system.actions?.find( - (s): s is StrikeData => s.type === "strike" && s.slug === slug && s.item.id === itemId, - ); + const strikeData = actor.system.actions?.find((s) => s.slug === slug && s.item.id === itemId); const itemMeleeOrRanged = strikeData?.item.isMelee ? "melee" : "ranged"; return meleeOrRanged === itemMeleeOrRanged diff --git a/src/module/chat-message/listeners/cards.ts b/src/module/chat-message/listeners/cards.ts index a3ae1aa4168..c06688a1258 100644 --- a/src/module/chat-message/listeners/cards.ts +++ b/src/module/chat-message/listeners/cards.ts @@ -46,8 +46,8 @@ class ChatCards { } // Handle attacks - const strikeAction = message._strike; - if (strikeAction?.type === "strike" && action?.startsWith("strike-")) { + const attack = message._attack; + if (attack?.type === "strike" && action?.startsWith("strike-")) { const context = ( message.rolls.some((r) => r instanceof CheckRoll) ? (message.flags.pf2e.context ?? null) : null ) as CheckContextChatFlag | null; @@ -61,17 +61,17 @@ class ChatCards { switch (sluggify(action ?? "")) { case "strike-attack": - strikeAction.variants[0].roll(rollArgs); + attack.variants[0].roll(rollArgs); return; case "strike-attack2": - strikeAction.variants[1].roll(rollArgs); + attack.variants[1].roll(rollArgs); return; case "strike-attack3": - strikeAction.variants[2].roll(rollArgs); + attack.variants[2].roll(rollArgs); return; case "strike-damage": { const method = button.dataset.outcome === "success" ? "damage" : "critical"; - strikeAction[method]?.(rollArgs); + attack[method]?.(rollArgs); return; } } @@ -101,7 +101,6 @@ class ChatCards { // Handle everything else if (item) { const spell = item.isOfType("spell") ? item : item.isOfType("consumable") ? item.embeddedSpell : null; - const attack = item.isOfType("melee") ? actor.system.actions?.find((a) => a.item.id === item.id) : null; switch (action) { case "spell-attack": @@ -228,9 +227,9 @@ class ChatCards { attack?.damage?.({ event }); return; case "place-area-template": { - const area = item.isOfType("melee") ? item.system.area : null; - if (!area) return; - placeItemTemplate(area, { item, message }); + const context = message.flags.pf2e.context; + const area = tupleHasValue(["area-fire", "auto-fire"], context?.type) ? context.area : null; + if (area) placeItemTemplate(area, { item, message }); return; } } diff --git a/src/module/item/base/data/system.ts b/src/module/item/base/data/system.ts index 8a920571a9c..3ce1a45e53c 100644 --- a/src/module/item/base/data/system.ts +++ b/src/module/item/base/data/system.ts @@ -1,6 +1,6 @@ import type { DocumentFlags, DocumentFlagsSource } from "@common/data/_types.d.mts"; import type * as fields from "@common/data/fields.d.mts"; -import type { ItemType } from "@item/types.ts"; +import type { EffectAreaShape, ItemType } from "@item/types.ts"; import type { MigrationRecord, OneToThree, PublicationData, Rarity } from "@module/data.ts"; import type { RuleElementSource } from "@module/rules/index.ts"; import type { Predicate } from "@system/predication.ts"; @@ -24,7 +24,8 @@ interface TraitConfig { volley?: number; tracking?: number; resilient?: number; - [key: string]: number | undefined; + area?: { type: EffectAreaShape; value: number | null }; + [key: string]: unknown | undefined; } interface ItemTraits { diff --git a/src/module/item/base/document.ts b/src/module/item/base/document.ts index 52ed81cf669..c33e76c5521 100644 --- a/src/module/item/base/document.ts +++ b/src/module/item/base/document.ts @@ -281,14 +281,13 @@ class ItemPF2e extends Item // If this item has traits, filter for valid traits and check for annotations if (this.system.traits.value) { - this.system.traits.value = this.system.traits.value.filter((t) => t in this.constructor.validTraits); + const validTraits = this.constructor.validTraits; + const original = this.system.traits.value; + this.system.traits.value = []; this.system.traits.config = {}; - for (const trait of this.system.traits.value) { - const annotatedTraitMatch = trait.match(/^([a-z][-a-z]+)-(\d*d?\d+)$/); - if (annotatedTraitMatch) { - const [_, traitBase, annotation] = annotatedTraitMatch; - this.system.traits.config[traitBase] = Number(annotation); - } + for (const trait of original) { + if (!(trait in validTraits)) continue; + addOrUpgradeTrait(this.system.traits, trait); } } diff --git a/src/module/item/helpers.ts b/src/module/item/helpers.ts index 0fc902ce122..b28af8bc97b 100644 --- a/src/module/item/helpers.ts +++ b/src/module/item/helpers.ts @@ -2,7 +2,7 @@ import type { ActorPF2e } from "@actor"; import { MeasuredTemplateType } from "@common/constants.mjs"; import { MeasuredTemplatePF2e } from "@module/canvas/measured-template.ts"; import { ChatMessagePF2e } from "@module/chat-message/document.ts"; -import { createHTMLElement, ErrorPF2e, setHasElement } from "@util"; +import { createHTMLElement, ErrorPF2e, setHasElement, tupleHasValue } from "@util"; import type { Converter } from "showdown"; import { processSanctification } from "./ability/helpers.ts"; import type { ItemSourcePF2e } from "./base/data/index.ts"; @@ -12,6 +12,7 @@ import { ItemTrait } from "./base/types.ts"; import type { PhysicalItemPF2e } from "./physical/document.ts"; import { PHYSICAL_ITEM_TYPES } from "./physical/values.ts"; import type { EffectAreaShape, ItemInstances, ItemType } from "./types.ts"; +import { EFFECT_AREA_SHAPES } from "./values.ts"; type ItemOrSource = PreCreate | ItemPF2e; @@ -93,6 +94,32 @@ function addOrUpgradeTrait( const value = isArray ? traits : traits.value; const config = isArray ? null : (traits.config ??= {}); + // Check if its an SF2e area trait, which has special handling + const areaRegexp = /^area-([\w]+)(?:-(\d+))?$/; + const areaMatch = newTrait.match(areaRegexp); + if (areaMatch) { + const type = areaMatch.at(1); + const range = areaMatch.at(2) ? Number(areaMatch.at(2)) : null; + if (!tupleHasValue(EFFECT_AREA_SHAPES, type)) { + console.error(`Unexpected area shape ${type}`); + return; + } + if ( + mode === "upgrade" && + range && + config?.area?.type === type && + (!config.area.value || range > config.area.value) + ) { + config.area.value = range; + } else { + if (config) config.area = { type, value: range }; + const existing = value.find((t) => t.match(areaRegexp)); + if (existing) value.splice(value.indexOf(existing), 1, newTrait); + value.push(newTrait); + } + return; + } + // Check first if its not an annotated trait const annotatedTraitMatch = newTrait.match(/^([a-z][-a-z]+)-(\d*d?\d+)$/); if (!annotatedTraitMatch) { @@ -121,7 +148,7 @@ function addOrUpgradeTrait( * @param trait the trait being removed, either the full one or an unannotated variant (like "volley") */ function removeTrait( - traits: ItemTraits | ItemTraitsNoRarity, + traits: Pick, "value" | "config">, trait: string, ): void { const idx = traits.value.findIndex((t) => t === trait); diff --git a/src/module/item/melee/data.ts b/src/module/item/melee/data.ts index 6f510e26c6c..fec48e286a3 100644 --- a/src/module/item/melee/data.ts +++ b/src/module/item/melee/data.ts @@ -119,7 +119,9 @@ class MeleeSystemData extends ItemSystemModel interface MeleeSystemData extends ItemSystemModel, - Omit, "description"> {} + Omit, "description"> { + traits: NPCAttackTraits; +} type NPCAttackSystemSchema = Omit & { traits: fields.SchemaField<{ diff --git a/src/module/rules/rule-element/battle-form/rule-element.ts b/src/module/rules/rule-element/battle-form/rule-element.ts index 5fcdf3478ed..8dddf389c12 100644 --- a/src/module/rules/rule-element/battle-form/rule-element.ts +++ b/src/module/rules/rule-element/battle-form/rule-element.ts @@ -1,5 +1,5 @@ import type { ActorType, CharacterPF2e } from "@actor"; -import { CharacterStrike } from "@actor/character/data.ts"; +import { CharacterAttack } from "@actor/character/data.ts"; import { SENSE_TYPES } from "@actor/creature/values.ts"; import { ActorInitiative } from "@actor/initiative.ts"; import { DamageDicePF2e, Modifier, StatisticModifier } from "@actor/modifiers.ts"; @@ -334,13 +334,13 @@ class BattleFormRuleElement extends RuleElement { } actor.system.actions = actor - .prepareStrikes({ includeBasicUnarmed: this.ownUnarmed }) + .prepareAttacks({ includeBasicUnarmed: this.ownUnarmed }) .filter((a) => a.item.flags.pf2e.battleForm || (this.ownUnarmed && a.item.category === "unarmed")); - const strikeActions = actor.system.actions.flatMap((s): CharacterStrike[] => [s, ...s.altUsages]); + const strikeActions = actor.system.actions.flatMap((s): CharacterAttack[] => [s, ...s.altUsages]); for (const action of strikeActions) { const strike = strikes[action.slug ?? ""]; - if (!strike) continue; + if (!strike || action.type !== "strike") continue; const addend = action.modifiers .filter((m) => m.enabled && this.#filterModifier(m)) .reduce((sum, m) => sum + m.modifier, 0); diff --git a/src/module/rules/rule-element/strike.ts b/src/module/rules/rule-element/strike.ts index 31601d98dd1..4575f9d1993 100644 --- a/src/module/rules/rule-element/strike.ts +++ b/src/module/rules/rule-element/strike.ts @@ -193,7 +193,7 @@ class StrikeRuleElement extends RuleElement { ) .map((action) => { // Continue showing shields but disable strikes with them - if (action.item.shield) action.canStrike = false; + if (action.item.shield) action.canAttack = false; return action; }); } else if (this.replaceBasicUnarmed) { diff --git a/src/scripts/config/index.ts b/src/scripts/config/index.ts index 4811ef6d9f4..e02fca2fdcd 100644 --- a/src/scripts/config/index.ts +++ b/src/scripts/config/index.ts @@ -558,7 +558,7 @@ export const PF2ECONFIG = { identification: configFromLocalization(EN_JSON.PF2E.identification, "PF2E.identification"), /** Base weapons that should always be treated as thrown */ - thrownBaseWeapons: ["alchemical-bomb"] as const, + thrownBaseWeapons: ["alchemical-bomb", "grenade"] as const, preparationType: { prepared: "PF2E.PreparationTypePrepared", diff --git a/src/scripts/config/traits.ts b/src/scripts/config/traits.ts index 6f4665726ef..2ecc92ac898 100644 --- a/src/scripts/config/traits.ts +++ b/src/scripts/config/traits.ts @@ -413,6 +413,7 @@ const weaponTraits = { "attached-to-shield": "PF2E.TraitAttachedToShield", "attached-to-crossbow-or-firearm": "PF2E.TraitAttachedToCrossbowOrFirearm", auditory: "PF2E.TraitAuditory", + automatic: "PF2E.TraitAutomatic", backstabber: "PF2E.TraitBackstabber", backswing: "PF2E.TraitBackswing", bomb: "PF2E.TraitBomb", diff --git a/src/scripts/macros/hotbar.ts b/src/scripts/macros/hotbar.ts index 6198267397a..0e71a1f2ac0 100644 --- a/src/scripts/macros/hotbar.ts +++ b/src/scripts/macros/hotbar.ts @@ -74,9 +74,12 @@ export async function createActionMacro({ } else if (actionIndex !== undefined) { const action = actor.system.actions[actionIndex]; if (!action) return null; + const actionName = game.i18n.localize( + action.type === "strike" ? "PF2E.WeaponStrikeLabel" : action.attackRollType, + ); return { - name: `${game.i18n.localize("PF2E.WeaponStrikeLabel")}: ${action.label}`, - command: `game.pf2e.rollActionMacro({ actorUUID: "${actorUUID}", type: "strike", itemId: "${action.item.id}", slug: "${action.slug}" })`, + name: `${actionName}: ${action.label}`, + command: `game.pf2e.rollActionMacro({ actorUUID: "${actorUUID}", type: "${action.type}", itemId: "${action.item.id}", slug: "${action.slug}" })`, img: action.item.img, }; } @@ -140,14 +143,16 @@ export async function rollActionMacro({ new AttackPopout(actor, { type, elementTrait }).render(true); return; } - case "strike": { - if (closedExisting(`strike-${itemId}-${slug}`)) return; + case "strike": + case "area-fire": + case "auto-fire": { + if (closedExisting(`${type}-${itemId}-${slug}`)) return; if (!strike) { ui.notifications.error("PF2E.MacroActionNoActionError", { localize: true }); return; } - new AttackPopout(actor, { type, strikeItemId: itemId, strikeSlug: slug }).render(true); + new AttackPopout(actor, { type, itemId, slug }).render(true); return; } } @@ -243,5 +248,5 @@ interface RollActionMacroParams { itemId?: string; slug?: string; elementTrait?: EffectTrait; - type?: "blast" | "strike"; + type?: "blast" | "strike" | "area-fire" | "auto-fire"; } diff --git a/static/lang/en.json b/static/lang/en.json index f8914e37718..aacd7d66253 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -4242,6 +4242,7 @@ "TraitAttack": "Attack", "TraitAuditory": "Auditory", "TraitAura": "Aura", + "TraitAutomatic": "Automatic", "TraitAutomaton": "Automaton", "TraitAwakenedAnimal": "Awakened Animal", "TraitAzarketi": "Azarketi", @@ -5628,6 +5629,7 @@ "greatclub": "Greatclub", "greatpick": "Greatpick", "greatsword": "Greatsword", + "grenade": "Grenade", "griffon-cane": "Griffon Cane", "guisarme": "Guisarme", "gun-sword": "Gun Sword", diff --git a/static/templates/actors/character/attack-popout.hbs b/static/templates/actors/character/attack-popout.hbs index bbc96fa04e3..59b75dc2772 100644 --- a/static/templates/actors/character/attack-popout.hbs +++ b/static/templates/actors/character/attack-popout.hbs @@ -11,10 +11,9 @@ {{> "systems/pf2e/templates/actors/character/partials/elemental-blast.hbs" action=blast omitName=true}} {{/each}} - {{/if}} - {{#if (eq popoutType "strike")}} + {{else}}
    - {{> "systems/pf2e/templates/actors/character/partials/strike.hbs" action=strike index=strikeIndex omitName=true}} + {{> "systems/pf2e/templates/actors/character/partials/strike.hbs" action=attack index=index omitName=true}}
{{/if}} diff --git a/static/templates/actors/character/partials/strike.hbs b/static/templates/actors/character/partials/strike.hbs index e4f41821d2d..e890c86a2bf 100644 --- a/static/templates/actors/character/partials/strike.hbs +++ b/static/templates/actors/character/partials/strike.hbs @@ -19,20 +19,20 @@ {{#if action.ready}} - {{#if action.canStrike}}{{#> attackDamage action}}{{/attackDamage}}{{/if}} + {{#if action.canAttack}}{{#> attackDamage action}}{{/attackDamage}}{{/if}} {{else if (not action.handsAvailable)}} {{localize "PF2E.Actor.Character.HandsOccupied"}} {{/if}} {{#if (and action.altUsages action.ready)}} - {{#each action.altUsages as |usage|}} + {{#each action.altUsages as |usage index|}}
{{#if usage.item.isThrown}} {{else}} {{/if}} - {{#> attackDamage usage isAltUsage=true}}{{/attackDamage}} + {{#> attackDamage usage altUsageIndex=index}}{{/attackDamage}}
{{/each}} {{/if}} @@ -108,22 +108,27 @@
{{#each variants as |variant index|}} {{/each}} {{#if item.dealsDamage}} {{#if (or doubleBarrel versatileOptions)}} @@ -151,7 +156,7 @@ class="damage color {{option.value}}{{#if option.selected}} selected{{/if}}" value="{{option.value}}" {{disabled option.selected}} - {{#if ../isAltUsage}} data-alt-usage="{{#if ../item.isThrown}}thrown{{else}}melee{{/if}}"{{/if}} + {{#if altUsageIndex includeZero=true}}data-alt-usage="{{altUsageIndex}}"{{/if}} data-action="toggle-weapon-trait" data-trait="versatile" data-tooltip="{{localize option.label}}"