Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 3 additions & 27 deletions src/module/actor/character/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1160,31 +1160,7 @@ class CharacterPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e
: [],
});

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:(?<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);
Expand Down
45 changes: 44 additions & 1 deletion src/module/actor/npc/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 59 additions & 12 deletions src/module/item/weapon/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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";
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,
Expand Down Expand Up @@ -666,7 +666,10 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph
}

/** Generate a melee item from this weapon for use by NPCs */
toNPCAttacks(this: WeaponPF2e<NonNullable<TParent>>, { keepId = false } = {}): MeleePF2e<NonNullable<TParent>>[] {
toNPCAttacks(
this: WeaponPF2e<NonNullable<TParent>>,
{ keepId = false, mode }: { keepId?: boolean; mode?: "strike" | "area" | "both" } = {},
): MeleePF2e<NonNullable<TParent>>[] {
const actor = this.actor;
if (!actor.isOfType("npc")) throw ErrorPF2e("Melee items can only be generated for NPCs");

Expand Down Expand Up @@ -791,12 +794,24 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> 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<MeleeSource> = {
_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<MeleeSource> => ({
_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),
Expand All @@ -815,16 +830,48 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph
range: !isThrown && (rangeData.increment || rangeData.max) ? rangeData : null,
},
flags: { [SYSTEM_ID]: { linkedWeapon: this.id } },
});

const createAttack = (source: PreCreate<MeleeSource>): MeleePF2e<NonNullable<TParent>> => {
const attack = new ItemProxyPF2e(source, { parent: this.actor }) as MeleePF2e<NonNullable<TParent>>;
// 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<NonNullable<TParent>>;
// 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<NonNullable<TParent>>[] = [];

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 */
Expand Down
34 changes: 32 additions & 2 deletions src/module/item/weapon/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -40,4 +41,33 @@ function getLoadedAmmo<T extends WeaponPF2e<A>, 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:(?<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 };
7 changes: 7 additions & 0 deletions static/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down