diff --git a/src/module/actor/character/document.ts b/src/module/actor/character/document.ts index f9a84bfa32d..8bdabf6fa27 100644 --- a/src/module/actor/character/document.ts +++ b/src/module/actor/character/document.ts @@ -45,9 +45,9 @@ 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 { EffectAreaShape, ItemType } from "@item/types.ts"; +import type { ItemType } from "@item/types.ts"; import type { WeaponSource } from "@item/weapon/data.ts"; -import { processTwoHandTrait } from "@item/weapon/helpers.ts"; +import { computeWeaponArea, processTwoHandTrait } from "@item/weapon/helpers.ts"; import { PROFICIENCY_RANKS, ZeroToFour, ZeroToTwo } from "@module/data.ts"; import { extractDegreeOfSuccessAdjustments, @@ -1160,31 +1160,7 @@ class CharacterPF2e { - // 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 area = computeWeaponArea(weapon, action, actor.getReach({ weapon })); const actionLabel = `PF2E.Actions.${sluggify(action, { camel: "bactrian" })}.Title`; const weaponSlug = weapon.slug ?? sluggify(weapon.name); diff --git a/src/module/actor/npc/sheet.ts b/src/module/actor/npc/sheet.ts index 6644acecc75..042df8e7571 100644 --- a/src/module/actor/npc/sheet.ts +++ b/src/module/actor/npc/sheet.ts @@ -441,8 +441,51 @@ class NPCSheetPF2e extends AbstractNPCSheet { } } + // Determine which attacks to generate based on weapon type + const isGrenade = item.baseType === "grenade"; + const hasAreaTrait = !!item.system.traits.config?.area; + const isAutomatic = item.system.traits.value.includes("automatic"); + + let mode: "strike" | "area" | "both" | undefined; + if (isGrenade) { + // Grenades always generate area-fire only, no prompt needed + mode = "area"; + } else if (hasAreaTrait || isAutomatic) { + const areaKey = isAutomatic ? "AutoFire" : "AreaFire"; + const result = await foundry.applications.api.DialogV2.wait({ + window: { + title: "PF2E.Actor.NPC.GenerateAttack.Mode.Title", + icon: "fa-solid hammer-crash", + }, + content: "", + buttons: [ + { + action: "strike", + label: game.i18n.localize("PF2E.Actor.NPC.GenerateAttack.Mode.Strike"), + icon: "fa-solid fa-sword", + default: true, + callback: () => "strike" as const, + }, + { + action: "area", + label: game.i18n.localize(`PF2E.Actor.NPC.GenerateAttack.Mode.${areaKey}`), + icon: "fa-solid fa-burst", + callback: () => "area" as const, + }, + { + action: "both", + label: game.i18n.localize("PF2E.Actor.NPC.GenerateAttack.Mode.Both"), + icon: "fa-solid fa-list", + callback: () => "both" as const, + }, + ], + }); + if (!result) return; // Dialog closed without selection + mode = result as "strike" | "area" | "both"; + } + // Create actions, and either relink to existing actions or create them - const attacks = item.toNPCAttacks().map((a) => a.toObject()); + const attacks = item.toNPCAttacks({ mode }).map((a) => a.toObject()); const missingLinks = actor.itemTypes.melee.filter((m) => !m.linkedWeapon); const getComparisonSubset = (item: MeleeSource | MeleePF2e) => ({ name: item.name, diff --git a/src/module/item/weapon/document.ts b/src/module/item/weapon/document.ts index 231572f83ff..1673c06c0b9 100644 --- a/src/module/item/weapon/document.ts +++ b/src/module/item/weapon/document.ts @@ -20,7 +20,7 @@ import { getPropertyRuneSlots, } from "@item/physical/index.ts"; import { MAGIC_TRADITIONS } from "@item/spell/values.ts"; -import type { RangeData } from "@item/types.ts"; +import type { EffectAreaShape, RangeData } from "@item/types.ts"; import type { StrikeRuleElement } from "@module/rules/rule-element/strike.ts"; import { WEAPON_UPGRADES } from "@scripts/config/usage.ts"; import { DamageCategorization } from "@system/damage/helpers.ts"; @@ -28,7 +28,7 @@ import { EnrichmentOptionsPF2e } from "@system/text-editor.ts"; import { ErrorPF2e, objectHasKey, setHasElement, sluggify, tupleHasValue } from "@util"; import * as R from "remeda"; import type { WeaponDamage, WeaponFlags, WeaponSource, WeaponSystemData } from "./data.ts"; -import { processTwoHandTrait } from "./helpers.ts"; +import { computeWeaponArea, processTwoHandTrait } from "./helpers.ts"; import { WeaponTraitToggles } from "./trait-toggles.ts"; import type { BaseWeaponType, @@ -666,7 +666,10 @@ class WeaponPF2e extends Ph } /** Generate a melee item from this weapon for use by NPCs */ - toNPCAttacks(this: WeaponPF2e>, { keepId = false } = {}): MeleePF2e>[] { + toNPCAttacks( + this: WeaponPF2e>, + { keepId = false, mode }: { keepId?: boolean; mode?: "strike" | "area" | "both" } = {}, + ): MeleePF2e>[] { const actor = this.actor; if (!actor.isOfType("npc")) throw ErrorPF2e("Melee items can only be generated for NPCs"); @@ -791,12 +794,24 @@ class WeaponPF2e extends Ph const newTraits = toAttackTraits(this.system.traits.value); const isThrown = newTraits.some((t) => t.startsWith("thrown-")); const rangeData = { increment: this._source.system.range, max: this._source.system.maxRange }; - const source: PreCreate = { - _id: keepId ? this.id : null, + + // Detect weapon capabilities for area/auto-fire attacks + const isGrenade = this.baseType === "grenade"; + const hasAreaTrait = !!this.system.traits.config?.area; + const isAutomatic = this.system.traits.value.includes("automatic"); + + const buildSource = ( + action: "strike" | "area-fire" | "auto-fire", + area: { type: EffectAreaShape; value: number } | null, + useKeepId: boolean, + ): PreCreate => ({ + _id: useKeepId && keepId ? this.id : null, name: this._source.name, type: "melee", system: { slug: this.slug ?? sluggify(this._source.name), + action, + area: area ?? undefined, bonus: { // Unless there is a fixed attack modifier, give an attack bonus approximating a high-threat NPC value: this.flags[SYSTEM_ID].fixedAttack || Math.round(1.5 * this.actor.level + 7), @@ -815,16 +830,48 @@ class WeaponPF2e extends Ph range: !isThrown && (rangeData.increment || rangeData.max) ? rangeData : null, }, flags: { [SYSTEM_ID]: { linkedWeapon: this.id } }, + }); + + const createAttack = (source: PreCreate): MeleePF2e> => { + const attack = new ItemProxyPF2e(source, { parent: this.actor }) as MeleePF2e>; + // Melee items retrieve these during `prepareSiblingData`, but if the attack is from a Strike rule element, + // there will be no inventory weapon from which to pull the data. + attack.category = this.category; + attack.group = this.group; + attack.baseType = this.baseType; + return attack; }; - const attack = new ItemProxyPF2e(source, { parent: this.actor }) as MeleePF2e>; - // Melee items retrieve these during `prepareSiblingData`, but if the attack is from a Strike rule element, - // there will be no inventory weapon from which to pull the data. - attack.category = this.category; - attack.group = this.group; - attack.baseType = this.baseType; + const attacks: MeleePF2e>[] = []; + + if (isGrenade) { + // Grenades: always area-fire only (no strike possible) + const area = computeWeaponArea(this, "area-fire"); + attacks.push(createAttack(buildSource("area-fire", area, true))); + } else if (hasAreaTrait) { + // Area-trait weapons: default to area-fire only, respect mode if specified + if (mode === "strike" || mode === "both") { + attacks.push(createAttack(buildSource("strike", null, mode === "strike"))); + } + if (mode !== "strike") { + const area = computeWeaponArea(this, "area-fire"); + attacks.push(createAttack(buildSource("area-fire", area, true))); + } + } else if (isAutomatic) { + // Automatic weapons: default to strike + auto-fire, respect mode if specified + if (mode !== "area") { + attacks.push(createAttack(buildSource("strike", null, true))); + } + if (mode !== "strike") { + const area = computeWeaponArea(this, "auto-fire"); + attacks.push(createAttack(buildSource("auto-fire", area, mode === "area"))); + } + } else { + // Normal weapons: always generate a strike + attacks.push(createAttack(buildSource("strike", null, true))); + } - return [attack, ...this.getAltUsages({ recurse: false }).flatMap((u) => u.toNPCAttacks())]; + return [...attacks, ...this.getAltUsages({ recurse: false }).flatMap((u) => u.toNPCAttacks())]; } /** Consume a unit of ammunition used by this weapon */ diff --git a/src/module/item/weapon/helpers.ts b/src/module/item/weapon/helpers.ts index 28607894746..8732e998431 100644 --- a/src/module/item/weapon/helpers.ts +++ b/src/module/item/weapon/helpers.ts @@ -1,8 +1,9 @@ import { ActorPF2e } from "@actor"; import { ConsumablePF2e } from "@item/consumable/document.ts"; +import type { EffectAreaShape } from "@item/types.ts"; import { nextDamageDieSize } from "@system/damage/helpers.ts"; import { DAMAGE_DICE_FACES } from "@system/damage/values.ts"; -import { tupleHasValue } from "@util"; +import { ErrorPF2e, tupleHasValue } from "@util"; import * as R from "remeda"; import { WeaponPF2e } from "./document.ts"; @@ -40,4 +41,33 @@ function getLoadedAmmo, A extends ActorPF2e | null>( return R.sortBy(ammo, (i) => i.sort); } -export { getLoadedAmmo, processTwoHandTrait, upgradeWeaponTrait }; +/** Compute the area shape and size for an area-fire or auto-fire attack from a weapon */ +function computeWeaponArea( + weapon: WeaponPF2e, + action: "area-fire" | "auto-fire", + fallbackRange = 5, +): { type: EffectAreaShape; value: number } { + 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 || fallbackRange; + + if (action === "auto-fire") { + return { + type: "cone", + value: Math.max(5, Math.floor(itemRange / 2) - (Math.floor(itemRange / 2) % 5)), + }; + } + + // Area-fire from area trait + 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) }; +} + +export { computeWeaponArea, getLoadedAmmo, processTwoHandTrait, upgradeWeaponTrait }; diff --git a/static/lang/en.json b/static/lang/en.json index 7a222e4a936..b3e1fb83c10 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -650,6 +650,13 @@ "Title": "Regenerate attack" }, "Label": "Generate Attack", + "Mode": { + "AreaFire": "Area Fire", + "AutoFire": "Auto-Fire", + "Both": "Both", + "Strike": "Strike", + "Title": "Generate Attack Type" + }, "Notification": { "New": "Generated NPC attack: {attack}", "Relinked": "Relinked NPC Attack: {attack}"