Add PF2e weak/elite creature adjustments with stat block toggle
Weak/Normal/Elite toggle in PF2e stat block header applies standard adjustments (level, AC, HP, saves, Perception, attacks, damage) to individual combatants. Adjusted stats are highlighted blue (elite) or red (weak). Persisted via creatureAdjustment field on Combatant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
270
packages/domain/src/__tests__/pf2e-adjustments.test.ts
Normal file
270
packages/domain/src/__tests__/pf2e-adjustments.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Pf2eCreature } from "../creature-types.js";
|
||||
import { creatureId } from "../creature-types.js";
|
||||
import {
|
||||
acDelta,
|
||||
adjustedLevel,
|
||||
applyPf2eAdjustment,
|
||||
hpDelta,
|
||||
modDelta,
|
||||
} from "../pf2e-adjustments.js";
|
||||
|
||||
describe("adjustedLevel", () => {
|
||||
it("elite on level 5 → 6", () => {
|
||||
expect(adjustedLevel(5, "elite")).toBe(6);
|
||||
});
|
||||
|
||||
it("elite on level 0 → 2 (double bump)", () => {
|
||||
expect(adjustedLevel(0, "elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("elite on level −1 → 1 (double bump)", () => {
|
||||
expect(adjustedLevel(-1, "elite")).toBe(1);
|
||||
});
|
||||
|
||||
it("weak on level 5 → 4", () => {
|
||||
expect(adjustedLevel(5, "weak")).toBe(4);
|
||||
});
|
||||
|
||||
it("weak on level 1 → −1 (double drop)", () => {
|
||||
expect(adjustedLevel(1, "weak")).toBe(-1);
|
||||
});
|
||||
|
||||
it("weak on level 0 → −1", () => {
|
||||
expect(adjustedLevel(0, "weak")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hpDelta", () => {
|
||||
it("level 1 elite → +10", () => {
|
||||
expect(hpDelta(1, "elite")).toBe(10);
|
||||
});
|
||||
|
||||
it("level 1 weak → −10", () => {
|
||||
expect(hpDelta(1, "weak")).toBe(-10);
|
||||
});
|
||||
|
||||
it("level 3 elite → +15", () => {
|
||||
expect(hpDelta(3, "elite")).toBe(15);
|
||||
});
|
||||
|
||||
it("level 3 weak → −15", () => {
|
||||
expect(hpDelta(3, "weak")).toBe(-15);
|
||||
});
|
||||
|
||||
it("level 10 elite → +20", () => {
|
||||
expect(hpDelta(10, "elite")).toBe(20);
|
||||
});
|
||||
|
||||
it("level 10 weak → −20", () => {
|
||||
expect(hpDelta(10, "weak")).toBe(-20);
|
||||
});
|
||||
|
||||
it("level 25 elite → +30", () => {
|
||||
expect(hpDelta(25, "elite")).toBe(30);
|
||||
});
|
||||
|
||||
it("level 25 weak → −30", () => {
|
||||
expect(hpDelta(25, "weak")).toBe(-30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acDelta", () => {
|
||||
it("elite → +2", () => {
|
||||
expect(acDelta("elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("weak → −2", () => {
|
||||
expect(acDelta("weak")).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modDelta", () => {
|
||||
it("elite → +2", () => {
|
||||
expect(modDelta("elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("weak → −2", () => {
|
||||
expect(modDelta("weak")).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
function baseCreature(overrides?: Partial<Pf2eCreature>): Pf2eCreature {
|
||||
return {
|
||||
system: "pf2e",
|
||||
id: creatureId("test-creature"),
|
||||
name: "Test Creature",
|
||||
source: "test-source",
|
||||
sourceDisplayName: "Test Source",
|
||||
level: 5,
|
||||
traits: ["humanoid"],
|
||||
perception: 12,
|
||||
skills: "Athletics +14",
|
||||
abilityMods: {
|
||||
str: 4,
|
||||
dex: 2,
|
||||
con: 3,
|
||||
int: 0,
|
||||
wis: 1,
|
||||
cha: -1,
|
||||
},
|
||||
ac: 22,
|
||||
saveFort: 14,
|
||||
saveRef: 11,
|
||||
saveWill: 9,
|
||||
hp: 75,
|
||||
speed: "25 feet",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyPf2eAdjustment", () => {
|
||||
it("adjusts all numeric stats for elite", () => {
|
||||
const creature = baseCreature();
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
|
||||
expect(result.level).toBe(6);
|
||||
expect(result.ac).toBe(24);
|
||||
expect(result.hp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||
expect(result.perception).toBe(14);
|
||||
expect(result.saveFort).toBe(16);
|
||||
expect(result.saveRef).toBe(13);
|
||||
expect(result.saveWill).toBe(11);
|
||||
});
|
||||
|
||||
it("adjusts all numeric stats for weak", () => {
|
||||
const creature = baseCreature();
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
|
||||
expect(result.level).toBe(4);
|
||||
expect(result.ac).toBe(20);
|
||||
expect(result.hp).toBe(55); // 75 - 20 (level 5 bracket)
|
||||
expect(result.perception).toBe(10);
|
||||
expect(result.saveFort).toBe(12);
|
||||
expect(result.saveRef).toBe(9);
|
||||
expect(result.saveWill).toBe(7);
|
||||
});
|
||||
|
||||
it("adjusts attack bonuses and damage", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: "+15 [+10/+5] (agile), 2d12+7 piercing plus Grab",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+17 [+12/+7] (agile), 2d12+9 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts attack damage for weak", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: "+15 (agile), 2d12+7 piercing plus Grab",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+13 (agile), 2d12+5 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles damage bonus becoming zero", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [{ type: "text", value: "+10, 1d4+2 slashing" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+8, 1d4 slashing",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles damage bonus becoming negative", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [{ type: "text", value: "+10, 1d4+1 slashing" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+8, 1d4-1 slashing",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not modify non-attack abilities", () => {
|
||||
const creature = baseCreature({
|
||||
abilitiesTop: [
|
||||
{
|
||||
name: "Darkvision",
|
||||
segments: [{ type: "text", value: "Can see in darkness." }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
expect(result.abilitiesTop).toEqual(creature.abilitiesTop);
|
||||
});
|
||||
|
||||
it("preserves non-text segments in attacks", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "list",
|
||||
items: [{ text: "some list item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
expect(result.attacks?.[0].segments[0]).toEqual({
|
||||
type: "list",
|
||||
items: [{ text: "some list item" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export interface CombatantInit {
|
||||
readonly ac?: number;
|
||||
readonly initiative?: number;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly creatureAdjustment?: "weak" | "elite";
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
@@ -67,6 +68,9 @@ function buildCombatant(
|
||||
...(init?.ac !== undefined && { ac: init.ac }),
|
||||
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||
...(init?.creatureAdjustment !== undefined && {
|
||||
creatureAdjustment: init.creatureAdjustment,
|
||||
}),
|
||||
...(init?.color !== undefined && { color: init.color }),
|
||||
...(init?.icon !== undefined && { icon: init.icon }),
|
||||
...(init?.playerCharacterId !== undefined && {
|
||||
|
||||
@@ -132,6 +132,12 @@ export interface ConcentrationEnded {
|
||||
readonly combatantId: CombatantId;
|
||||
}
|
||||
|
||||
export interface CreatureAdjustmentSet {
|
||||
readonly type: "CreatureAdjustmentSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly adjustment: "weak" | "elite" | undefined;
|
||||
}
|
||||
|
||||
export interface EncounterCleared {
|
||||
readonly type: "EncounterCleared";
|
||||
readonly combatantCount: number;
|
||||
@@ -175,6 +181,7 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| CreatureAdjustmentSet
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
|
||||
@@ -75,6 +75,7 @@ export type {
|
||||
ConcentrationStarted,
|
||||
ConditionAdded,
|
||||
ConditionRemoved,
|
||||
CreatureAdjustmentSet,
|
||||
CrSet,
|
||||
CurrentHpAdjusted,
|
||||
DomainEvent,
|
||||
@@ -99,6 +100,14 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
acDelta,
|
||||
adjustedLevel,
|
||||
applyPf2eAdjustment,
|
||||
type CreatureAdjustment,
|
||||
hpDelta,
|
||||
modDelta,
|
||||
} from "./pf2e-adjustments.js";
|
||||
export {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
|
||||
110
packages/domain/src/pf2e-adjustments.ts
Normal file
110
packages/domain/src/pf2e-adjustments.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Pf2eCreature,
|
||||
TraitBlock,
|
||||
TraitSegment,
|
||||
} from "./creature-types.js";
|
||||
|
||||
export type CreatureAdjustment = "weak" | "elite";
|
||||
|
||||
/** HP bracket delta by creature level (standard PF2e table). */
|
||||
function hpBracketDelta(level: number): number {
|
||||
if (level <= 1) return 10;
|
||||
if (level <= 4) return 15;
|
||||
if (level <= 19) return 20;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/** Level shift: elite +1 (or +2 if level ≤ 0), weak −1 (or −2 if level is 1). */
|
||||
export function adjustedLevel(
|
||||
baseLevel: number,
|
||||
adjustment: CreatureAdjustment,
|
||||
): number {
|
||||
if (adjustment === "elite") {
|
||||
return baseLevel <= 0 ? baseLevel + 2 : baseLevel + 1;
|
||||
}
|
||||
return baseLevel === 1 ? baseLevel - 2 : baseLevel - 1;
|
||||
}
|
||||
|
||||
/** Signed HP delta for a given base level and adjustment. */
|
||||
export function hpDelta(
|
||||
baseLevel: number,
|
||||
adjustment: CreatureAdjustment,
|
||||
): number {
|
||||
const delta = hpBracketDelta(baseLevel);
|
||||
return adjustment === "elite" ? delta : -delta;
|
||||
}
|
||||
|
||||
/** AC delta: +2 for elite, −2 for weak. */
|
||||
export function acDelta(adjustment: CreatureAdjustment): number {
|
||||
return adjustment === "elite" ? 2 : -2;
|
||||
}
|
||||
|
||||
/** Generic ±2 modifier delta. Used for saves, Perception, attacks, damage. */
|
||||
export function modDelta(adjustment: CreatureAdjustment): number {
|
||||
return adjustment === "elite" ? 2 : -2;
|
||||
}
|
||||
|
||||
const ATTACK_BONUS_RE = /^([+-])(\d+)/;
|
||||
const MAP_RE = /\[([+-]\d+)\/([+-]\d+)\]/g;
|
||||
const DAMAGE_BONUS_RE = /(\d+d\d+)([+-])(\d+)/g;
|
||||
|
||||
/**
|
||||
* Adjust attack bonus in a formatted attack string.
|
||||
* "+15 (agile), 2d12+7 piercing plus Grab" → "+17 (agile), 2d12+9 piercing plus Grab"
|
||||
*/
|
||||
function adjustAttackText(text: string, delta: number): string {
|
||||
// Adjust leading attack bonus: "+15" → "+17"
|
||||
let result = text.replace(ATTACK_BONUS_RE, (_, sign, num) => {
|
||||
const adjusted = (sign === "+" ? 1 : -1) * Number(num) + delta;
|
||||
return adjusted >= 0 ? `+${adjusted}` : `${adjusted}`;
|
||||
});
|
||||
// Adjust MAP values in brackets: "[+10/+5]" → "[+12/+7]"
|
||||
result = result.replace(MAP_RE, (_, m1, m2) => {
|
||||
const a1 = Number(m1) + delta;
|
||||
const a2 = Number(m2) + delta;
|
||||
const f = (n: number) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
return `[${f(a1)}/${f(a2)}]`;
|
||||
});
|
||||
// Adjust damage bonus in "NdN+N type" patterns
|
||||
result = result.replace(DAMAGE_BONUS_RE, (_, dice, sign, num) => {
|
||||
const current = (sign === "+" ? 1 : -1) * Number(num);
|
||||
const adjusted = current + delta;
|
||||
if (adjusted === 0) return dice as string;
|
||||
return adjusted > 0 ? `${dice}+${adjusted}` : `${dice}${adjusted}`;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function adjustTraitBlock(block: TraitBlock, delta: number): TraitBlock {
|
||||
return {
|
||||
...block,
|
||||
segments: block.segments.map(
|
||||
(seg): TraitSegment =>
|
||||
seg.type === "text"
|
||||
? { type: "text", value: adjustAttackText(seg.value, delta) }
|
||||
: seg,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a weak or elite adjustment to a full PF2e creature.
|
||||
* Returns a new Pf2eCreature with all numeric stats adjusted.
|
||||
*/
|
||||
export function applyPf2eAdjustment(
|
||||
creature: Pf2eCreature,
|
||||
adjustment: CreatureAdjustment,
|
||||
): Pf2eCreature {
|
||||
const d = modDelta(adjustment);
|
||||
return {
|
||||
...creature,
|
||||
level: adjustedLevel(creature.level, adjustment),
|
||||
ac: creature.ac + d,
|
||||
hp: creature.hp + hpDelta(creature.level, adjustment),
|
||||
perception: creature.perception + d,
|
||||
saveFort: creature.saveFort + d,
|
||||
saveRef: creature.saveRef + d,
|
||||
saveWill: creature.saveWill + d,
|
||||
attacks: creature.attacks?.map((a) => adjustTraitBlock(a, d)),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,7 @@ function validateCr(value: unknown): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const VALID_ADJUSTMENTS = new Set(["weak", "elite"]);
|
||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||
|
||||
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
||||
@@ -110,6 +111,10 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
creatureId: validateNonEmptyString(entry.creatureId)
|
||||
? creatureId(entry.creatureId as string)
|
||||
: undefined,
|
||||
creatureAdjustment: validateSetMember(
|
||||
entry.creatureAdjustment,
|
||||
VALID_ADJUSTMENTS,
|
||||
) as "weak" | "elite" | undefined,
|
||||
cr: validateCr(entry.cr),
|
||||
side: validateSide(entry.side),
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Combatant {
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly creatureAdjustment?: "weak" | "elite";
|
||||
readonly cr?: string;
|
||||
readonly side?: "party" | "enemy";
|
||||
readonly color?: string;
|
||||
|
||||
Reference in New Issue
Block a user