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:
@@ -16,12 +16,18 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
const CLOSE_REGEX = /close/i;
|
||||
const COLLAPSE_REGEX = /collapse/i;
|
||||
@@ -82,6 +88,7 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
||||
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||
selectedCombatantId: null,
|
||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||
isWideDesktop: false,
|
||||
@@ -110,6 +117,11 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
||||
refreshCache: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
|
||||
mockUseEncounterContext.mockReturnValue({
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
setCreatureAdjustment: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useEncounterContext>);
|
||||
|
||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||
}
|
||||
|
||||
|
||||
@@ -455,14 +455,20 @@ export function CombatantRow({
|
||||
decrementCondition,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
useSidePanelContext();
|
||||
const {
|
||||
selectedCreatureId,
|
||||
selectedCombatantId,
|
||||
showCreature,
|
||||
toggleCollapse,
|
||||
} = useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const isPf2e = edition === "pf2e";
|
||||
|
||||
// Derive what was previously conditional props
|
||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||
const isStatBlockOpen =
|
||||
combatant.creatureId === selectedCreatureId &&
|
||||
combatant.id === selectedCombatantId;
|
||||
const { creatureId } = combatant;
|
||||
const hasStatBlock = !!creatureId;
|
||||
const onToggleStatBlock = hasStatBlock
|
||||
@@ -470,7 +476,7 @@ export function CombatantRow({
|
||||
if (isStatBlockOpen) {
|
||||
toggleCollapse();
|
||||
} else {
|
||||
showCreature(creatureId);
|
||||
showCreature(creatureId, combatant.id);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type {
|
||||
CombatantId,
|
||||
EquipmentItem,
|
||||
Pf2eCreature,
|
||||
SpellReference,
|
||||
} from "@initiative/domain";
|
||||
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||
import {
|
||||
@@ -15,6 +18,14 @@ import {
|
||||
|
||||
interface Pf2eStatBlockProps {
|
||||
creature: Pf2eCreature;
|
||||
adjustment?: "weak" | "elite";
|
||||
combatantId?: CombatantId;
|
||||
baseCreature?: Pf2eCreature;
|
||||
onSetAdjustment?: (
|
||||
id: CombatantId,
|
||||
adj: "weak" | "elite" | undefined,
|
||||
base: Pf2eCreature,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ALIGNMENTS = new Set([
|
||||
@@ -41,6 +52,13 @@ function formatMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
/** Returns the text color class for stats affected by weak/elite adjustment. */
|
||||
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
||||
if (adjustment === "elite") return "text-blue-400";
|
||||
if (adjustment === "weak") return "text-red-400";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface SpellLinkProps {
|
||||
readonly spell: SpellReference;
|
||||
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||
@@ -136,7 +154,13 @@ function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
export function Pf2eStatBlock({
|
||||
creature,
|
||||
adjustment,
|
||||
combatantId,
|
||||
baseCreature,
|
||||
onSetAdjustment,
|
||||
}: Readonly<Pf2eStatBlockProps>) {
|
||||
const [openSpell, setOpenSpell] = useState<{
|
||||
spell: SpellReference;
|
||||
rect: DOMRect;
|
||||
@@ -157,6 +181,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
|
||||
|
||||
const rk = recallKnowledge(creature.level, creature.traits);
|
||||
const adjColor = adjustmentColor(adjustment);
|
||||
|
||||
const abilityEntries = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
@@ -172,13 +197,46 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-bold text-stat-heading text-xl">
|
||||
<h2 className="flex items-center gap-1.5 font-bold text-stat-heading text-xl">
|
||||
{adjustment === "elite" && (
|
||||
<ChevronUp className="h-5 w-5 shrink-0 text-blue-400" />
|
||||
)}
|
||||
{adjustment === "weak" && (
|
||||
<ChevronDown className="h-5 w-5 shrink-0 text-red-400" />
|
||||
)}
|
||||
{creature.name}
|
||||
</h2>
|
||||
<span className="shrink-0 font-semibold text-sm">
|
||||
<span className={cn("shrink-0 font-semibold text-sm", adjColor)}>
|
||||
Level {creature.level}
|
||||
</span>
|
||||
</div>
|
||||
{combatantId != null &&
|
||||
onSetAdjustment != null &&
|
||||
baseCreature != null && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
{(["weak", "normal", "elite"] as const).map((opt) => {
|
||||
const value = opt === "normal" ? undefined : opt;
|
||||
const isActive = adjustment === value;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 font-medium text-xs capitalize",
|
||||
isActive
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-accent/30",
|
||||
)}
|
||||
onClick={() =>
|
||||
onSetAdjustment(combatantId, value, baseCreature)
|
||||
}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{displayTraits(creature.traits).map((trait) => (
|
||||
<span
|
||||
@@ -204,7 +262,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
|
||||
{/* Perception, Languages, Skills */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">Perception</span>{" "}
|
||||
{formatInitiativeModifier(creature.perception)}
|
||||
{creature.senses || creature.perceptionDetails
|
||||
@@ -236,7 +294,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
|
||||
{/* Defenses */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">AC</span> {creature.ac}
|
||||
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||
<span className="font-semibold">Fort</span>{" "}
|
||||
@@ -247,7 +305,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{formatMod(creature.saveWill)}
|
||||
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">HP</span> {creature.hp}
|
||||
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CombatantId,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Pf2eCreature,
|
||||
} from "@initiative/domain";
|
||||
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
@@ -216,6 +225,7 @@ function MobileDrawer({
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { encounter, setCreatureAdjustment } = useEncounterContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
@@ -223,10 +233,18 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
: sidePanel.pinnedCreatureId;
|
||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||
|
||||
const combatantId =
|
||||
panelRole === "browse" ? sidePanel.selectedCombatantId : null;
|
||||
const combatant = combatantId
|
||||
? (encounter.combatants.find((c) => c.id === combatantId) ?? null)
|
||||
: null;
|
||||
|
||||
const isBrowse = panelRole === "browse";
|
||||
return {
|
||||
creatureId,
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
@@ -238,6 +256,33 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
};
|
||||
}
|
||||
|
||||
function renderStatBlock(
|
||||
creature: AnyCreature,
|
||||
combatant: Combatant | null,
|
||||
setCreatureAdjustment: (
|
||||
id: CombatantId,
|
||||
adj: "weak" | "elite" | undefined,
|
||||
base: Pf2eCreature,
|
||||
) => void,
|
||||
) {
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
const baseCreature = creature;
|
||||
const adjusted = combatant?.creatureAdjustment
|
||||
? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment)
|
||||
: baseCreature;
|
||||
return (
|
||||
<Pf2eStatBlock
|
||||
creature={adjusted}
|
||||
adjustment={combatant?.creatureAdjustment}
|
||||
combatantId={combatant?.id}
|
||||
baseCreature={baseCreature}
|
||||
onSetAdjustment={setCreatureAdjustment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <DndStatBlock creature={creature as Creature} />;
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
panelRole,
|
||||
side,
|
||||
@@ -245,6 +290,8 @@ export function StatBlockPanel({
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
@@ -316,10 +363,7 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
return <Pf2eStatBlock creature={creature} />;
|
||||
}
|
||||
return <DndStatBlock creature={creature as Creature} />;
|
||||
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
|
||||
238
apps/web/src/hooks/__tests__/use-encounter-adjustment.test.ts
Normal file
238
apps/web/src/hooks/__tests__/use-encounter-adjustment.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
creatureId,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
const BASE_CREATURE: Pf2eCreature = {
|
||||
system: "pf2e",
|
||||
id: creatureId("b1:goblin-warrior"),
|
||||
name: "Goblin Warrior",
|
||||
source: "B1",
|
||||
sourceDisplayName: "Bestiary",
|
||||
level: 5,
|
||||
traits: ["humanoid"],
|
||||
perception: 12,
|
||||
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",
|
||||
};
|
||||
|
||||
function stateWithCreature(
|
||||
name: string,
|
||||
hp: number,
|
||||
ac: number,
|
||||
adj?: "weak" | "elite",
|
||||
): EncounterState {
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name,
|
||||
maxHp: hp,
|
||||
currentHp: hp,
|
||||
ac,
|
||||
creatureId: creatureId("b1:goblin-warrior"),
|
||||
...(adj !== undefined && { creatureAdjustment: adj }),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
events: [],
|
||||
nextId: 1,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("set-creature-adjustment", () => {
|
||||
it("Normal → Elite: HP increases, AC +2, name prefixed, adjustment stored", () => {
|
||||
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||
expect(c.currentHp).toBe(95);
|
||||
expect(c.ac).toBe(24);
|
||||
expect(c.name).toBe("Elite Goblin Warrior");
|
||||
expect(c.creatureAdjustment).toBe("elite");
|
||||
});
|
||||
|
||||
it("Normal → Weak: HP decreases, AC −2, name prefixed", () => {
|
||||
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "weak",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(55); // 75 - 20
|
||||
expect(c.currentHp).toBe(55);
|
||||
expect(c.ac).toBe(20);
|
||||
expect(c.name).toBe("Weak Goblin Warrior");
|
||||
expect(c.creatureAdjustment).toBe("weak");
|
||||
});
|
||||
|
||||
it("Elite → Normal: HP/AC/name revert", () => {
|
||||
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: undefined,
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(75);
|
||||
expect(c.currentHp).toBe(75);
|
||||
expect(c.ac).toBe(22);
|
||||
expect(c.name).toBe("Goblin Warrior");
|
||||
expect(c.creatureAdjustment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Elite → Weak: full swing applied in one step", () => {
|
||||
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "weak",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(55); // 95 - 40 (revert +20, apply -20)
|
||||
expect(c.currentHp).toBe(55);
|
||||
expect(c.ac).toBe(20); // 24 - 4
|
||||
expect(c.name).toBe("Weak Goblin Warrior");
|
||||
expect(c.creatureAdjustment).toBe("weak");
|
||||
});
|
||||
|
||||
it("toggle with damage taken: currentHp shifted by delta, clamped to 0", () => {
|
||||
const state: EncounterState = {
|
||||
...stateWithCreature("Goblin Warrior", 75, 22),
|
||||
};
|
||||
// Simulate damage: currentHp = 10
|
||||
const damaged: EncounterState = {
|
||||
...state,
|
||||
encounter: {
|
||||
...state.encounter,
|
||||
combatants: [{ ...state.encounter.combatants[0], currentHp: 10 }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = encounterReducer(damaged, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "weak",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(55);
|
||||
// currentHp = 10 - 20 = -10, clamped to 0
|
||||
expect(c.currentHp).toBe(0);
|
||||
});
|
||||
|
||||
it("toggle with temp HP: temp HP unchanged", () => {
|
||||
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||
const withTemp: EncounterState = {
|
||||
...state,
|
||||
encounter: {
|
||||
...state.encounter,
|
||||
combatants: [{ ...state.encounter.combatants[0], tempHp: 10 }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = encounterReducer(withTemp, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].tempHp).toBe(10);
|
||||
});
|
||||
|
||||
it("name with auto-number suffix: 'Goblin 2' → 'Elite Goblin 2'", () => {
|
||||
const state = stateWithCreature("Goblin 2", 75, 22);
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].name).toBe("Elite Goblin 2");
|
||||
});
|
||||
|
||||
it("manually renamed combatant: prefix not found, name unchanged", () => {
|
||||
// Combatant was elite but manually renamed to "Big Boss"
|
||||
const state = stateWithCreature("Big Boss", 95, 24, "elite");
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: undefined,
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
// No "Elite " prefix found, so name stays as is
|
||||
expect(next.encounter.combatants[0].name).toBe("Big Boss");
|
||||
});
|
||||
|
||||
it("emits CreatureAdjustmentSet event", () => {
|
||||
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
const event = next.events.find((e) => e.type === "CreatureAdjustmentSet");
|
||||
expect(event).toEqual({
|
||||
type: "CreatureAdjustmentSet",
|
||||
combatantId: "c-1",
|
||||
adjustment: "elite",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unchanged state when adjustment is the same", () => {
|
||||
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-1"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("returns unchanged state for unknown combatant", () => {
|
||||
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-creature-adjustment",
|
||||
id: combatantId("c-99"),
|
||||
adjustment: "elite",
|
||||
baseCreature: BASE_CREATURE,
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@ export function useAutoStatBlock(): void {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { panelView, updateCreature } = useSidePanelContext();
|
||||
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
const activeCreatureId = activeCombatant?.creatureId;
|
||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +21,13 @@ export function useAutoStatBlock(): void {
|
||||
activeCreatureId &&
|
||||
panelView.mode === "creature"
|
||||
) {
|
||||
updateCreature(activeCreatureId);
|
||||
updateCreature(activeCreatureId, activeCombatant.id);
|
||||
}
|
||||
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
activeCreatureId,
|
||||
activeCombatant?.id,
|
||||
panelView.mode,
|
||||
updateCreature,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,15 @@ import type {
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
Pf2eCreature,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
acDelta,
|
||||
clearHistory,
|
||||
combatantId,
|
||||
hpDelta,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
pushUndo,
|
||||
@@ -84,6 +87,12 @@ type EncounterAction =
|
||||
entry: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
type: "set-creature-adjustment";
|
||||
id: CombatantId;
|
||||
adjustment: "weak" | "elite" | undefined;
|
||||
baseCreature: Pf2eCreature;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
| {
|
||||
type: "import";
|
||||
@@ -279,6 +288,76 @@ function handleAddFromPlayerCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
function applyNamePrefix(
|
||||
name: string,
|
||||
oldAdj: "weak" | "elite" | undefined,
|
||||
newAdj: "weak" | "elite" | undefined,
|
||||
): string {
|
||||
let base = name;
|
||||
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
|
||||
else if (oldAdj === "elite" && name.startsWith("Elite "))
|
||||
base = name.slice(6);
|
||||
if (newAdj === "weak") return `Weak ${base}`;
|
||||
if (newAdj === "elite") return `Elite ${base}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function handleSetCreatureAdjustment(
|
||||
state: EncounterState,
|
||||
id: CombatantId,
|
||||
adjustment: "weak" | "elite" | undefined,
|
||||
baseCreature: Pf2eCreature,
|
||||
): EncounterState {
|
||||
const combatant = state.encounter.combatants.find((c) => c.id === id);
|
||||
if (!combatant) return state;
|
||||
|
||||
const oldAdj = combatant.creatureAdjustment;
|
||||
if (oldAdj === adjustment) return state;
|
||||
|
||||
const baseLevel = baseCreature.level;
|
||||
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
|
||||
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
|
||||
const netHpDelta = newHpDelta - oldHpDelta;
|
||||
|
||||
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
|
||||
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
|
||||
const netAcDelta = newAcDelta - oldAcDelta;
|
||||
|
||||
const newMaxHp =
|
||||
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
|
||||
const newCurrentHp =
|
||||
combatant.currentHp === undefined || newMaxHp === undefined
|
||||
? undefined
|
||||
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
|
||||
const newAc =
|
||||
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
|
||||
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
|
||||
|
||||
const updatedCombatant: typeof combatant = {
|
||||
...combatant,
|
||||
name: newName,
|
||||
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
|
||||
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
|
||||
...(newAc !== undefined && { ac: newAc }),
|
||||
...(adjustment === undefined
|
||||
? { creatureAdjustment: undefined }
|
||||
: { creatureAdjustment: adjustment }),
|
||||
};
|
||||
|
||||
const combatants = state.encounter.combatants.map((c) =>
|
||||
c.id === id ? updatedCombatant : c,
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: { ...state.encounter, combatants },
|
||||
events: [
|
||||
...state.events,
|
||||
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// -- Reducer --
|
||||
|
||||
export function encounterReducer(
|
||||
@@ -310,6 +389,13 @@ export function encounterReducer(
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
case "set-creature-adjustment":
|
||||
return handleSetCreatureAdjustment(
|
||||
state,
|
||||
action.id,
|
||||
action.adjustment,
|
||||
action.baseCreature,
|
||||
);
|
||||
case "add-from-bestiary":
|
||||
return handleAddFromBestiary(state, action.entry, 1);
|
||||
case "add-multiple-from-bestiary":
|
||||
@@ -565,6 +651,20 @@ export function useEncounter() {
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
),
|
||||
setCreatureAdjustment: useCallback(
|
||||
(
|
||||
id: CombatantId,
|
||||
adjustment: "weak" | "elite" | undefined,
|
||||
baseCreature: Pf2eCreature,
|
||||
) =>
|
||||
dispatch({
|
||||
type: "set-creature-adjustment",
|
||||
id,
|
||||
adjustment,
|
||||
baseCreature,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
clearEncounter: useCallback(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import type { CombatantId, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type PanelView =
|
||||
| { mode: "closed" }
|
||||
| { mode: "creature"; creatureId: CreatureId }
|
||||
| { mode: "creature"; creatureId: CreatureId; combatantId?: CombatantId }
|
||||
| { mode: "bulk-import" }
|
||||
| { mode: "source-manager" };
|
||||
|
||||
interface SidePanelState {
|
||||
panelView: PanelView;
|
||||
selectedCreatureId: CreatureId | null;
|
||||
selectedCombatantId: CombatantId | null;
|
||||
bulkImportMode: boolean;
|
||||
sourceManagerMode: boolean;
|
||||
isRightPanelCollapsed: boolean;
|
||||
@@ -18,8 +19,8 @@ interface SidePanelState {
|
||||
}
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
updateCreature: (creatureId: CreatureId) => void;
|
||||
showCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||
updateCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
@@ -48,14 +49,23 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
const selectedCreatureId =
|
||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||
|
||||
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
setIsRightPanelCollapsed(false);
|
||||
}, []);
|
||||
const selectedCombatantId =
|
||||
panelView.mode === "creature" ? (panelView.combatantId ?? null) : null;
|
||||
|
||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
}, []);
|
||||
const showCreature = useCallback(
|
||||
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||
setIsRightPanelCollapsed(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateCreature = useCallback(
|
||||
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
@@ -90,6 +100,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
return {
|
||||
panelView,
|
||||
selectedCreatureId,
|
||||
selectedCombatantId,
|
||||
bulkImportMode: panelView.mode === "bulk-import",
|
||||
sourceManagerMode: panelView.mode === "source-manager",
|
||||
isRightPanelCollapsed,
|
||||
|
||||
Reference in New Issue
Block a user