- 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>
187 lines
4.8 KiB
TypeScript
187 lines
4.8 KiB
TypeScript
import type { ConditionEntry, ConditionId } from "./conditions.js";
|
|
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
|
|
import type { DomainEvent } from "./events.js";
|
|
import {
|
|
type CombatantId,
|
|
type DomainError,
|
|
type Encounter,
|
|
findCombatant,
|
|
isDomainError,
|
|
} from "./types.js";
|
|
|
|
export interface ToggleConditionSuccess {
|
|
readonly encounter: Encounter;
|
|
readonly events: DomainEvent[];
|
|
}
|
|
|
|
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
|
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
|
return {
|
|
kind: "domain-error",
|
|
code: "unknown-condition",
|
|
message: `Unknown condition "${conditionId}"`,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyConditions(
|
|
encounter: Encounter,
|
|
combatantId: CombatantId,
|
|
newConditions: readonly ConditionEntry[] | undefined,
|
|
): Encounter {
|
|
return {
|
|
combatants: encounter.combatants.map((c) =>
|
|
c.id === combatantId ? { ...c, conditions: newConditions } : c,
|
|
),
|
|
activeIndex: encounter.activeIndex,
|
|
roundNumber: encounter.roundNumber,
|
|
};
|
|
}
|
|
|
|
export function toggleCondition(
|
|
encounter: Encounter,
|
|
combatantId: CombatantId,
|
|
conditionId: ConditionId,
|
|
): ToggleConditionSuccess | DomainError {
|
|
const err = validateConditionId(conditionId);
|
|
if (err) return err;
|
|
|
|
const found = findCombatant(encounter, combatantId);
|
|
if (isDomainError(found)) return found;
|
|
const { combatant: target } = found;
|
|
const current = target.conditions ?? [];
|
|
const isActive = current.some((c) => c.id === conditionId);
|
|
|
|
let newConditions: readonly ConditionEntry[] | undefined;
|
|
let event: DomainEvent;
|
|
|
|
if (isActive) {
|
|
const filtered = current.filter((c) => c.id !== conditionId);
|
|
newConditions = filtered.length > 0 ? filtered : undefined;
|
|
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
|
} else {
|
|
newConditions = [...current, { id: conditionId }];
|
|
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
|
}
|
|
|
|
return {
|
|
encounter: applyConditions(encounter, combatantId, newConditions),
|
|
events: [event],
|
|
};
|
|
}
|
|
|
|
export function setConditionValue(
|
|
encounter: Encounter,
|
|
combatantId: CombatantId,
|
|
conditionId: ConditionId,
|
|
value: number,
|
|
): ToggleConditionSuccess | DomainError {
|
|
const err = validateConditionId(conditionId);
|
|
if (err) return err;
|
|
|
|
const found = findCombatant(encounter, combatantId);
|
|
if (isDomainError(found)) return found;
|
|
const { combatant: target } = found;
|
|
const current = target.conditions ?? [];
|
|
|
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === conditionId);
|
|
const clampedValue =
|
|
def?.maxValue === undefined ? value : Math.min(value, def.maxValue);
|
|
|
|
if (clampedValue <= 0) {
|
|
const filtered = current.filter((c) => c.id !== conditionId);
|
|
const newConditions = filtered.length > 0 ? filtered : undefined;
|
|
return {
|
|
encounter: applyConditions(encounter, combatantId, newConditions),
|
|
events: [
|
|
{ type: "ConditionRemoved", combatantId, condition: conditionId },
|
|
],
|
|
};
|
|
}
|
|
|
|
const existing = current.find((c) => c.id === conditionId);
|
|
if (existing) {
|
|
const updated = current.map((c) =>
|
|
c.id === conditionId ? { ...c, value: clampedValue } : c,
|
|
);
|
|
return {
|
|
encounter: applyConditions(encounter, combatantId, updated),
|
|
events: [
|
|
{
|
|
type: "ConditionAdded",
|
|
combatantId,
|
|
condition: conditionId,
|
|
value: clampedValue,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const added = [...current, { id: conditionId, value: clampedValue }];
|
|
return {
|
|
encounter: applyConditions(encounter, combatantId, added),
|
|
events: [
|
|
{
|
|
type: "ConditionAdded",
|
|
combatantId,
|
|
condition: conditionId,
|
|
value: clampedValue,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function decrementCondition(
|
|
encounter: Encounter,
|
|
combatantId: CombatantId,
|
|
conditionId: ConditionId,
|
|
): ToggleConditionSuccess | DomainError {
|
|
const err = validateConditionId(conditionId);
|
|
if (err) return err;
|
|
|
|
const found = findCombatant(encounter, combatantId);
|
|
if (isDomainError(found)) return found;
|
|
const { combatant: target } = found;
|
|
const current = target.conditions ?? [];
|
|
const existing = current.find((c) => c.id === conditionId);
|
|
|
|
if (!existing) {
|
|
return {
|
|
kind: "domain-error",
|
|
code: "condition-not-active",
|
|
message: `Condition "${conditionId}" is not active`,
|
|
};
|
|
}
|
|
|
|
const newValue = (existing.value ?? 1) - 1;
|
|
if (newValue <= 0) {
|
|
const filtered = current.filter((c) => c.id !== conditionId);
|
|
return {
|
|
encounter: applyConditions(
|
|
encounter,
|
|
combatantId,
|
|
filtered.length > 0 ? filtered : undefined,
|
|
),
|
|
events: [
|
|
{ type: "ConditionRemoved", combatantId, condition: conditionId },
|
|
],
|
|
};
|
|
}
|
|
|
|
const updated = current.map((c) =>
|
|
c.id === conditionId ? { ...c, value: newValue } : c,
|
|
);
|
|
return {
|
|
encounter: applyConditions(encounter, combatantId, updated),
|
|
events: [
|
|
{
|
|
type: "ConditionAdded",
|
|
combatantId,
|
|
condition: conditionId,
|
|
value: newValue,
|
|
},
|
|
],
|
|
};
|
|
}
|