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>
73 lines
1.8 KiB
TypeScript
73 lines
1.8 KiB
TypeScript
import type { PlayerCharacter } from "@initiative/domain";
|
|
import {
|
|
playerCharacterId,
|
|
VALID_PLAYER_COLORS,
|
|
VALID_PLAYER_ICONS,
|
|
} from "@initiative/domain";
|
|
|
|
const STORAGE_KEY = "initiative:player-characters";
|
|
|
|
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
|
|
} catch {
|
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
|
}
|
|
}
|
|
|
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
return null;
|
|
const entry = raw as Record<string, unknown>;
|
|
|
|
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
|
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
|
return null;
|
|
if (
|
|
typeof entry.ac !== "number" ||
|
|
!Number.isInteger(entry.ac) ||
|
|
entry.ac < 0
|
|
)
|
|
return null;
|
|
if (
|
|
typeof entry.maxHp !== "number" ||
|
|
!Number.isInteger(entry.maxHp) ||
|
|
entry.maxHp < 1
|
|
)
|
|
return null;
|
|
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
|
return null;
|
|
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
|
return null;
|
|
|
|
return {
|
|
id: playerCharacterId(entry.id),
|
|
name: entry.name,
|
|
ac: entry.ac,
|
|
maxHp: entry.maxHp,
|
|
color: entry.color as PlayerCharacter["color"],
|
|
icon: entry.icon as PlayerCharacter["icon"],
|
|
};
|
|
}
|
|
|
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw === null) return [];
|
|
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
|
|
const characters: PlayerCharacter[] = [];
|
|
for (const item of parsed) {
|
|
const pc = rehydrateCharacter(item);
|
|
if (pc !== null) {
|
|
characters.push(pc);
|
|
}
|
|
}
|
|
return characters;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|