Compare commits

...

2 Commits

Author SHA1 Message Date
Lukas
064af16f95 Fix persistent damage tag ordering and differentiate condition icons
All checks were successful
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 18s
- Render persistent damage tags before the "+" button, not after
- Use insertion order for conditions on the row instead of definition order
- Differentiate Undetected condition (EyeClosed/slate) from Invisible (Ghost/violet)
- Use purple for void persistent damage to distinguish from violet conditions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:06:31 +02:00
Lukas
0f640601b6 Add force, void, spirit, vitality, and piercing persistent damage types
All checks were successful
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 19s
Expands persistent damage from 7 to 12 types to cover all PF2e damage
types that have verified persistent damage sources in published content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:44:03 +02:00
8 changed files with 60 additions and 29 deletions

View File

@@ -618,14 +618,17 @@ export function CombatantRow({
onRemove={(conditionId) => toggleCondition(id, conditionId)} onRemove={(conditionId) => toggleCondition(id, conditionId)}
onDecrement={(conditionId) => decrementCondition(id, conditionId)} onDecrement={(conditionId) => decrementCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
/> >
</div>
{isPf2e && ( {isPf2e && (
<PersistentDamageTags <PersistentDamageTags
entries={combatant.persistentDamage} entries={combatant.persistentDamage}
onRemove={(damageType) => removePersistentDamage(id, damageType)} onRemove={(damageType) =>
removePersistentDamage(id, damageType)
}
/> />
)} )}
</ConditionTags>
</div>
{!!pickerOpen && ( {!!pickerOpen && (
<ConditionPicker <ConditionPicker
anchorRef={conditionAnchorRef} anchorRef={conditionAnchorRef}

View File

@@ -11,7 +11,9 @@ import {
Droplet, Droplet,
Droplets, Droplets,
EarOff, EarOff,
Eclipse,
Eye, Eye,
EyeClosed,
EyeOff, EyeOff,
Flame, Flame,
FlaskConical, FlaskConical,
@@ -24,6 +26,7 @@ import {
HeartPulse, HeartPulse,
Link, Link,
Moon, Moon,
Orbit,
PersonStanding, PersonStanding,
ShieldMinus, ShieldMinus,
ShieldOff, ShieldOff,
@@ -31,9 +34,12 @@ import {
Skull, Skull,
Snail, Snail,
Snowflake, Snowflake,
Sparkle,
Sparkles, Sparkles,
Sun, Sun,
Sword,
TrendingDown, TrendingDown,
Wind,
Zap, Zap,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
@@ -50,7 +56,9 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
Droplet, Droplet,
Droplets, Droplets,
EarOff, EarOff,
Eclipse,
Eye, Eye,
EyeClosed,
EyeOff, EyeOff,
Flame, Flame,
FlaskConical, FlaskConical,
@@ -63,6 +71,7 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
HeartPulse, HeartPulse,
Link, Link,
Moon, Moon,
Orbit,
PersonStanding, PersonStanding,
ShieldMinus, ShieldMinus,
ShieldOff, ShieldOff,
@@ -70,9 +79,12 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
Skull, Skull,
Snail, Snail,
Snowflake, Snowflake,
Sparkle,
Sparkles, Sparkles,
Sun, Sun,
Sword,
TrendingDown, TrendingDown,
Wind,
Zap, Zap,
ZapOff, ZapOff,
}; };
@@ -82,6 +94,7 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
pink: "text-pink-400", pink: "text-pink-400",
amber: "text-amber-400", amber: "text-amber-400",
orange: "text-orange-400", orange: "text-orange-400",
purple: "text-purple-400",
gray: "text-gray-400", gray: "text-gray-400",
violet: "text-violet-400", violet: "text-violet-400",
yellow: "text-yellow-400", yellow: "text-yellow-400",

View File

@@ -5,6 +5,7 @@ import {
getConditionDescription, getConditionDescription,
} from "@initiative/domain"; } from "@initiative/domain";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { ReactNode } from "react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { import {
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
onRemove: (conditionId: ConditionId) => void; onRemove: (conditionId: ConditionId) => void;
onDecrement: (conditionId: ConditionId) => void; onDecrement: (conditionId: ConditionId) => void;
onOpenPicker: () => void; onOpenPicker: () => void;
children?: ReactNode;
} }
export function ConditionTags({ export function ConditionTags({
@@ -25,6 +27,7 @@ export function ConditionTags({
onRemove, onRemove,
onDecrement, onDecrement,
onOpenPicker, onOpenPicker,
children,
}: Readonly<ConditionTagsProps>) { }: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
return ( return (
@@ -69,6 +72,7 @@ export function ConditionTags({
</Tooltip> </Tooltip>
); );
})} })}
{children}
<button <button
type="button" type="button"
title="Add condition" title="Add condition"

View File

@@ -60,13 +60,13 @@ describe("toggleCondition", () => {
]); ]);
}); });
it("maintains definition order when adding conditions", () => { it("appends new conditions to the end (insertion order)", () => {
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]); const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
const { encounter } = success(e, "A", "blinded"); const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual([ expect(encounter.combatants[0].conditions).toEqual([
{ id: "blinded" },
{ id: "poisoned" }, { id: "poisoned" },
{ id: "blinded" },
]); ]);
}); });
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
expect(encounter.combatants[0].conditions).toBeUndefined(); expect(encounter.combatants[0].conditions).toBeUndefined();
}); });
it("preserves order across all conditions", () => { it("preserves insertion order across all conditions", () => {
const order = CONDITION_DEFINITIONS.map((d) => d.id); const order = CONDITION_DEFINITIONS.map((d) => d.id);
// Add in reverse order // Add in reverse order — result should be reverse order (insertion order)
const reversed = [...order].reverse();
let e = enc([makeCombatant("A")]); let e = enc([makeCombatant("A")]);
for (const cond of [...order].reverse()) { for (const cond of reversed) {
const result = success(e, "A", cond); const result = success(e, "A", cond);
e = result.encounter; e = result.encounter;
} }
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id }))); expect(e.combatants[0].conditions).toEqual(reversed.map((id) => ({ id })));
}); });
}); });

