Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions src/persist.js
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 45 additions & 19 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = []) {
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -822,5 +847,6 @@ function build() {
console.error(err);
return;
}
loadFromStorage();
build();
})();
Loading
Loading