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

@@ -0,0 +1,106 @@
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;
}