Move entity rehydration to domain layer, fix tempHp gap
All checks were successful
CI / check (push) Successful in 1m16s
CI / build-image (push) Has been skipped

Rehydration functions (reconstructing typed domain objects from untyped
JSON) lived in persistence adapters, duplicating domain validation.
Adding a field required updating both the domain type and a separate
adapter function — the adapter was missed for `level`, silently dropping
it on reload. Now adding a field only requires updating the domain type
and its co-located rehydration function.

- Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain
- Persistence adapters delegate to domain instead of reimplementing
- Add `tempHp` validation (was silently dropped during rehydration)
- Tighten initiative validation to integer-only
- Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip
- Remove stale `jsinspect-plus` Knip ignoreDependencies entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 11:12:41 +01:00
parent f4fb69dbc7
commit 1de00e3d8e
12 changed files with 799 additions and 426 deletions

View File

@@ -1,9 +1,5 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
@@ -15,55 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
}
}
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
export 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 (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (
entry.level !== undefined &&
(typeof entry.level !== "number" ||
!Number.isInteger(entry.level) ||
entry.level < 1 ||
entry.level > 20)
)
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"],
level: entry.level,
};
}
export function loadPlayerCharacters(): PlayerCharacter[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -74,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
const pc = rehydratePlayerCharacter(item);
if (pc !== null) {
characters.push(pc);
}