feat(engine): modifier-profile library persistence (v1)
This commit is contained in:
parent
5f339d568f
commit
1402c7094b
2 changed files with 449 additions and 0 deletions
261
packages/chess/src/modifiers/library.test.ts
Normal file
261
packages/chess/src/modifiers/library.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
||||
import {
|
||||
loadLibrary,
|
||||
saveToLibrary,
|
||||
deleteFromLibrary,
|
||||
setStarred,
|
||||
duplicateEntry,
|
||||
makeId,
|
||||
__test__,
|
||||
type SavedModifierProfile,
|
||||
} from "./library.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
// happy-dom provides a localStorage object but its methods are
|
||||
// bound to a prototype that doesn't survive certain destructuring
|
||||
// patterns; we install a simple Map-backed shim unconditionally so
|
||||
// the tests have predictable behavior.
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// Clear between tests so each one starts fresh.
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const emptyProfile: ModifierProfile = {
|
||||
id: "test",
|
||||
name: "Test Profile",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
function seed(entry: Partial<SavedModifierProfile> = {}): SavedModifierProfile {
|
||||
return {
|
||||
id: makeId(),
|
||||
name: "Test",
|
||||
profile: emptyProfile,
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
...entry,
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadLibrary()", () => {
|
||||
it("returns [] when no key is set", () => {
|
||||
expect(loadLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when storage contains malformed JSON", () => {
|
||||
localStorage.setItem(__test__.STORAGE_KEY, "not json");
|
||||
expect(loadLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries that fail shape validation", () => {
|
||||
const validEntry: SavedModifierProfile = {
|
||||
id: "ok",
|
||||
name: "ok",
|
||||
profile: emptyProfile,
|
||||
starred: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
localStorage.setItem(
|
||||
__test__.STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
validEntry,
|
||||
{ not: "a valid entry" },
|
||||
]),
|
||||
);
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.id).toBe("ok");
|
||||
});
|
||||
|
||||
it("filters out entries with invalid nested profile", () => {
|
||||
localStorage.setItem(
|
||||
__test__.STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "bad-profile",
|
||||
name: "bad",
|
||||
profile: { version: 99, source: "unknown" },
|
||||
starred: false,
|
||||
updatedAt: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadLibrary()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveToLibrary()", () => {
|
||||
it("appends a new entry", () => {
|
||||
const result = saveToLibrary(seed({ name: "First" }));
|
||||
expect(result.ok).toBe(true);
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.name).toBe("First");
|
||||
});
|
||||
|
||||
it("updates in place when id matches", () => {
|
||||
const entry = seed({ name: "Original" });
|
||||
saveToLibrary(entry);
|
||||
saveToLibrary({ ...entry, name: "Renamed" });
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.name).toBe("Renamed");
|
||||
});
|
||||
|
||||
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
|
||||
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
||||
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
|
||||
}
|
||||
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
|
||||
|
||||
// Save one more — oldest (E0) should be evicted.
|
||||
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(__test__.MAX_ENTRIES);
|
||||
expect(library.map((e) => e.name)).not.toContain("E0");
|
||||
expect(library.some((e) => e.name === "newest")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses save when every entry is starred and library is full", () => {
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
||||
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
|
||||
}
|
||||
const result = saveToLibrary(seed({ name: "newest" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toMatch(/unstar/i);
|
||||
}
|
||||
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFromLibrary()", () => {
|
||||
it("removes the matching entry", () => {
|
||||
const entry = seed();
|
||||
saveToLibrary(entry);
|
||||
deleteFromLibrary(entry.id);
|
||||
expect(loadLibrary()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
saveToLibrary(seed());
|
||||
deleteFromLibrary("not-real");
|
||||
expect(loadLibrary()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setStarred()", () => {
|
||||
it("toggles starred and updates updatedAt", () => {
|
||||
const entry = seed({ starred: false, updatedAt: 100 });
|
||||
saveToLibrary(entry);
|
||||
const before = Date.now();
|
||||
setStarred(entry.id, true);
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library[0]?.starred).toBe(true);
|
||||
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
saveToLibrary(seed());
|
||||
setStarred("not-real", true);
|
||||
expect(loadLibrary()[0]?.starred).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateEntry()", () => {
|
||||
it("creates a copy with a new id and '(copy)' name suffix", () => {
|
||||
const entry = seed({ name: "Original" });
|
||||
saveToLibrary(entry);
|
||||
|
||||
const newId = duplicateEntry(entry.id);
|
||||
expect(newId).toBeDefined();
|
||||
expect(newId).not.toBe(entry.id);
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(2);
|
||||
const copy = library.find((e) => e.id === newId);
|
||||
expect(copy?.name).toBe("Original (copy)");
|
||||
expect(copy?.starred).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the profile on duplication", () => {
|
||||
const profileWithData: ModifierProfile = {
|
||||
id: "p1",
|
||||
name: "Buff Profile",
|
||||
description: "has modifiers",
|
||||
perType: [{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
const entry = seed({ name: "Source", profile: profileWithData });
|
||||
saveToLibrary(entry);
|
||||
|
||||
const newId = duplicateEntry(entry.id);
|
||||
const library = loadLibrary();
|
||||
const copy = library.find((e) => e.id === newId);
|
||||
expect(copy?.profile).toEqual(profileWithData);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown id", () => {
|
||||
expect(duplicateEntry("not-real")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeId()", () => {
|
||||
it("produces unique ids", () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) ids.add(makeId());
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
|
||||
it("falls back when crypto.randomUUID is unavailable", () => {
|
||||
const original = crypto.randomUUID;
|
||||
// @ts-expect-error — intentional override for test
|
||||
crypto.randomUUID = undefined;
|
||||
try {
|
||||
const id = makeId();
|
||||
expect(id).toMatch(/^layout-/);
|
||||
} finally {
|
||||
crypto.randomUUID = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Silence React testing-library warnings if this file runs in a mixed env.
|
||||
vi.mock("react", async () => await vi.importActual("react"));
|
||||
188
packages/chess/src/modifiers/library.ts
Normal file
188
packages/chess/src/modifiers/library.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Modifier-profile library — localStorage-backed store of user-authored
|
||||
* modifier profiles.
|
||||
*
|
||||
* Each entry:
|
||||
* - `id` — local-only UUID. Used to identify the entry for
|
||||
* update/delete/star operations.
|
||||
* - `name` — user-provided label, shown in the library drawer.
|
||||
* - `profile` — the full ModifierProfile.
|
||||
* - `starred` — true when the user has pinned this profile. Starred
|
||||
* entries are exempt from FIFO eviction and sort first.
|
||||
* - `updatedAt` — unix ms of last write. Drives display order for
|
||||
* non-starred entries (newest first) and FIFO eviction (oldest
|
||||
* non-starred entry is removed when capacity is hit).
|
||||
*
|
||||
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
|
||||
* entry is evicted. If every entry is starred, we refuse the save
|
||||
* and the caller surfaces a "library full — unstar something" error.
|
||||
*
|
||||
* Storage key is versioned (`houserules:modifier-profiles:v1`). A schema
|
||||
* bump would ship a new key + migration; v1 entries are kept on best-
|
||||
* effort and re-hydrated read-only if they can't be migrated.
|
||||
*/
|
||||
import { parseModifierProfile } from "./schema.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
const STORAGE_KEY = "houserules:modifier-profiles:v1";
|
||||
const MAX_ENTRIES = 20;
|
||||
|
||||
export interface SavedModifierProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly profile: ModifierProfile;
|
||||
readonly starred: boolean;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every saved modifier profile from storage. Returns an empty array on
|
||||
* empty/missing/corrupt storage — silently discarding unparseable
|
||||
* data is preferable to blocking the UI.
|
||||
*/
|
||||
export function loadLibrary(): SavedModifierProfile[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
// Shallow shape validation — anything that fails is dropped.
|
||||
return parsed.filter(isSavedModifierProfile);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full library array back to storage. */
|
||||
function writeLibrary(entries: SavedModifierProfile[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
/* quota exceeded / storage disabled — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new modifier profile or update an existing one (matched by `id`).
|
||||
*
|
||||
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
|
||||
* when the library is full of starred entries — caller surfaces a
|
||||
* message telling the user to unstar something.
|
||||
*/
|
||||
export function saveToLibrary(
|
||||
entry: SavedModifierProfile,
|
||||
): { ok: true } | { ok: false; reason: string } {
|
||||
const library = loadLibrary();
|
||||
const existingIdx = library.findIndex((e) => e.id === entry.id);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
// Update in place — no capacity check needed.
|
||||
library[existingIdx] = entry;
|
||||
writeLibrary(library);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// New entry — enforce capacity.
|
||||
if (library.length >= MAX_ENTRIES) {
|
||||
// Find the oldest non-starred entry and evict it.
|
||||
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 profiles). Unstar one to make room, or delete an entry.",
|
||||
};
|
||||
}
|
||||
const oldestNonStarred = evictable[0]!;
|
||||
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
|
||||
pruned.push(entry);
|
||||
writeLibrary(pruned);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
library.push(entry);
|
||||
writeLibrary(library);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Remove a modifier profile by id. No-op if the id is unknown. */
|
||||
export function deleteFromLibrary(id: string): void {
|
||||
const library = loadLibrary();
|
||||
writeLibrary(library.filter((e) => e.id !== id));
|
||||
}
|
||||
|
||||
/** Toggle the starred flag on a modifier profile. */
|
||||
export function setStarred(id: string, starred: boolean): void {
|
||||
const library = loadLibrary();
|
||||
const idx = library.findIndex((e) => e.id === id);
|
||||
if (idx < 0) return;
|
||||
const updated: SavedModifierProfile = {
|
||||
...library[idx]!,
|
||||
starred,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
library[idx] = updated;
|
||||
writeLibrary(library);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
|
||||
* appended to the name, and starred=false regardless of the
|
||||
* original's state. Returns the new entry's id so the caller can
|
||||
* select it.
|
||||
*/
|
||||
export function duplicateEntry(id: string): string | undefined {
|
||||
const library = loadLibrary();
|
||||
const entry = library.find((e) => e.id === id);
|
||||
if (entry === undefined) return undefined;
|
||||
|
||||
const newId = makeId();
|
||||
const copy: SavedModifierProfile = {
|
||||
id: newId,
|
||||
name: `${entry.name} (copy)`,
|
||||
profile: entry.profile,
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const result = saveToLibrary(copy);
|
||||
if (!result.ok) return undefined;
|
||||
return newId;
|
||||
}
|
||||
|
||||
/** Generate a local-only id for a library entry. */
|
||||
export function makeId(): string {
|
||||
// crypto.randomUUID is available in every browser we target (and
|
||||
// in Node 19+). Fall back to a Math.random-based id only on
|
||||
// ancient runtimes.
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `layout-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
function isSavedModifierProfile(value: unknown): value is SavedModifierProfile {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof v["id"] !== "string" ||
|
||||
typeof v["name"] !== "string" ||
|
||||
typeof v["starred"] !== "boolean" ||
|
||||
typeof v["updatedAt"] !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Validate the nested profile using the Zod schema.
|
||||
try {
|
||||
parseModifierProfile(v["profile"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests only. Prefer the high-level helpers above.
|
||||
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };
|
||||
Loading…
Add table
Add a link
Reference in a new issue