View File

@@ -500,8 +500,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "", description5e: "",
descriptionPf2e: descriptionPf2e:
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.", "Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
iconName: "Ghost", iconName: "EyeClosed",
color: "violet", color: "slate",
systems: ["pf2e"], systems: ["pf2e"],
}, },
{ {

View File

@@ -15,6 +15,11 @@ export const PERSISTENT_DAMAGE_TYPES = [
"electricity", "electricity",
"poison", "poison",
"mental", "mental",
"force",
"void",
"spirit",
"vitality",
"piercing",
] as const; ] as const;
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number]; export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
@@ -64,6 +69,21 @@ export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[
iconName: "BrainCog", iconName: "BrainCog",
color: "pink", color: "pink",
}, },
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
{
type: "vitality",
label: "Vitality",
iconName: "Sparkle",
color: "amber",
},
{
type: "piercing",
label: "Piercing",
iconName: "Sword",
color: "neutral",
},
]; ];
export interface PersistentDamageSuccess { export interface PersistentDamageSuccess {

View File

@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
readonly events: DomainEvent[]; readonly events: DomainEvent[];
} }
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
const order = CONDITION_DEFINITIONS.map((d) => d.id);
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
return entries;
}
function validateConditionId(conditionId: ConditionId): DomainError | null { function validateConditionId(conditionId: ConditionId): DomainError | null {
if (!VALID_CONDITION_IDS.has(conditionId)) { if (!VALID_CONDITION_IDS.has(conditionId)) {
return { return {
@@ -67,8 +61,7 @@ export function toggleCondition(
newConditions = filtered.length > 0 ? filtered : undefined; newConditions = filtered.length > 0 ? filtered : undefined;
event = { type: "ConditionRemoved", combatantId, condition: conditionId }; event = { type: "ConditionRemoved", combatantId, condition: conditionId };
} else { } else {
const added = sortByDefinitionOrder([...current, { id: conditionId }]); newConditions = [...current, { id: conditionId }];
newConditions = added;
event = { type: "ConditionAdded", combatantId, condition: conditionId }; event = { type: "ConditionAdded", combatantId, condition: conditionId };
} }
@@ -125,10 +118,7 @@ export function setConditionValue(
}; };
} }
const added = sortByDefinitionOrder([ const added = [...current, { id: conditionId, value: clampedValue }];
...current,
{ id: conditionId, value: clampedValue },
]);
return { return {
encounter: applyConditions(encounter, combatantId, added), encounter: applyConditions(encounter, combatantId, added),
events: [ events: [

View File

@@ -356,7 +356,7 @@ Acceptance scenarios:
As a DM running a PF2e encounter, I want to apply persistent damage to a combatant as a compact tag showing a damage type icon and formula so I can track ongoing damage effects without manual bookkeeping. As a DM running a PF2e encounter, I want to apply persistent damage to a combatant as a compact tag showing a damage type icon and formula so I can track ongoing damage effects without manual bookkeeping.
Acceptance scenarios: Acceptance scenarios:
1. **Given** the game system is Pathfinder 2e and the condition picker is open, **When** the user clicks "Persistent Damage", **Then** a sub-picker opens with a damage type dropdown (fire, bleed, acid, cold, electricity, poison, mental) and a formula text input. 1. **Given** the game system is Pathfinder 2e and the condition picker is open, **When** the user clicks "Persistent Damage", **Then** a sub-picker opens with a damage type dropdown (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a formula text input.
2. **Given** the sub-picker is open, **When** the user selects "fire" and types "2d6" and confirms, **Then** a compact tag appears on the combatant row showing a fire icon and "2d6". 2. **Given** the sub-picker is open, **When** the user selects "fire" and types "2d6" and confirms, **Then** a compact tag appears on the combatant row showing a fire icon and "2d6".
3. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent bleed 1d4, **Then** both tags appear on the row simultaneously. 3. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent bleed 1d4, **Then** both tags appear on the row simultaneously.
4. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent fire 3d6, **Then** the existing fire entry is replaced with 3d6 (one instance per type). 4. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent fire 3d6, **Then** the existing fire entry is replaced with 3d6 (one instance per type).
@@ -421,7 +421,7 @@ Acceptance scenarios:
- **FR-111**: When Pathfinder 2e is the active game system, the concentration UI (Brain icon toggle, purple left border accent, damage pulse animation) MUST be hidden entirely. The Brain icon MUST NOT be shown on hover or at rest, and the concentration toggle MUST NOT be interactive. - **FR-111**: When Pathfinder 2e is the active game system, the concentration UI (Brain icon toggle, purple left border accent, damage pulse animation) MUST be hidden entirely. The Brain icon MUST NOT be shown on hover or at rest, and the concentration toggle MUST NOT be interactive.
- **FR-112**: Switching the game system MUST NOT clear or modify `isConcentrating` state on any combatant. The state MUST be preserved in storage and restored to the UI when switching back to a D&D game system. - **FR-112**: Switching the game system MUST NOT clear or modify `isConcentrating` state on any combatant. The state MUST be preserved in storage and restored to the UI when switching back to a D&D game system.
- **FR-117**: When Pathfinder 2e is active, the condition picker MUST include a "Persistent Damage" entry that opens a sub-picker instead of toggling directly. - **FR-117**: When Pathfinder 2e is active, the condition picker MUST include a "Persistent Damage" entry that opens a sub-picker instead of toggling directly.
- **FR-118**: The persistent damage sub-picker MUST contain a dropdown of common PF2e damage types (fire, bleed, acid, cold, electricity, poison, mental) and a text input for the damage formula (e.g., "2d6"). - **FR-118**: The persistent damage sub-picker MUST contain a dropdown of PF2e damage types (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a text input for the damage formula (e.g., "2d6").
- **FR-119**: Each persistent damage entry MUST be displayed as a compact tag on the combatant row showing a damage type icon and the formula text (e.g., fire icon + "2d6"). - **FR-119**: Each persistent damage entry MUST be displayed as a compact tag on the combatant row showing a damage type icon and the formula text (e.g., fire icon + "2d6").
- **FR-120**: Only one persistent damage entry per damage type is allowed per combatant. Adding the same damage type MUST replace the existing formula. - **FR-120**: Only one persistent damage entry per damage type is allowed per combatant. Adding the same damage type MUST replace the existing formula.
- **FR-121**: Clicking a persistent damage tag on the combatant row MUST remove that entry. - **FR-121**: Clicking a persistent damage tag on the combatant row MUST remove that entry.