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