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>
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import type { ConditionId } from "./conditions.js";
|
|
import { VALID_CONDITION_IDS } from "./conditions.js";
|
|
import { creatureId } from "./creature-types.js";
|
|
import {
|
|
playerCharacterId,
|
|
VALID_PLAYER_COLORS,
|
|
VALID_PLAYER_ICONS,
|
|
} from "./player-character-types.js";
|
|
import type { Combatant } from "./types.js";
|
|
import { combatantId } from "./types.js";
|
|
|
|
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 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 validateTempHp(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function validateInteger(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isInteger(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function validateSetMember(
|
|
value: unknown,
|
|
valid: ReadonlySet<string>,
|
|
): string | undefined {
|
|
return typeof value === "string" && valid.has(value) ? value : undefined;
|
|
}
|
|
|
|
function validateNonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function parseOptionalFields(entry: Record<string, unknown>) {
|
|
return {
|
|
initiative: validateInteger(entry.initiative),
|
|
ac: validateAc(entry.ac),
|
|
conditions: validateConditions(entry.conditions),
|
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
|
creatureId: validateNonEmptyString(entry.creatureId)
|
|
? creatureId(entry.creatureId as string)
|
|
: undefined,
|
|
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
|
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
|
|
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
|
|
? playerCharacterId(entry.playerCharacterId as string)
|
|
: undefined,
|
|
tempHp: validateTempHp(entry.tempHp),
|
|
};
|
|
}
|
|
|
|
export function rehydrateCombatant(raw: unknown): Combatant | 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") return null;
|
|
|
|
const shared: Combatant = {
|
|
id: combatantId(entry.id),
|
|
name: entry.name,
|
|
...parseOptionalFields(entry),
|
|
};
|
|
|
|
const hp = validateHp(entry.maxHp, entry.currentHp);
|
|
return hp ? { ...shared, ...hp } : shared;
|
|
}
|