diff --git a/architecture.md b/architecture.md index 01f12c5..e8344f8 100644 --- a/architecture.md +++ b/architecture.md @@ -112,6 +112,12 @@ Supporting pure helpers: em-dash for non-finite values). - **`markdown.js`** — a small dependency-free Markdown→HTML renderer (escapes HTML and sanitizes URLs) used to render the README in-page. +- **`persist.js`** — pure `serialize` / `deserialize` of the UI state plus a + `defaultState()` factory and a versioned `STORAGE_KEY`. The DOM layer reads + the last snapshot from `localStorage` on start and writes a fresh one after + every refresh, so a returning user sees the same party they left. Validation + drops any non-canonical race/class or out-of-range number, so a stale or + hand-edited blob can't poison the engine. ## Testing & CI diff --git a/package-lock.json b/package-lock.json index bdb5d3e..34b4b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eq-xp-calculator", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eq-xp-calculator", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/src/persist.js b/src/persist.js new file mode 100644 index 0000000..be15d4a --- /dev/null +++ b/src/persist.js @@ -0,0 +1,112 @@ +// Pure persistence helpers — serialize the UI state to a JSON-safe object and +// rehydrate one, dropping any field that fails validation so a stale or +// hand-edited snapshot can never inject a non-canonical race/class or an +// out-of-range number into the engine. The browser layer (ui.js) owns the +// actual localStorage I/O. + +import { isRace, isClass } from "./enums.js"; + +export const STORAGE_KEY = "eq-xp-calculator/v1"; + +const MAX_LEVEL = 60; +const MAX_MOB_LEVEL = 70; +const PARTY_SIZE = 6; + +const emptyMember = () => ({ race: "", className: "", level: null }); + +export function defaultState() { + return { + party: [ + { race: "Troll", className: "Shadow Knight", level: 1 }, + emptyMember(), + emptyMember(), + emptyMember(), + emptyMember(), + emptyMember(), + ], + enc: { + mobLevel: 1, + minutesPerKill: 6, + zoneName: "Innothule Swamp", + useManualZem: false, + manualZem: 75, + penaltiesOn: false, + }, + }; +} + +export function serialize(state) { + return { + party: state.party.map((m) => ({ + race: typeof m.race === "string" ? m.race : "", + className: typeof m.className === "string" ? m.className : "", + level: Number.isFinite(m.level) ? m.level : null, + })), + enc: { + mobLevel: state.enc.mobLevel, + minutesPerKill: state.enc.minutesPerKill, + zoneName: state.enc.zoneName, + useManualZem: !!state.enc.useManualZem, + manualZem: state.enc.manualZem, + penaltiesOn: !!state.enc.penaltiesOn, + }, + }; +} + +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +function sanitizeMember(raw) { + const m = emptyMember(); + if (!raw || typeof raw !== "object") return m; + if (typeof raw.race === "string" && isRace(raw.race)) m.race = raw.race; + if (typeof raw.className === "string" && isClass(raw.className)) { + m.className = raw.className; + } + if (typeof raw.level === "number" && Number.isFinite(raw.level)) { + const lv = Math.round(raw.level); + if (lv >= 1 && lv <= MAX_LEVEL) m.level = lv; + } + return m; +} + +export function deserialize(raw) { + const out = defaultState(); + if (!raw || typeof raw !== "object") return out; + + if (Array.isArray(raw.party)) { + const party = []; + for (let i = 0; i < PARTY_SIZE; i++) { + party.push(sanitizeMember(raw.party[i])); + } + out.party = party; + } + + const renc = raw.enc; + if (renc && typeof renc === "object") { + if (typeof renc.mobLevel === "number" && Number.isFinite(renc.mobLevel)) { + out.enc.mobLevel = clamp(Math.round(renc.mobLevel), 1, MAX_MOB_LEVEL); + } + if ( + typeof renc.minutesPerKill === "number" && + Number.isFinite(renc.minutesPerKill) + ) { + out.enc.minutesPerKill = clamp(renc.minutesPerKill, 0.1, 60); + } + if (typeof renc.zoneName === "string" && renc.zoneName.length > 0) { + out.enc.zoneName = renc.zoneName; + } + if (typeof renc.useManualZem === "boolean") { + out.enc.useManualZem = renc.useManualZem; + } + if (typeof renc.manualZem === "number" && Number.isFinite(renc.manualZem)) { + out.enc.manualZem = clamp(Math.round(renc.manualZem), 1, 500); + } + if (typeof renc.penaltiesOn === "boolean") { + out.enc.penaltiesOn = renc.penaltiesOn; + } + } + + return out; +} diff --git a/src/ui.js b/src/ui.js index 06472ed..e292316 100644 --- a/src/ui.js +++ b/src/ui.js @@ -18,6 +18,12 @@ import { import { fmtNum, fmtMins } from "./format.js"; import { loadZems, continentGroups, zemForZone, zemRange } from "./data.js"; import { renderMarkdown } from "./markdown.js"; +import { + STORAGE_KEY, + defaultState, + serialize, + deserialize, +} from "./persist.js"; const MAX_LEVEL = 60; const MAX_MOB_LEVEL = 70; @@ -28,24 +34,34 @@ const emptyMember = () => ({ race: "", className: "", level: null }); const isFilled = (c) => Boolean(c.race) && Boolean(c.className) && Number.isFinite(c.level); -const state = { - party: [ - { race: "Troll", className: "Shadow Knight", level: 1 }, - emptyMember(), - emptyMember(), - emptyMember(), - emptyMember(), - emptyMember(), - ], - enc: { - mobLevel: 1, - minutesPerKill: 6, - zoneName: "Innothule Swamp", - useManualZem: false, - manualZem: 75, - penaltiesOn: false, - }, -}; +const state = defaultState(); + +// Pull the last-saved snapshot in before the DOM is built so the form +// renders pre-filled. Invalid or stale snapshots fall back to defaults +// inside deserialize(), so a corrupted blob can't break the page. +function loadFromStorage() { + let raw = null; + try { + const text = localStorage.getItem(STORAGE_KEY); + if (text) raw = JSON.parse(text); + } catch { + // localStorage may be disabled (private mode) or the value unparseable — + // either way, fall through to defaults. + return; + } + const loaded = deserialize(raw); + state.party.length = 0; + state.party.push(...loaded.party); + Object.assign(state.enc, loaded.enc); +} + +function saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(serialize(state))); + } catch { + // Quota exceeded or storage disabled — persistence is best-effort. + } +} // ── tiny DOM helper ────────────────────────────────────── function el(tag, props = {}, children = []) { @@ -226,6 +242,8 @@ function refresh() { ? "×" + groupBonus(Math.min(activeN, 6), state.enc.penaltiesOn).toFixed(2) : "—"; refs.constants.party.textContent = result ? fmtNum(result.total) : "—"; + + saveToStorage(); } // ── builders ───────────────────────────────────────────── @@ -586,7 +604,14 @@ function buildEncounter() { }, ); const toggle = el("div", { class: "toggle-row" }, [ - el("span", { class: "enc-label" }, "class XP penalties"), + el("span", { class: "enc-label" }, [ + "class XP penalties ", + el( + "span", + { class: "zem-rel", style: "margin:0" }, + "(Jan 14, 2001 patch)", + ), + ]), penCb, ]); card.appendChild(toggle); @@ -822,5 +847,6 @@ function build() { console.error(err); return; } + loadFromStorage(); build(); })(); diff --git a/test/persist.test.js b/test/persist.test.js new file mode 100644 index 0000000..6cef583 --- /dev/null +++ b/test/persist.test.js @@ -0,0 +1,185 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + STORAGE_KEY, + defaultState, + serialize, + deserialize, +} from "../src/persist.js"; + +// The persistence module is the pure half of localStorage support: it turns +// the UI state into a JSON-safe snapshot and rehydrates one, dropping any +// field that fails validation so a stale or hand-edited snapshot can never +// inject a non-canonical race/class or an out-of-range number into the +// engine. The browser layer (ui.js) owns the actual localStorage I/O. + +test("STORAGE_KEY is a stable, versioned string", () => { + assert.equal(typeof STORAGE_KEY, "string"); + assert.ok(STORAGE_KEY.length > 0); + assert.ok(/v\d+/.test(STORAGE_KEY), "key should be versioned"); +}); + +test("defaultState() returns the canonical starting shape", () => { + const d = defaultState(); + assert.equal(d.party.length, 6); + assert.deepEqual(d.party[0], { + race: "Troll", + className: "Shadow Knight", + level: 1, + }); + for (let i = 1; i < 6; i++) { + assert.deepEqual(d.party[i], { race: "", className: "", level: null }); + } + assert.deepEqual(d.enc, { + mobLevel: 1, + minutesPerKill: 6, + zoneName: "Innothule Swamp", + useManualZem: false, + manualZem: 75, + penaltiesOn: false, + }); +}); + +test("defaultState() returns a fresh object each call", () => { + const a = defaultState(); + const b = defaultState(); + assert.notStrictEqual(a, b); + assert.notStrictEqual(a.party, b.party); + assert.notStrictEqual(a.enc, b.enc); +}); + +test("serialize → deserialize round-trips a fully-populated state", () => { + const state = { + party: [ + { race: "Iksar", className: "Monk", level: 42 }, + { race: "Halfling", className: "Druid", level: 17 }, + { race: "", className: "", level: null }, + { race: "", className: "", level: null }, + { race: "", className: "", level: null }, + { race: "", className: "", level: null }, + ], + enc: { + mobLevel: 35, + minutesPerKill: 4.5, + zoneName: "Lower Guk", + useManualZem: true, + manualZem: 120, + penaltiesOn: true, + }, + }; + const round = deserialize(serialize(state)); + assert.deepEqual(round, state); +}); + +test("deserialize(null|undefined|garbage) returns defaultState()", () => { + assert.deepEqual(deserialize(null), defaultState()); + assert.deepEqual(deserialize(undefined), defaultState()); + assert.deepEqual(deserialize(42), defaultState()); + assert.deepEqual(deserialize("nope"), defaultState()); +}); + +test("deserialize drops non-canonical race/class to empty fields", () => { + const raw = { + party: [ + { race: "Vah Shir", className: "Beastlord", level: 50 }, // out-of-scope + { race: "troll", className: "ShadowKnight", level: 50 }, // wrong spelling + { race: "Troll", className: "Shadow Knight", level: 50 }, // canonical + ], + }; + const out = deserialize(raw); + assert.deepEqual(out.party[0], { race: "", className: "", level: 50 }); + assert.deepEqual(out.party[1], { race: "", className: "", level: 50 }); + assert.deepEqual(out.party[2], { + race: "Troll", + className: "Shadow Knight", + level: 50, + }); +}); + +test("deserialize clamps and rounds member level to 1..60", () => { + const raw = { + party: [ + { race: "Troll", className: "Warrior", level: 0 }, + { race: "Troll", className: "Warrior", level: 99 }, + { race: "Troll", className: "Warrior", level: 12.7 }, + { race: "Troll", className: "Warrior", level: "30" }, + ], + }; + const out = deserialize(raw); + assert.equal(out.party[0].level, null); // 0 is out of range → drop + assert.equal(out.party[1].level, null); // 99 is out of range → drop + assert.equal(out.party[2].level, 13); // rounded + assert.equal(out.party[3].level, null); // not a finite number → drop +}); + +test("deserialize pads short party arrays and truncates long ones to 6", () => { + const short = deserialize({ + party: [{ race: "Ogre", className: "Shaman", level: 5 }], + }); + assert.equal(short.party.length, 6); + assert.equal(short.party[0].race, "Ogre"); + for (let i = 1; i < 6; i++) { + assert.deepEqual(short.party[i], { race: "", className: "", level: null }); + } + + const long = deserialize({ + party: new Array(20).fill({ + race: "Human", + className: "Cleric", + level: 10, + }), + }); + assert.equal(long.party.length, 6); +}); + +test("deserialize clamps encounter numeric ranges", () => { + const out = deserialize({ + enc: { + mobLevel: 999, + minutesPerKill: 0, + manualZem: -50, + useManualZem: true, + penaltiesOn: false, + zoneName: "Lower Guk", + }, + }); + assert.equal(out.enc.mobLevel, 70); + assert.equal(out.enc.minutesPerKill, 0.1); + assert.equal(out.enc.manualZem, 1); + + const high = deserialize({ + enc: { mobLevel: -5, minutesPerKill: 600, manualZem: 99999 }, + }); + assert.equal(high.enc.mobLevel, 1); + assert.equal(high.enc.minutesPerKill, 60); + assert.equal(high.enc.manualZem, 500); +}); + +test("deserialize ignores wrong-typed encounter fields", () => { + const out = deserialize({ + enc: { + mobLevel: "fifteen", + minutesPerKill: null, + zoneName: 42, + useManualZem: "yes", + manualZem: {}, + penaltiesOn: 1, + }, + }); + // all invalid → falls back to defaults + assert.deepEqual(out.enc, defaultState().enc); +}); + +test("deserialize accepts a non-empty zoneName string even if unknown", () => { + // The pure module can't know which zones are in zems.json; UI handles + // unknown zones gracefully. Just require a non-empty string. + const out = deserialize({ enc: { zoneName: "Plane of Fire" } }); + assert.equal(out.enc.zoneName, "Plane of Fire"); +}); + +test("serialize never mutates the input", () => { + const state = defaultState(); + const snapshot = JSON.parse(JSON.stringify(state)); + serialize(state); + assert.deepEqual(state, snapshot); +});