Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064af16f95 | ||
|
|
0f640601b6 |
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 })));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user