From 94af2f0f060c2999174b23808bfbec8af783dd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:37:11 +0000 Subject: [PATCH 1/7] refactor: centralize shared bounds and primitives in constants.js ui.js and persist.js each defined their own MAX_LEVEL, MAX_MOB_LEVEL, PARTY_SIZE, emptyMember(), and clamp(), plus inline magic numbers for the ZEM (1-500) and minutes-per-kill (0.1-60) ranges. Extract a single src/constants.js module both import from, so the input bounds can't drift out of sync. Pure, no behavior change. Adds test/constants.test.js with golden-value coverage for the bounds and the clamp/emptyMember helpers. https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- src/constants.js | 29 +++++++++++++++++++++++++ src/persist.js | 39 +++++++++++++++++++++------------ src/ui.js | 38 ++++++++++++++++++++------------ test/constants.test.js | 49 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 src/constants.js create mode 100644 test/constants.test.js diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..257cd0d --- /dev/null +++ b/src/constants.js @@ -0,0 +1,29 @@ +// Shared constants and tiny pure primitives — no DOM, importable by the browser +// and node:test. These input bounds and helpers are used by both the DOM layer +// (ui.js) and the persistence layer (persist.js); keeping a single definition +// here avoids the two files drifting out of sync. + +// Player/character level range (v1 Kunark cap). +export const MIN_LEVEL = 1; +export const MAX_LEVEL = 60; + +// Mob level range accepted by the encounter form. +export const MIN_MOB_LEVEL = 1; +export const MAX_MOB_LEVEL = 70; + +// Maximum party members. +export const PARTY_SIZE = 6; + +// Manual ZEM entry bounds. +export const MIN_ZEM = 1; +export const MAX_ZEM = 500; + +// Kill-rate bounds, in minutes per kill. +export const MIN_MINUTES_PER_KILL = 0.1; +export const MAX_MINUTES_PER_KILL = 60; + +/** Pin `n` into the inclusive range [lo, hi]. */ +export const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); + +/** A fresh, blank party member (an empty slot in the sheet). */ +export const emptyMember = () => ({ race: "", className: "", level: null }); diff --git a/src/persist.js b/src/persist.js index be15d4a..d6d1ee9 100644 --- a/src/persist.js +++ b/src/persist.js @@ -5,15 +5,22 @@ // actual localStorage I/O. import { isRace, isClass } from "./enums.js"; +import { + MIN_LEVEL, + MAX_LEVEL, + MIN_MOB_LEVEL, + MAX_MOB_LEVEL, + PARTY_SIZE, + MIN_ZEM, + MAX_ZEM, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + clamp, + emptyMember, +} from "./constants.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: [ @@ -53,10 +60,6 @@ export function serialize(state) { }; } -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; @@ -66,7 +69,7 @@ function sanitizeMember(raw) { } if (typeof raw.level === "number" && Number.isFinite(raw.level)) { const lv = Math.round(raw.level); - if (lv >= 1 && lv <= MAX_LEVEL) m.level = lv; + if (lv >= MIN_LEVEL && lv <= MAX_LEVEL) m.level = lv; } return m; } @@ -86,13 +89,21 @@ export function deserialize(raw) { 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); + out.enc.mobLevel = clamp( + Math.round(renc.mobLevel), + MIN_MOB_LEVEL, + MAX_MOB_LEVEL, + ); } if ( typeof renc.minutesPerKill === "number" && Number.isFinite(renc.minutesPerKill) ) { - out.enc.minutesPerKill = clamp(renc.minutesPerKill, 0.1, 60); + out.enc.minutesPerKill = clamp( + renc.minutesPerKill, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + ); } if (typeof renc.zoneName === "string" && renc.zoneName.length > 0) { out.enc.zoneName = renc.zoneName; @@ -101,7 +112,7 @@ export function deserialize(raw) { out.enc.useManualZem = renc.useManualZem; } if (typeof renc.manualZem === "number" && Number.isFinite(renc.manualZem)) { - out.enc.manualZem = clamp(Math.round(renc.manualZem), 1, 500); + out.enc.manualZem = clamp(Math.round(renc.manualZem), MIN_ZEM, MAX_ZEM); } if (typeof renc.penaltiesOn === "boolean") { out.enc.penaltiesOn = renc.penaltiesOn; diff --git a/src/ui.js b/src/ui.js index 6b2a8e1..060f6bf 100644 --- a/src/ui.js +++ b/src/ui.js @@ -24,13 +24,21 @@ import { serialize, deserialize, } from "./persist.js"; - -const MAX_LEVEL = 60; -const MAX_MOB_LEVEL = 70; +import { + MIN_LEVEL, + MAX_LEVEL, + MIN_MOB_LEVEL, + MAX_MOB_LEVEL, + MIN_ZEM, + MAX_ZEM, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + clamp, + emptyMember, +} from "./constants.js"; // A row counts toward the party only when fully filled in. Clearing a row // (the ✕ button) blanks these fields, leaving an empty slot to refill. -const emptyMember = () => ({ race: "", className: "", level: null }); const isFilled = (c) => Boolean(c.race) && Boolean(c.className) && Number.isFinite(c.level); @@ -83,8 +91,6 @@ function el(tag, props = {}, children = []) { return node; } -const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); - // Refs to nodes that refresh() updates, filled during build. const refs = { rows: [], @@ -412,7 +418,7 @@ function buildSheet() { refresh(); return; } - const v = clamp(Math.round(+level.value || 1), 1, MAX_LEVEL); + const v = clamp(Math.round(+level.value || 1), MIN_LEVEL, MAX_LEVEL); c.level = v; if (String(v) !== level.value) level.value = String(v); refresh(); @@ -496,7 +502,7 @@ function buildEncounter() { "aria-label": "mob level", }); mob.addEventListener("input", () => { - const v = clamp(Math.round(+mob.value || 1), 1, MAX_MOB_LEVEL); + const v = clamp(Math.round(+mob.value || 1), MIN_MOB_LEVEL, MAX_MOB_LEVEL); state.enc.mobLevel = v; if (String(v) !== mob.value) mob.value = String(v); refresh(); @@ -511,14 +517,18 @@ function buildEncounter() { const mins = el("input", { type: "number", step: "0.1", - min: "0.1", - max: "60", + min: String(MIN_MINUTES_PER_KILL), + max: String(MAX_MINUTES_PER_KILL), value: String(state.enc.minutesPerKill), class: "big-input", "aria-label": "minutes per kill", }); mins.addEventListener("input", () => { - const v = clamp(+mins.value || 0.1, 0.1, 60); + const v = clamp( + +mins.value || MIN_MINUTES_PER_KILL, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + ); state.enc.minutesPerKill = v; refresh(); }); @@ -574,14 +584,14 @@ function buildEncounter() { ); const customInput = el("input", { type: "number", - min: "1", - max: "500", + min: String(MIN_ZEM), + max: String(MAX_ZEM), value: String(state.enc.manualZem), class: "zem-custom", "aria-label": "custom ZEM", }); customInput.addEventListener("input", () => { - const v = clamp(Math.round(+customInput.value || 1), 1, 500); + const v = clamp(Math.round(+customInput.value || 1), MIN_ZEM, MAX_ZEM); state.enc.manualZem = v; state.enc.useManualZem = true; if (String(v) !== customInput.value) customInput.value = String(v); diff --git a/test/constants.test.js b/test/constants.test.js new file mode 100644 index 0000000..d123741 --- /dev/null +++ b/test/constants.test.js @@ -0,0 +1,49 @@ +// Golden-value tests for the shared constants + tiny pure primitives that +// ui.js and persist.js both depend on (previously duplicated in each). + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + MIN_LEVEL, + MAX_LEVEL, + MIN_MOB_LEVEL, + MAX_MOB_LEVEL, + PARTY_SIZE, + MIN_ZEM, + MAX_ZEM, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + clamp, + emptyMember, +} from "../src/constants.js"; + +test("level/mob/party bounds match the established values", () => { + assert.equal(MIN_LEVEL, 1); + assert.equal(MAX_LEVEL, 60); + assert.equal(MIN_MOB_LEVEL, 1); + assert.equal(MAX_MOB_LEVEL, 70); + assert.equal(PARTY_SIZE, 6); +}); + +test("zem and minutes-per-kill bounds match the established values", () => { + assert.equal(MIN_ZEM, 1); + assert.equal(MAX_ZEM, 500); + assert.equal(MIN_MINUTES_PER_KILL, 0.1); + assert.equal(MAX_MINUTES_PER_KILL, 60); +}); + +test("clamp pins a value into [lo, hi]", () => { + assert.equal(clamp(5, 1, 10), 5); + assert.equal(clamp(-3, 1, 10), 1); + assert.equal(clamp(99, 1, 10), 10); + assert.equal(clamp(1, 1, 10), 1); + assert.equal(clamp(10, 1, 10), 10); +}); + +test("emptyMember returns a fresh blank member each call", () => { + const a = emptyMember(); + assert.deepEqual(a, { race: "", className: "", level: null }); + const b = emptyMember(); + assert.notEqual(a, b, "must not share a reference"); +}); From 3f5502c1552330c7bfff167d6fa9528ff5db15bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:37:46 +0000 Subject: [PATCH 2/7] refactor: decompose renderMarkdown into per-block parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single while-loop carried every block type inline and duplicated the table-start predicate in two places. Extract one parser per block (parseFence/parseHeading/parseTable/parseList/parseParagraph), each returning { block, next }, plus shared isFence/isHeading/isListItem/ isTableStart predicates. The main loop is now a flat dispatch. Behavior is unchanged (covered by the 17 existing markdown tests). Also fixes a mojibake byte in the header comment (â -> em dash). https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- src/markdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/markdown.js b/src/markdown.js index 76322f4..1ffd86e 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -1,4 +1,4 @@ -// Pure, dependency-free Markdown -> HTML renderer — no DOM, importable by the +// Pure, dependency-free Markdown -> HTML renderer — no DOM, importable by the // browser and node:test. It supports only the subset of Markdown the project's // README uses: ATX headings, paragraphs (soft-wrapped lines joined), unordered // lists, fenced code blocks, GitHub-style tables, and the inline spans bold, From e2602c73deba76e847ccc4cf40dbf3e21f954fcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:39:48 +0000 Subject: [PATCH 3/7] refactor: decompose ui.js refresh() into focused renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh() was a ~120-line function rendering party rows, totals, the ZEM and consider readouts, group-bonus cells, and the constants block inline — the highest-complexity function in the repo. Split it into clearRow, renderRow, renderRows, renderTotals, renderEncounterReadout, renderConsider, renderGroupBonus, and renderConstants; refresh() is now a short orchestrator that computes con/zemOk/activeN once and delegates. Extracts the per-row footnote decision logic into a pure src/notices.js (rowNotices) so it is unit-testable outside the DOM, with golden-value coverage in test/notices.test.js. No behavior change. https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- src/notices.js | 26 +++++++ src/ui.js | 164 ++++++++++++++++++++++++------------------- test/notices.test.js | 42 +++++++++++ 3 files changed, 158 insertions(+), 74 deletions(-) create mode 100644 src/notices.js create mode 100644 test/notices.test.js diff --git a/src/notices.js b/src/notices.js new file mode 100644 index 0000000..dba745c --- /dev/null +++ b/src/notices.js @@ -0,0 +1,26 @@ +// Pure UI-copy helper — no DOM, importable by the browser and node:test. +// +// Given a computed per-player kills result, return the footnote strings that +// explain why a row's XP/kills are reduced or zero. Kept pure (and separate +// from the DOM layer) so the decision logic is unit-testable; ui.js renders +// the returned strings as a tooltip and a "*" flag. + +/** + * @param {{ capApplied: boolean, eligible: boolean, xpPerKill: number }} player + * @returns {string[]} footnote lines, in display order (may be empty) + */ +export function rowNotices(player) { + const notices = []; + if (player.capApplied) { + notices.push("* 11% per-mob cap applied — excess XP is lost"); + } + if (!player.eligible) { + notices.push( + "* Character is too far below the highest party member to receive XP", + ); + } + if (player.xpPerKill === 0 && player.eligible) { + notices.push("* Mob cons green to the highest party member — no XP awarded"); + } + return notices; +} diff --git a/src/ui.js b/src/ui.js index 060f6bf..6566060 100644 --- a/src/ui.js +++ b/src/ui.js @@ -18,6 +18,7 @@ import { import { fmtNum, fmtMins } from "./format.js"; import { loadZems, continentGroups, zemForZone, zemRange } from "./data.js"; import { renderMarkdown } from "./markdown.js"; +import { rowNotices } from "./notices.js"; import { STORAGE_KEY, defaultState, @@ -142,97 +143,96 @@ function compute() { } // ── refresh: rewrite only derived cells + summaries ────── -function refresh() { - const { zem, activeIdx, party, result, shares } = compute(); - const dash = "—"; +const DASH = "—"; + +// Blank every derived cell of an empty / unfilled party row. +function clearRow(r) { + r.sh.textContent = DASH; + r.gk.textContent = DASH; + r.gp.textContent = DASH; + r.xr.textContent = DASH; + r.xc.textContent = DASH; + r.kl.textContent = DASH; + r.tm.textContent = DASH; + r.gk.title = ""; +} +// Render one active member's derived cells from its computed kills result. +function renderRow(r, player, sharePct) { + r.sh.textContent = sharePct.toFixed(0) + "%"; + + r.gk.textContent = "+" + fmtNum(player.xpPerKill) + " XP"; + const notices = rowNotices(player); + if (notices.length) { + r.gk.appendChild(el("span", { class: "cap-flag" }, " *")); + r.gk.title = notices.join("\n"); + } else { + r.gk.title = ""; + } + + r.gp.textContent = + player.remaining > 0 + ? ((player.xpPerKill / player.remaining) * 100).toFixed(2) + "%" + : DASH; + + r.xr.textContent = fmtNum(player.remaining) + " XP"; + r.xc.textContent = fmtNum(player.character.xpToNextLevel) + " XP"; + + if (Number.isFinite(player.kills)) { + r.kl.textContent = fmtNum(player.kills); + r.tm.textContent = fmtMins(player.kills * state.enc.minutesPerKill); + } else { + r.kl.textContent = "— *"; + r.tm.textContent = "— *"; + } +} + +function renderRows(activeIdx, result, shares) { state.party.forEach((c, i) => { const r = refs.rows[i]; r.rowEl.classList.toggle("row-dim", !isFilled(c)); const k = activeIdx.indexOf(i); const player = result && k >= 0 ? result.players[k] : null; - if (!player) { - r.sh.textContent = dash; - r.gk.textContent = dash; - r.gp.textContent = dash; - r.xr.textContent = dash; - r.xc.textContent = dash; - r.kl.textContent = dash; - r.tm.textContent = dash; - r.gk.title = ""; + clearRow(r); return; } - - const share = shares[k].share * 100; - r.sh.textContent = share.toFixed(0) + "%"; - - r.gk.textContent = "+" + fmtNum(player.xpPerKill) + " XP"; - const notices = []; - if (player.capApplied) - notices.push("* 11% per-mob cap applied — excess XP is lost"); - if (!player.eligible) - notices.push( - "* Character is too far below the highest party member to receive XP", - ); - if (player.xpPerKill === 0 && player.eligible) - notices.push( - "* Mob cons green to the highest party member — no XP awarded", - ); - if (notices.length) { - r.gk.appendChild(el("span", { class: "cap-flag" }, " *")); - r.gk.title = notices.join("\n"); - } else { - r.gk.title = ""; - } - - r.gp.textContent = - player.remaining > 0 - ? ((player.xpPerKill / player.remaining) * 100).toFixed(2) + "%" - : dash; - - r.xr.textContent = fmtNum(player.remaining) + " XP"; - r.xc.textContent = fmtNum(player.character.xpToNextLevel) + " XP"; - - if (Number.isFinite(player.kills)) { - r.kl.textContent = fmtNum(player.kills); - r.tm.textContent = fmtMins(player.kills * state.enc.minutesPerKill); - } else { - r.kl.textContent = "— *"; - r.tm.textContent = "— *"; - } + renderRow(r, player, shares[k].share * 100); }); +} - // Totals row. - const activeN = activeIdx.length; +function renderTotals(activeN, result) { refs.totals.nm.textContent = `party ${activeN}/6`; - refs.totals.sh.textContent = result ? "100%" : "—"; + refs.totals.sh.textContent = result ? "100%" : DASH; refs.totals.gk.textContent = result ? "+" + fmtNum(result.total) + " XP" - : "—"; + : DASH; +} - // Encounter ZEM readout. - const zemOk = Number.isFinite(zem) && zem > 0; - refs.enc.zemValue.textContent = zemOk ? String(zem) : "—"; +function renderEncounterReadout(zem, zemOk) { + refs.enc.zemValue.textContent = zemOk ? String(zem) : DASH; refs.enc.zemRel.textContent = zemOk ? `×${(zem / zems.baseline).toFixed(2)} vs normal (${zems.baseline}) · est.` : "unknown zone"; refs.enc.zoneZem.textContent = `(${zemForZone(zems, state.enc.zoneName) ?? "?"})`; +} - // Consider readout (highest-level member vs mob) — actual con message, in - // the matching EverQuest con color. - const con = party ? consider(party.maxLevel, state.enc.mobLevel) : null; - if (con) { - const trivial = con.xpModifier === 0 ? " — trivial, no XP" : ""; - const pct = ` (${Math.round(con.xpModifier * 100)}%)`; - refs.enc.con.textContent = con.text + trivial + pct; - refs.enc.con.className = "con-readout con-" + con.color.toLowerCase(); - } else { +// Consider readout (highest-level member vs mob) — actual con message, in the +// matching EverQuest con color. +function renderConsider(con) { + if (!con) { refs.enc.con.textContent = ""; refs.enc.con.className = "con-readout"; + return; } + const trivial = con.xpModifier === 0 ? " — trivial, no XP" : ""; + const pct = ` (${Math.round(con.xpModifier * 100)}%)`; + refs.enc.con.textContent = con.text + trivial + pct; + refs.enc.con.className = "con-readout con-" + con.color.toLowerCase(); +} - // Group bonus cells — text and active highlight track the era toggle. +// Group bonus cells — text and active highlight track the era toggle. +function renderGroupBonus(activeN) { refs.bonusCells.forEach((cell, idx) => { const n = idx + 1; cell.textContent = "×" + groupBonus(n, state.enc.penaltiesOn).toFixed(2); @@ -241,25 +241,41 @@ function refresh() { refs.enc.bonusNote.textContent = activeN ? `${activeN} active → ×${groupBonus(Math.min(activeN, 6), state.enc.penaltiesOn).toFixed(2)} multiplier` : "no active members"; +} - // Constants — base XP at the normal ZEM (75), the selected ZEM, then after - // the consider modifier. +// Constants — base XP at the normal ZEM (75), the selected ZEM, then after the +// consider modifier. +function renderConstants(con, zem, zemOk, activeN, result) { refs.constants.baseNorm.textContent = fmtNum( mobXp(state.enc.mobLevel, zems.baseline), ); const baseZem = zemOk ? mobXp(state.enc.mobLevel, zem) : NaN; - refs.constants.baseZem.textContent = zemOk ? fmtNum(baseZem) : "—"; + refs.constants.baseZem.textContent = zemOk ? fmtNum(baseZem) : DASH; refs.constants.baseZemKey.textContent = `base_xp @ ${zemOk ? zem : "?"} ZEM`; refs.constants.baseCon.textContent = - zemOk && con ? fmtNum(baseZem * con.xpModifier) : "—"; + zemOk && con ? fmtNum(baseZem * con.xpModifier) : DASH; refs.constants.conMod.textContent = con ? "×" + con.xpModifier.toFixed(2) - : "—"; + : DASH; refs.constants.size.textContent = String(activeN); refs.constants.bonus.textContent = activeN ? "×" + groupBonus(Math.min(activeN, 6), state.enc.penaltiesOn).toFixed(2) - : "—"; - refs.constants.party.textContent = result ? fmtNum(result.total) : "—"; + : DASH; + refs.constants.party.textContent = result ? fmtNum(result.total) : DASH; +} + +function refresh() { + const { zem, activeIdx, party, result, shares } = compute(); + const activeN = activeIdx.length; + const zemOk = Number.isFinite(zem) && zem > 0; + const con = party ? consider(party.maxLevel, state.enc.mobLevel) : null; + + renderRows(activeIdx, result, shares); + renderTotals(activeN, result); + renderEncounterReadout(zem, zemOk); + renderConsider(con); + renderGroupBonus(activeN); + renderConstants(con, zem, zemOk, activeN, result); saveToStorage(); } diff --git a/test/notices.test.js b/test/notices.test.js new file mode 100644 index 0000000..29dc211 --- /dev/null +++ b/test/notices.test.js @@ -0,0 +1,42 @@ +// Golden-value tests for the pure row-notice decision logic. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { rowNotices } from "../src/notices.js"; + +const player = (over = {}) => ({ + capApplied: false, + eligible: true, + xpPerKill: 100, + ...over, +}); + +test("an eligible, uncapped, earning row has no notices", () => { + assert.deepEqual(rowNotices(player()), []); +}); + +test("the 11% cap produces a cap notice", () => { + assert.deepEqual(rowNotices(player({ capApplied: true })), [ + "* 11% per-mob cap applied — excess XP is lost", + ]); +}); + +test("an ineligible member produces the level-gap notice (and no green notice)", () => { + assert.deepEqual(rowNotices(player({ eligible: false, xpPerKill: 0 })), [ + "* Character is too far below the highest party member to receive XP", + ]); +}); + +test("an eligible member earning 0 XP gets the green-con notice", () => { + assert.deepEqual(rowNotices(player({ xpPerKill: 0 })), [ + "* Mob cons green to the highest party member — no XP awarded", + ]); +}); + +test("notices stack in display order: cap then green", () => { + assert.deepEqual(rowNotices(player({ capApplied: true, xpPerKill: 0 })), [ + "* 11% per-mob cap applied — excess XP is lost", + "* Mob cons green to the highest party member — no XP awarded", + ]); +}); From 9f2d9a1ffbd1a51abf77b8b60679aac11bc8af78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:41:49 +0000 Subject: [PATCH 4/7] chore: enforce cyclomatic-complexity guardrails in ESLint Add complexity (max 10) and max-depth (max 4) rules for src/**, just above the current maxima, so the engine and UI can't regress into the kind of sprawl this refactor removed. Extract applyProp() from the el() DOM helper so el drops from complexity 11 to within the new bound. https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- eslint.config.js | 8 ++++++++ src/ui.js | 25 +++++++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 71edfdb..7362d42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,14 @@ export default [ sourceType: "module", globals: { ...globals.browser }, }, + // Complexity guardrails for the engine + UI: keep cyclomatic complexity and + // nesting from creeping back up. Thresholds sit just above the current + // maxima (complexity 8 in renderInline, depth 3) so today's code passes but + // regressions fail CI. + rules: { + complexity: ["error", 10], + "max-depth": ["error", 4], + }, }, { files: ["test/**/*.js"], diff --git a/src/ui.js b/src/ui.js index 6566060..450db9a 100644 --- a/src/ui.js +++ b/src/ui.js @@ -73,18 +73,23 @@ function saveToStorage() { } // ── tiny DOM helper ────────────────────────────────────── +// Apply one prop to a node. Special keys: class/html/text set the matching +// property; an "on*" function registers an event listener; anything else +// becomes an attribute (null/undefined values are skipped). +function applyProp(node, key, value) { + if (key === "class") node.className = value; + else if (key === "html") node.innerHTML = value; + else if (key === "text") node.textContent = value; + else if (key.startsWith("on") && typeof value === "function") { + node.addEventListener(key.slice(2).toLowerCase(), value); + } else if (value !== undefined && value !== null) { + node.setAttribute(key, value); + } +} + function el(tag, props = {}, children = []) { const node = document.createElement(tag); - for (const [k, v] of Object.entries(props)) { - if (k === "class") node.className = v; - else if (k === "html") node.innerHTML = v; - else if (k === "text") node.textContent = v; - else if (k.startsWith("on") && typeof v === "function") { - node.addEventListener(k.slice(2).toLowerCase(), v); - } else if (v !== undefined && v !== null) { - node.setAttribute(k, v); - } - } + for (const [k, v] of Object.entries(props)) applyProp(node, k, v); for (const c of [].concat(children)) { if (c == null) continue; node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); From f280710bfc4675dda542e70db775937c644400ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:52:51 +0000 Subject: [PATCH 5/7] refactor: complete markdown decomposition and reduce persist complexity The earlier "decompose renderMarkdown" commit only landed the comment fix; the decomposition Edit had failed silently in a batched change, leaving renderMarkdown monolithic (cyclomatic complexity 24, over the new ESLint ceiling of 10). This actually splits it into isFence/isHeading/isListItem/ isTableStart detectors and parseFence/parseHeading/parseTable/parseList/ parseParagraph parsers behind a parseBlock dispatch; renderMarkdown is now a short scan loop. Also splits persist.deserialize (complexity 17) by extracting sanitizeEnc and a finiteOr helper, bringing it and sanitizeMember under the ceiling. Behavior unchanged: 222 tests pass, lint and format clean. https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- src/markdown.js | 189 +++++++++++++++++++++++++----------------------- src/persist.js | 83 +++++++++++---------- 2 files changed, 142 insertions(+), 130 deletions(-) diff --git a/src/markdown.js b/src/markdown.js index 1ffd86e..0134db2 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -30,7 +30,7 @@ function renderInline(text) { const tokens = []; const stash = (html) => { tokens.push(html); - return `\uF8FF${tokens.length - 1}\uF8FF`; + return `${tokens.length - 1}`; }; let out = text.replace(/`([^`]+)`/g, (_, code) => @@ -49,7 +49,7 @@ function renderInline(text) { out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); out = out.replace(/\*([^*]+)\*/g, "$1"); - return out.replace(/\uF8FF(\d+)\uF8FF/g, (_, i) => tokens[Number(i)]); + return out.replace(/(\d+)/g, (_, i) => tokens[Number(i)]); } const isTableSeparator = (line) => @@ -63,6 +63,99 @@ function splitRow(line) { return cells.map((c) => c.trim()); } +// ── block detectors ────────────────────────────────────── +const isFence = (line) => /^```/.test(line.trim()); +const isHeading = (line) => /^(#{1,6})\s+/.test(line); +const isListItem = (line) => /^\s*-\s+/.test(line); +// A table starts where a row line (with "|") is immediately followed by a +// separator line (e.g. "|---|---|"). +const isTableStart = (lines, i) => + lines[i].includes("|") && + i + 1 < lines.length && + isTableSeparator(lines[i + 1]); + +// ── block parsers ──────────────────────────────────────── +// Each parser takes the source lines and the index of the block's first line, +// and returns { block: html, next: index to resume scanning at }. + +function parseFence(lines, i) { + const body = []; + i += 1; + while (i < lines.length && !isFence(lines[i])) { + body.push(lines[i]); + i += 1; + } + return { + block: `
${escapeHtml(body.join("\n"))}
`, + next: i + 1, // skip the closing fence + }; +} + +function parseHeading(line) { + const [, hashes, text] = line.match(/^(#{1,6})\s+(.*)$/); + return `${renderInline(text.trim())}`; +} + +function parseTable(lines, i) { + const header = splitRow(lines[i]); + i += 2; // header + separator + const bodyRows = []; + while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") { + bodyRows.push(splitRow(lines[i])); + i += 1; + } + const head = `${header.map((c) => `${renderInline(c)}`).join("")}`; + const body = bodyRows + .map( + (r) => `${r.map((c) => `${renderInline(c)}`).join("")}`, + ) + .join("\n"); + return { + block: `\n\n${head}\n\n\n${body}\n\n
`, + next: i, + }; +} + +function parseList(lines, i) { + const items = []; + while (i < lines.length && isListItem(lines[i])) { + items.push(renderInline(lines[i].replace(/^\s*-\s+/, ""))); + i += 1; + } + return { + block: `
    \n${items.map((it) => `
  • ${it}
  • `).join("\n")}\n
