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/index.html b/index.html index 87f0ea8..5fd6df5 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@
-${escapeHtml(body.join("\n"))}`,
+ next: i + 1, // skip the closing fence
+ };
+}
+
+function parseHeading(line) {
+ const [, hashes, text] = line.match(/^(#{1,6})\s+(.*)$/);
+ return `${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(para.join(" ").trim())}
`); + const { block, next } = parseBlock(lines, i); + blocks.push(block); + i = next; } return blocks.join("\n"); diff --git a/src/notices.js b/src/notices.js new file mode 100644 index 0000000..b70ca6a --- /dev/null +++ b/src/notices.js @@ -0,0 +1,28 @@ +// 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/persist.js b/src/persist.js index be15d4a..925a7ad 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,9 +60,9 @@ export function serialize(state) { }; } -function clamp(n, lo, hi) { - return Math.max(lo, Math.min(hi, n)); -} +// 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(); @@ -64,49 +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); - if (lv >= 1 && lv <= MAX_LEVEL) m.level = lv; + 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), 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; - } + out.party = Array.from({ length: PARTY_SIZE }, (_, i) => + sanitizeMember(raw.party[i]), + ); } + sanitizeEnc(out.enc, raw.enc); return out; } diff --git a/src/ui.js b/src/ui.js index 6b2a8e1..868cfb0 100644 --- a/src/ui.js +++ b/src/ui.js @@ -18,19 +18,28 @@ 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, 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); @@ -64,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); @@ -83,8 +97,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: [], @@ -136,97 +148,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); @@ -235,25 +246,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(); } @@ -412,7 +439,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 +523,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 +538,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 +605,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); @@ -802,7 +833,7 @@ function build() { app.appendChild( el("header", { class: "app-header" }, [ - el("span", { class: "app-title" }, "EQ XP Calculator"), + el("span", { class: "app-title" }, "Gorrek's EQ XP Calculator"), el( "span", { class: "app-tagline" }, 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"); +}); 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", + ]); +});