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:
parent
b35d758c57
commit
795207e8b4
2 changed files with 394 additions and 0 deletions
205
packages/chess/src/modifiers/custom/library.test.ts
Normal file
205
packages/chess/src/modifiers/custom/library.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
189
packages/chess/src/modifiers/custom/library.ts
Normal file
189
packages/chess/src/modifiers/custom/library.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue