1de00e3d8e
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>
56 lines
1.5 KiB
TypeScript
56 lines
1.5 KiB
TypeScript
import type { PlayerCharacter } from "./player-character-types.js";
|
|
import {
|
|
playerCharacterId,
|
|
VALID_PLAYER_COLORS,
|
|
VALID_PLAYER_ICONS,
|
|
} from "./player-character-types.js";
|
|
|
|
function isValidOptionalMember(
|
|
value: unknown,
|
|
valid: ReadonlySet<string>,
|
|
): boolean {
|
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
|
}
|
|
|
|
export function rehydratePlayerCharacter(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,
|
|
};
|
|
}
|