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,241 @@
import { describe, expect, it } from "vitest";
import { rehydrateCombatant } from "../rehydrate-combatant.js";
function validCombatant(overrides: Record<string, unknown> = {}) {
return {
id: "c-1",
name: "Goblin",
initiative: 12,
ac: 15,
maxHp: 7,
currentHp: 5,
tempHp: 3,
conditions: ["poisoned"],
isConcentrating: true,
creatureId: "creature-goblin",
color: "red",
icon: "skull",
playerCharacterId: "pc-1",
...overrides,
};
}
function minimalCombatant() {
return { id: "c-1", name: "Goblin" };
}
describe("rehydrateCombatant", () => {
describe("valid input", () => {
it("accepts a combatant with all fields", () => {
const result = rehydrateCombatant(validCombatant());
expect(result).not.toBeNull();
expect(result?.name).toBe("Goblin");
expect(result?.initiative).toBe(12);
expect(result?.ac).toBe(15);
expect(result?.maxHp).toBe(7);
expect(result?.currentHp).toBe(5);
expect(result?.tempHp).toBe(3);
expect(result?.conditions).toEqual(["poisoned"]);
expect(result?.isConcentrating).toBe(true);
expect(result?.creatureId).toBe("creature-goblin");
expect(result?.color).toBe("red");
expect(result?.icon).toBe("skull");
expect(result?.playerCharacterId).toBe("pc-1");
});
it("accepts a minimal combatant (id + name only)", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result).not.toBeNull();
expect(result?.id).toBe("c-1");
expect(result?.name).toBe("Goblin");
expect(result?.initiative).toBeUndefined();
expect(result?.ac).toBeUndefined();
expect(result?.maxHp).toBeUndefined();
});
it("preserves branded CombatantId", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result?.id).toBe("c-1");
});
});
describe("required field rejection", () => {
it.each([
null,
42,
"string",
[1, 2],
undefined,
])("rejects non-object input: %j", (input) => {
expect(rehydrateCombatant(input)).toBeNull();
});
it("rejects missing id", () => {
const { id: _, ...rest } = minimalCombatant();
expect(rehydrateCombatant(rest)).toBeNull();
});
it("rejects empty id", () => {
expect(rehydrateCombatant({ ...minimalCombatant(), id: "" })).toBeNull();
});
it("rejects missing name", () => {
const { name: _, ...rest } = minimalCombatant();
expect(rehydrateCombatant(rest)).toBeNull();
});
it("rejects non-string name", () => {
expect(
rehydrateCombatant({ ...minimalCombatant(), name: 42 }),
).toBeNull();
expect(
rehydrateCombatant({ ...minimalCombatant(), name: null }),
).toBeNull();
});
});
describe("optional field leniency", () => {
it("drops invalid ac — keeps combatant", () => {
for (const ac of [-1, 1.5, "15"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), ac });
expect(result).not.toBeNull();
expect(result?.ac).toBeUndefined();
}
});
it("drops invalid maxHp — keeps combatant", () => {
for (const maxHp of [0, 1.5, "7"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), maxHp });
expect(result).not.toBeNull();
expect(result?.maxHp).toBeUndefined();
}
});
it("falls back currentHp to maxHp when currentHp invalid", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
maxHp: 10,
currentHp: "bad",
});
expect(result?.maxHp).toBe(10);
expect(result?.currentHp).toBe(10);
});
it("falls back currentHp to maxHp when currentHp > maxHp", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
maxHp: 10,
currentHp: 15,
});
expect(result?.maxHp).toBe(10);
expect(result?.currentHp).toBe(10);
});
it("drops invalid initiative — keeps combatant", () => {
for (const initiative of [1.5, "12"]) {
const result = rehydrateCombatant({
...minimalCombatant(),
initiative,
});
expect(result).not.toBeNull();
expect(result?.initiative).toBeUndefined();
}
});
it("drops invalid conditions — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: "poisoned",
});
expect(result).not.toBeNull();
expect(result?.conditions).toBeUndefined();
});
it("drops unknown condition IDs", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["fake-condition"],
});
expect(result).not.toBeNull();
expect(result?.conditions).toBeUndefined();
});
it("filters valid conditions from mixed array", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["poisoned", "fake", "blinded"],
});
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
});
it("drops invalid color — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
color: "neon",
});
expect(result).not.toBeNull();
expect(result?.color).toBeUndefined();
});
it("drops invalid icon — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
icon: "rocket",
});
expect(result).not.toBeNull();
expect(result?.icon).toBeUndefined();
});
it("drops isConcentrating when not strictly true", () => {
for (const isConcentrating of [false, "true", 1]) {
const result = rehydrateCombatant({
...minimalCombatant(),
isConcentrating,
});
expect(result).not.toBeNull();
expect(result?.isConcentrating).toBeUndefined();
}
});
it("drops invalid creatureId", () => {
for (const creatureId of ["", 42]) {
const result = rehydrateCombatant({
...minimalCombatant(),
creatureId,
});
expect(result).not.toBeNull();
expect(result?.creatureId).toBeUndefined();
}
});
it("drops invalid playerCharacterId", () => {
for (const playerCharacterId of ["", 42]) {
const result = rehydrateCombatant({
...minimalCombatant(),
playerCharacterId,
});
expect(result).not.toBeNull();
expect(result?.playerCharacterId).toBeUndefined();
}
});
it("drops invalid tempHp — keeps combatant", () => {
for (const tempHp of [-1, 1.5, "3"]) {
const result = rehydrateCombatant({
...minimalCombatant(),
tempHp,
});
expect(result).not.toBeNull();
expect(result?.tempHp).toBeUndefined();
}
});
it("preserves valid tempHp of 0", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
tempHp: 0,
});
expect(result?.tempHp).toBe(0);
});
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { rehydratePlayerCharacter } from "../rehydrate-player-character.js";
function validPc(overrides: Record<string, unknown> = {}) {
return {
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
level: 5,
...overrides,
};
}
describe("rehydratePlayerCharacter", () => {
describe("valid input", () => {
it("accepts a valid PC with all fields", () => {
const result = rehydratePlayerCharacter(validPc());
expect(result).not.toBeNull();
expect(result?.name).toBe("Aria");
expect(result?.ac).toBe(16);
expect(result?.maxHp).toBe(45);
expect(result?.color).toBe("blue");
expect(result?.icon).toBe("sword");
expect(result?.level).toBe(5);
});
it("accepts a valid PC without optional color/icon/level", () => {
const result = rehydratePlayerCharacter(
validPc({ color: undefined, icon: undefined, level: undefined }),
);
expect(result).not.toBeNull();
expect(result?.color).toBeUndefined();
expect(result?.icon).toBeUndefined();
expect(result?.level).toBeUndefined();
});
it("preserves branded PlayerCharacterId", () => {
const result = rehydratePlayerCharacter(validPc());
expect(result?.id).toBe("pc-1");
});
});
describe("required field rejection", () => {
it.each([
null,
42,
"string",
[1, 2],
undefined,
])("rejects non-object input: %j", (input) => {
expect(rehydratePlayerCharacter(input)).toBeNull();
});
it("rejects missing id", () => {
const { id: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects empty id", () => {
expect(rehydratePlayerCharacter(validPc({ id: "" }))).toBeNull();
});
it("rejects missing name", () => {
const { name: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects empty/whitespace name", () => {
expect(rehydratePlayerCharacter(validPc({ name: "" }))).toBeNull();
expect(rehydratePlayerCharacter(validPc({ name: " " }))).toBeNull();
});
it("rejects missing ac", () => {
const { ac: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects negative ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: -1 }))).toBeNull();
});
it("rejects float ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: 1.5 }))).toBeNull();
});
it("rejects string ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: "16" }))).toBeNull();
});
it("rejects missing maxHp", () => {
const { maxHp: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects maxHp of 0", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: 0 }))).toBeNull();
});
it("rejects float maxHp", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: 1.5 }))).toBeNull();
});
it("rejects string maxHp", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: "45" }))).toBeNull();
});
});
describe("optional field rejection (strict)", () => {
it("rejects invalid color", () => {
expect(rehydratePlayerCharacter(validPc({ color: "neon" }))).toBeNull();
});
it("rejects invalid icon", () => {
expect(rehydratePlayerCharacter(validPc({ icon: "rocket" }))).toBeNull();
});
it("rejects level 0", () => {
expect(rehydratePlayerCharacter(validPc({ level: 0 }))).toBeNull();
});
it("rejects level 21", () => {
expect(rehydratePlayerCharacter(validPc({ level: 21 }))).toBeNull();
});
it("rejects float level", () => {
expect(rehydratePlayerCharacter(validPc({ level: 3.5 }))).toBeNull();
});
it("rejects string level", () => {
expect(rehydratePlayerCharacter(validPc({ level: "5" }))).toBeNull();
});
});
});

View File

@@ -94,6 +94,8 @@ export {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export { rehydrateCombatant } from "./rehydrate-combatant.js";
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
export {
type RemoveCombatantSuccess,
removeCombatant,

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;
}

View File

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