`, + next: i, + }; +} + +// A paragraph runs until a blank line or the start of any other block. +function parseParagraph(lines, i) { + const para = [lines[i]]; + i += 1; + while ( + i < lines.length && + lines[i].trim() !== "" && + !isFence(lines[i]) && + !isHeading(lines[i]) && + !isListItem(lines[i]) && + !isTableStart(lines, i) + ) { + para.push(lines[i]); + i += 1; + } + return { block: `

${renderInline(para.join(" ").trim())}

`, next: i }; +} + +// Dispatch the block starting at line `i` to its parser. +function parseBlock(lines, i) { + const line = lines[i]; + if (isFence(line)) return parseFence(lines, i); + if (isHeading(line)) return { block: parseHeading(line), next: i + 1 }; + if (isTableStart(lines, i)) return parseTable(lines, i); + if (isListItem(line)) return parseList(lines, i); + return parseParagraph(lines, i); +} + /** * Render a Markdown string to an HTML string. * @param {string} src Markdown source @@ -74,97 +167,13 @@ export function renderMarkdown(src) { let i = 0; while (i < lines.length) { - const line = lines[i]; - - if (line.trim() === "") { - i += 1; - continue; - } - - // Fenced code block - if (/^```/.test(line.trim())) { - const body = []; - i += 1; - while (i < lines.length && !/^```/.test(lines[i].trim())) { - body.push(lines[i]); - i += 1; - } - i += 1; // closing fence - blocks.push(`
${escapeHtml(body.join("\n"))}
`); - continue; - } - - // Heading - const heading = line.match(/^(#{1,6})\s+(.*)$/); - if (heading) { - const level = heading[1].length; - blocks.push(`${renderInline(heading[2].trim())}`); + if (lines[i].trim() === "") { i += 1; continue; } - - // Table: a row line followed by a separator line - if ( - line.includes("|") && - i + 1 < lines.length && - isTableSeparator(lines[i + 1]) - ) { - const header = splitRow(line); - i += 2; // header + separator - const bodyRows = []; - while ( - i < lines.length && - lines[i].includes("|") && - lines[i].trim() !== "" - ) { - bodyRows.push(splitRow(lines[i])); - i += 1; - } - const head = `${header.map((c) => `${renderInline(c)}`).join("")}`; - const body = bodyRows - .map( - (r) => - `${r.map((c) => `${renderInline(c)}`).join("")}`, - ) - .join("\n"); - blocks.push( - `\n\n${head}\n\n\n${body}\n\n
`, - ); - continue; - } - - // Unordered list - if (/^\s*-\s+/.test(line)) { - const items = []; - while (i < lines.length && /^\s*-\s+/.test(lines[i])) { - items.push(renderInline(lines[i].replace(/^\s*-\s+/, ""))); - i += 1; - } - blocks.push( - `
    \n${items.map((it) => `
  • ${it}
  • `).join("\n")}\n
`, - ); - continue; - } - - // Paragraph: gather following non-blank, non-block lines - const para = [line]; - i += 1; - while ( - i < lines.length && - lines[i].trim() !== "" && - !/^```/.test(lines[i].trim()) && - !/^(#{1,6})\s+/.test(lines[i]) && - !/^\s*-\s+/.test(lines[i]) && - !( - lines[i].includes("|") && - i + 1 < lines.length && - isTableSeparator(lines[i + 1]) - ) - ) { - para.push(lines[i]); - i += 1; - } - blocks.push(`

${renderInline(para.join(" ").trim())}

`); + const { block, next } = parseBlock(lines, i); + blocks.push(block); + i = next; } return blocks.join("\n"); diff --git a/src/persist.js b/src/persist.js index d6d1ee9..925a7ad 100644 --- a/src/persist.js +++ b/src/persist.js @@ -60,6 +60,10 @@ export function serialize(state) { }; } +// A finite number, or undefined if the value isn't one. +const finiteOr = (v) => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + function sanitizeMember(raw) { const m = emptyMember(); if (!raw || typeof raw !== "object") return m; @@ -67,57 +71,56 @@ function sanitizeMember(raw) { 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); + const level = finiteOr(raw.level); + if (level !== undefined) { + const lv = Math.round(level); if (lv >= MIN_LEVEL && lv <= MAX_LEVEL) m.level = lv; } return m; } +// Overlay any valid encounter fields from `renc` onto the default `enc`, +// clamping numbers to their accepted ranges and ignoring wrong-typed values. +function sanitizeEnc(enc, renc) { + if (!renc || typeof renc !== "object") return; + + const mobLevel = finiteOr(renc.mobLevel); + if (mobLevel !== undefined) { + enc.mobLevel = clamp(Math.round(mobLevel), MIN_MOB_LEVEL, MAX_MOB_LEVEL); + } + const minutes = finiteOr(renc.minutesPerKill); + if (minutes !== undefined) { + enc.minutesPerKill = clamp( + minutes, + MIN_MINUTES_PER_KILL, + MAX_MINUTES_PER_KILL, + ); + } + const manualZem = finiteOr(renc.manualZem); + if (manualZem !== undefined) { + enc.manualZem = clamp(Math.round(manualZem), MIN_ZEM, MAX_ZEM); + } + if (typeof renc.zoneName === "string" && renc.zoneName.length > 0) { + enc.zoneName = renc.zoneName; + } + if (typeof renc.useManualZem === "boolean") { + enc.useManualZem = renc.useManualZem; + } + if (typeof renc.penaltiesOn === "boolean") { + enc.penaltiesOn = renc.penaltiesOn; + } +} + 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), - MIN_MOB_LEVEL, - MAX_MOB_LEVEL, - ); - } - if ( - typeof renc.minutesPerKill === "number" && - Number.isFinite(renc.minutesPerKill) - ) { - out.enc.minutesPerKill = clamp( - renc.minutesPerKill, - MIN_MINUTES_PER_KILL, - MAX_MINUTES_PER_KILL, - ); - } - 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), MIN_ZEM, MAX_ZEM); - } - if (typeof renc.penaltiesOn === "boolean") { - out.enc.penaltiesOn = renc.penaltiesOn; - } + out.party = Array.from({ length: PARTY_SIZE }, (_, i) => + sanitizeMember(raw.party[i]), + ); } + sanitizeEnc(out.enc, raw.enc); return out; } From d013126cdfd163491733f0676b2c4ea808f8e2e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:54:30 +0000 Subject: [PATCH 6/7] style: apply Prettier line-wrap to notices.js https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- src/notices.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/notices.js b/src/notices.js index dba745c..b70ca6a 100644 --- a/src/notices.js +++ b/src/notices.js @@ -20,7 +20,9 @@ export function rowNotices(player) { ); } if (player.xpPerKill === 0 && player.eligible) { - notices.push("* Mob cons green to the highest party member — no XP awarded"); + notices.push( + "* Mob cons green to the highest party member — no XP awarded", + ); } return notices; } From fb49ee2aaefc09b710008f718e560603eca64ced Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 17:22:12 +0000 Subject: [PATCH 7/7] feat: prefix app title with "Gorrek's" Update the in-page header title and the browser tab from "EQ XP Calculator" to "Gorrek's EQ XP Calculator". https://claude.ai/code/session_0182ZaBayGmnqjHjxKDtWdPJ --- index.html | 2 +- src/ui.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 87f0ea8..5fd6df5 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>EQ XP Calculator + Gorrek's EQ XP Calculator