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
8 changes: 8 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EQ XP Calculator</title>
<title>Gorrek's EQ XP Calculator</title>
<meta
name="description"
content="An honest EverQuest Project 1999 XP calculator: per-character kills and time to level for your party."
Expand Down
29 changes: 29 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -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 });
191 changes: 100 additions & 91 deletions src/markdown.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) =>
Expand All @@ -49,7 +49,7 @@ function renderInline(text) {
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");

return out.replace(/\uF8FF(\d+)\uF8FF/g, (_, i) => tokens[Number(i)]);
return out.replace(/(\d+)/g, (_, i) => tokens[Number(i)]);
}

const isTableSeparator = (line) =>
Expand All @@ -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: `<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`,
next: i + 1, // skip the closing fence
};
}

function parseHeading(line) {
const [, hashes, text] = line.match(/^(#{1,6})\s+(.*)$/);
return `<h${hashes.length}>${renderInline(text.trim())}</h${hashes.length}>`;
}

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 = `<tr>${header.map((c) => `<th>${renderInline(c)}</th>`).join("")}</tr>`;
const body = bodyRows
.map(
(r) => `<tr>${r.map((c) => `<td>${renderInline(c)}</td>`).join("")}</tr>`,
)
.join("\n");
return {
block: `<table>\n<thead>\n${head}\n</thead>\n<tbody>\n${body}\n</tbody>\n</table>`,
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: `<ul>\n${items.map((it) => `<li>${it}</li>`).join("\n")}\n</ul>`,
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: `<p>${renderInline(para.join(" ").trim())}</p>`, 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
Expand All @@ -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(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
continue;
}

// Heading
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
blocks.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`);
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 = `<tr>${header.map((c) => `<th>${renderInline(c)}</th>`).join("")}</tr>`;
const body = bodyRows
.map(
(r) =>
`<tr>${r.map((c) => `<td>${renderInline(c)}</td>`).join("")}</tr>`,
)
.join("\n");
blocks.push(
`<table>\n<thead>\n${head}\n</thead>\n<tbody>\n${body}\n</tbody>\n</table>`,
);
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(
`<ul>\n${items.map((it) => `<li>${it}</li>`).join("\n")}\n</ul>`,
);
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(`<p>${renderInline(para.join(" ").trim())}</p>`);
const { block, next } = parseBlock(lines, i);
blocks.push(block);
i = next;
}

return blocks.join("\n");
Expand Down
28 changes: 28 additions & 0 deletions src/notices.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading