Persistent player character templates (name, AC, HP, color, icon) with full CRUD, bestiary-style search to add PCs to encounters with pre-filled stats, and color/icon visual distinction in combatant rows. Also stops the stat block panel from auto-opening when adding a creature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
154 lines
3.8 KiB
TypeScript
154 lines
3.8 KiB
TypeScript
import {
|
|
type ConditionId,
|
|
combatantId,
|
|
createEncounter,
|
|
creatureId,
|
|
type Encounter,
|
|
isDomainError,
|
|
playerCharacterId,
|
|
VALID_CONDITION_IDS,
|
|
VALID_PLAYER_COLORS,
|
|
VALID_PLAYER_ICONS,
|
|
} from "@initiative/domain";
|
|
|
|
const STORAGE_KEY = "initiative:encounter";
|
|
|
|
export function saveEncounter(encounter: Encounter): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(encounter));
|
|
} catch {
|
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
|
}
|
|
}
|
|
|
|
function validateAc(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function validateConditions(value: unknown): ConditionId[] | undefined {
|
|
if (!Array.isArray(value)) return undefined;
|
|
const valid = value.filter(
|
|
(v): v is ConditionId =>
|
|
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
|
);
|
|
return valid.length > 0 ? valid : undefined;
|
|
}
|
|
|
|
function validateCreatureId(value: unknown) {
|
|
return typeof value === "string" && value.length > 0
|
|
? creatureId(value)
|
|
: undefined;
|
|
}
|
|
|
|
function validateHp(
|
|
rawMaxHp: unknown,
|
|
rawCurrentHp: unknown,
|
|
): { maxHp: number; currentHp: number } | undefined {
|
|
if (
|
|
typeof rawMaxHp !== "number" ||
|
|
!Number.isInteger(rawMaxHp) ||
|
|
rawMaxHp < 1
|
|
) {
|
|
return undefined;
|
|
}
|
|
const validCurrentHp =
|
|
typeof rawCurrentHp === "number" &&
|
|
Number.isInteger(rawCurrentHp) &&
|
|
rawCurrentHp >= 0 &&
|
|
rawCurrentHp <= rawMaxHp;
|
|
return {
|
|
maxHp: rawMaxHp,
|
|
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
|
};
|
|
}
|
|
|
|
function rehydrateCombatant(c: unknown) {
|
|
const entry = c as Record<string, unknown>;
|
|
const base = {
|
|
id: combatantId(entry.id as string),
|
|
name: entry.name as string,
|
|
initiative:
|
|
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
|
};
|
|
|
|
const color =
|
|
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
|
? entry.color
|
|
: undefined;
|
|
const icon =
|
|
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
|
? entry.icon
|
|
: undefined;
|
|
const pcId =
|
|
typeof entry.playerCharacterId === "string" &&
|
|
entry.playerCharacterId.length > 0
|
|
? playerCharacterId(entry.playerCharacterId)
|
|
: undefined;
|
|
|
|
const shared = {
|
|
...base,
|
|
ac: validateAc(entry.ac),
|
|
conditions: validateConditions(entry.conditions),
|
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
|
creatureId: validateCreatureId(entry.creatureId),
|
|
color,
|
|
icon,
|
|
playerCharacterId: pcId,
|
|
};
|
|
|
|
const hp = validateHp(entry.maxHp, entry.currentHp);
|
|
return hp ? { ...shared, ...hp } : shared;
|
|
}
|
|
|
|
function isValidCombatantEntry(c: unknown): boolean {
|
|
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
|
const entry = c as Record<string, unknown>;
|
|
return typeof entry.id === "string" && typeof entry.name === "string";
|
|
}
|
|
|
|
export function loadEncounter(): Encounter | null {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw === null) return null;
|
|
|
|
const parsed: unknown = JSON.parse(raw);
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
return null;
|
|
|
|
const obj = parsed as Record<string, unknown>;
|
|
|
|
if (!Array.isArray(obj.combatants)) return null;
|
|
if (typeof obj.activeIndex !== "number") return null;
|
|
if (typeof obj.roundNumber !== "number") return null;
|
|
|
|
const combatants = obj.combatants as unknown[];
|
|
|
|
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
|
if (combatants.length === 0) {
|
|
return {
|
|
combatants: [],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
}
|
|
|
|
if (!combatants.every(isValidCombatantEntry)) return null;
|
|
|
|
const rehydrated = combatants.map(rehydrateCombatant);
|
|
|
|
const result = createEncounter(
|
|
rehydrated,
|
|
obj.activeIndex,
|
|
obj.roundNumber,
|
|
);
|
|
if (isDomainError(result)) return null;
|
|
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|