feat(engine): custom modifier library persistence

T3 Wave 3 (T21). localStorage-backed library mirroring the T1 modifier-
profile library at modifiers/library.ts:

- Storage key: houserules:custom-modifiers:v1
- Capacity: MAX_ENTRIES (20) with starred-aware FIFO eviction; refusal
  with a human reason when every slot is starred.
- API: loadCustomModifierLibrary, saveToCustomModifierLibrary (runs
  T19's validateCustomDescriptor before persisting; rejects with the
  validator's error list on failure), removeFromCustomModifierLibrary,
  setCustomModifierStarred.
- Auto-populates descriptor.createdAt on first save; preserves it
  across subsequent saves so library order stays stable.
- Per-entry shape failures during load are silently dropped (matches
  T1 library behaviour — one bad row never blocks the drawer).

12 vitest scenarios cover round-trip, malformed-JSON tolerance,
update/remove/star, validator rejection, capacity eviction, and
all-starred refusal.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 18:02:35 -06:00
commit 795207e8b4
No known key found for this signature in database
2 changed files with 394 additions and 0 deletions

View file

@ -0,0 +1,205 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
import {
__test__,
loadCustomModifierLibrary,
removeFromCustomModifierLibrary,
saveToCustomModifierLibrary,
setCustomModifierStarred,
} from "./library.js";
// Same Map-backed Storage shim used by the T1 library tests
// (see ../library.test.ts) — happy-dom's bundled localStorage doesn't
// survive every destructuring pattern reliably across vitest workers.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
beforeEach(() => {
localStorage.clear();
});
function descriptor(
overrides: Partial<CustomModifierDescriptor> = {},
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(`custom:${Math.random().toString(36).slice(2, 10)}`),
name: "Test",
description: "",
version: 1,
primitives: [],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
describe("custom modifier library — basic round-trip", () => {
it("loadCustomModifierLibrary returns [] when storage is empty", () => {
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("loadCustomModifierLibrary returns [] on malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "{not json[");
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("save then load returns the entry", () => {
const desc = descriptor({ name: "Roundtrip" });
const saved = saveToCustomModifierLibrary(desc);
expect(saved.ok).toBe(true);
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(1);
expect(loaded[0]!.id).toBe(desc.id);
expect(loaded[0]!.descriptor.name).toBe("Roundtrip");
expect(loaded[0]!.starred).toBe(false);
expect(typeof loaded[0]!.updatedAt).toBe("number");
});
it("auto-populates createdAt on first save when absent", () => {
const desc = descriptor();
expect(desc.createdAt).toBeUndefined();
const saved = saveToCustomModifierLibrary(desc);
expect(saved.ok).toBe(true);
if (!saved.ok) return;
expect(typeof saved.entry.descriptor.createdAt).toBe("number");
});
it("preserves createdAt on subsequent saves", async () => {
const id = asCustomModifierId("custom:stable");
const first = saveToCustomModifierLibrary(descriptor({ id, name: "A" }));
expect(first.ok).toBe(true);
if (!first.ok) return;
const originalCreatedAt = first.entry.descriptor.createdAt;
expect(originalCreatedAt).toBeDefined();
// Wait a tick so updatedAt definitely changes.
await new Promise((r) => setTimeout(r, 2));
const second = saveToCustomModifierLibrary(
descriptor({ id, name: "B" }),
);
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.entry.descriptor.createdAt).toBe(originalCreatedAt);
expect(second.entry.descriptor.name).toBe("B");
});
});
describe("custom modifier library — update, remove, star", () => {
it("update by id replaces the descriptor in place", () => {
const id = asCustomModifierId("custom:update");
saveToCustomModifierLibrary(descriptor({ id, name: "v1" }));
saveToCustomModifierLibrary(descriptor({ id, name: "v2" }));
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(1);
expect(loaded[0]!.descriptor.name).toBe("v2");
});
it("removeFromCustomModifierLibrary deletes by id and returns true", () => {
const id = asCustomModifierId("custom:remove");
saveToCustomModifierLibrary(descriptor({ id }));
expect(removeFromCustomModifierLibrary(id)).toBe(true);
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("removeFromCustomModifierLibrary returns false when id absent", () => {
expect(
removeFromCustomModifierLibrary(asCustomModifierId("custom:nope")),
).toBe(false);
});
it("setCustomModifierStarred toggles the flag", () => {
const id = asCustomModifierId("custom:star");
saveToCustomModifierLibrary(descriptor({ id }));
setCustomModifierStarred(id, true);
expect(loadCustomModifierLibrary()[0]!.starred).toBe(true);
setCustomModifierStarred(id, false);
expect(loadCustomModifierLibrary()[0]!.starred).toBe(false);
});
});
describe("custom modifier library — capacity and validation", () => {
it("rejects descriptors that fail T19 validation", () => {
// Empty name fails the validator's name-length rule.
const bad = descriptor({ name: "" });
const result = saveToCustomModifierLibrary(bad);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.code === "descriptor.name.length")).toBe(
true,
);
});
it("evicts the oldest non-starred entry when at capacity", async () => {
// Fill with MAX_ENTRIES non-starred entries with strictly-increasing
// updatedAt timestamps.
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
saveToCustomModifierLibrary(
descriptor({
id: asCustomModifierId(`custom:fill-${i}`),
name: `fill-${i}`,
}),
);
// Tiny stagger so updatedAt timestamps differ.
await new Promise((r) => setTimeout(r, 1));
}
expect(loadCustomModifierLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Add one more — the oldest (fill-0) should be evicted.
const overflow = descriptor({
id: asCustomModifierId("custom:overflow"),
name: "overflow",
});
const result = saveToCustomModifierLibrary(overflow);
expect(result.ok).toBe(true);
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(__test__.MAX_ENTRIES);
expect(loaded.find((e) => e.descriptor.name === "fill-0")).toBeUndefined();
expect(loaded.find((e) => e.descriptor.name === "overflow")).toBeDefined();
});
it("refuses save when the library is full of starred entries", async () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
const id = asCustomModifierId(`custom:starred-${i}`);
saveToCustomModifierLibrary(descriptor({ id, name: `s-${i}` }));
setCustomModifierStarred(id, true);
await new Promise((r) => setTimeout(r, 1));
}
const result = saveToCustomModifierLibrary(
descriptor({ id: asCustomModifierId("custom:rejected") }),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toMatch(/library full/i);
});
});

View file

@ -0,0 +1,189 @@
/**
* Custom modifier library localStorage-backed store of user-authored
* custom modifier descriptors (T21).
*
* Mirrors the T1 modifier-profile library at `../library.ts`:
* - Same MAX_ENTRIES (20) capacity rule with starred-aware FIFO eviction.
* - Same `loadLibrary` / `saveToLibrary` / `deleteFromLibrary` /
* `setStarred` / `duplicateEntry` API surface.
* - Different storage key: `houserules:custom-modifiers:v1`.
*
* On `saveToLibrary` we run T19's validator (`validateCustomDescriptor`)
* before persisting. Invalid descriptors are rejected with the validator's
* error list so the caller can surface inline editor feedback. The library
* never persists a structurally-broken descriptor.
*
* `createdAt` on the descriptor is auto-populated on first save (when the
* descriptor doesn't already carry one); subsequent saves preserve the
* original timestamp so library order stays stable.
*/
import {
parseCustomModifierDescriptor,
safeParseCustomModifierDescriptor,
} from "./schema.js";
import type { CustomModifierDescriptor, CustomModifierId } from "./types.js";
import {
validateCustomDescriptor,
type ValidationError,
} from "./validate.js";
const STORAGE_KEY = "houserules:custom-modifiers:v1";
const MAX_ENTRIES = 20;
export interface SavedCustomModifier {
readonly id: CustomModifierId;
readonly descriptor: CustomModifierDescriptor;
readonly starred: boolean;
readonly updatedAt: number;
}
export type SaveResult =
| { ok: true; entry: SavedCustomModifier }
| { ok: false; reason: string; errors?: ValidationError[] };
/**
* Read every saved custom modifier from storage. Empty/missing/corrupt
* `[]`. Per-entry shape failures are silently dropped so a single
* bad row never blocks the whole library.
*/
export function loadCustomModifierLibrary(): SavedCustomModifier[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSavedCustomModifier);
} catch {
return [];
}
}
/** Replace the entire library array on disk. Best-effort on quota error. */
function writeLibrary(entries: readonly SavedCustomModifier[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new descriptor or update an existing one (matched by descriptor
* id). Runs T19's structural+semantic validator first; rejects with
* the error list on failure. Auto-populates `descriptor.createdAt` on
* first save and preserves it on subsequent saves.
*
* Capacity: when adding a NEW descriptor would exceed MAX_ENTRIES, the
* oldest non-starred entry is evicted. If every entry is starred, the
* save is refused with a human-friendly reason.
*/
export function saveToCustomModifierLibrary(
descriptor: CustomModifierDescriptor,
): SaveResult {
const validation = validateCustomDescriptor(descriptor);
if (!validation.ok) {
return {
ok: false,
reason: "Descriptor failed validation",
errors: validation.errors,
};
}
const library = loadCustomModifierLibrary();
const existingIdx = library.findIndex((e) => e.id === descriptor.id);
const now = Date.now();
// Preserve the original createdAt if this is an update; populate it
// for first-time saves.
const existingCreatedAt =
existingIdx >= 0 ? library[existingIdx]?.descriptor.createdAt : undefined;
const createdAt =
descriptor.createdAt ?? existingCreatedAt ?? now;
const stampedDescriptor: CustomModifierDescriptor = {
...descriptor,
createdAt,
};
const entry: SavedCustomModifier = {
id: descriptor.id,
descriptor: stampedDescriptor,
starred: existingIdx >= 0 ? library[existingIdx]!.starred : false,
updatedAt: now,
};
if (existingIdx >= 0) {
const updated = library.slice();
updated[existingIdx] = entry;
writeLibrary(updated);
return { ok: true, entry };
}
if (library.length >= MAX_ENTRIES) {
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 custom modifiers). Unstar one to make room, or delete an entry.",
};
}
const oldest = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldest.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true, entry };
}
writeLibrary([...library, entry]);
return { ok: true, entry };
}
/** Remove a custom modifier by id. No-op if the id is unknown. Returns true if removed. */
export function removeFromCustomModifierLibrary(
id: CustomModifierId,
): boolean {
const library = loadCustomModifierLibrary();
const next = library.filter((e) => e.id !== id);
if (next.length === library.length) return false;
writeLibrary(next);
return true;
}
/** Toggle the starred flag on a saved custom modifier. */
export function setCustomModifierStarred(
id: CustomModifierId,
starred: boolean,
): void {
const library = loadCustomModifierLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated = library.slice();
updated[idx] = { ...library[idx]!, starred, updatedAt: Date.now() };
writeLibrary(updated);
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedCustomModifier(value: unknown): value is SavedCustomModifier {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
if (
typeof v["id"] !== "string" ||
typeof v["starred"] !== "boolean" ||
typeof v["updatedAt"] !== "number"
) {
return false;
}
// Validate the nested descriptor structurally via Zod.
const parseResult = safeParseCustomModifierDescriptor(v["descriptor"]);
if (!parseResult.success) return false;
// Discard the parse output — we keep the original raw shape so the
// entry round-trips identically (createdAt preserved, etc.).
void parseCustomModifierDescriptor;
return true;
}
// Exported for tests only.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };