- character-builder/: Vue 3 + NestJS + Valkey app for planning house, class, character XP, 5 spec tracks, faction standing, and skill trees. Shareable via short link (POST /api/builds → 8-char nanoid). - character-builder/data/: parsed JSON tables (character XP through L200, 5 specs to L100, 2 faction standing tables, 5 class skill trees). - character-builder/scripts/extract.py: parser that regenerates data/*.json from the gitignored sample-data/*.html snapshots. - Dockerfile + docker-compose.yml: two-container deploy (app + Valkey). - specialization-calculator/: pre-existing single-file XP/quest calculator, carried into the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
662 lines
22 KiB
HTML
662 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Lansraad Specialization Calculator</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||
<style>
|
||
:root {
|
||
--bg: #1a1410;
|
||
--bg-2: #221a14;
|
||
--panel: #2b2018;
|
||
--panel-2: #352720;
|
||
--line: #4a3628;
|
||
--line-soft: #3a2a1f;
|
||
--ink: #f4e9d8;
|
||
--ink-dim: #c9b89c;
|
||
--ink-muted: #8a7560;
|
||
--sand: #e6c98a;
|
||
--sand-2: #d4a85a;
|
||
--ember: #c8643a;
|
||
--spice: #e08a3c;
|
||
--green: #8fb87a;
|
||
--shadow: 0 1px 0 rgba(255,255,255,0.04), 0 12px 32px -16px rgba(0,0,0,0.6);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
|
||
body {
|
||
font-family: "Inter", system-ui, sans-serif;
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
background:
|
||
radial-gradient(1200px 600px at 80% -10%, rgba(224,138,60,0.10), transparent 60%),
|
||
radial-gradient(900px 500px at -10% 110%, rgba(212,168,90,0.06), transparent 60%),
|
||
var(--bg);
|
||
min-height: 100vh;
|
||
}
|
||
.wrap { max-width: 1180px; margin: 0 auto; padding: 56px 32px 96px; }
|
||
header.hero { margin-bottom: 40px; }
|
||
.eyebrow {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--sand-2);
|
||
margin-bottom: 14px;
|
||
display: flex; align-items: center; gap: 12px;
|
||
}
|
||
.eyebrow::after {
|
||
content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line) 0%, transparent 100%);
|
||
}
|
||
h1 {
|
||
font-family: "Cormorant Garamond", serif;
|
||
font-weight: 500;
|
||
font-size: clamp(40px, 5vw, 64px);
|
||
line-height: 1.0;
|
||
margin: 0 0 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
h1 em { font-style: italic; color: var(--sand); font-weight: 500; }
|
||
.lede {
|
||
color: var(--ink-dim);
|
||
max-width: 640px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
/* Settings strip */
|
||
.settings {
|
||
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: 4px;
|
||
padding: 22px 26px;
|
||
margin: 36px 0 28px;
|
||
box-shadow: var(--shadow);
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 28px;
|
||
align-items: center;
|
||
}
|
||
.settings-title {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.2em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-muted);
|
||
border-right: 1px solid var(--line-soft);
|
||
padding-right: 28px;
|
||
white-space: nowrap;
|
||
}
|
||
.settings-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 24px;
|
||
}
|
||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||
.field label {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-muted);
|
||
}
|
||
.field input[type="number"] {
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line-soft);
|
||
color: var(--ink);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 16px;
|
||
padding: 10px 12px;
|
||
border-radius: 3px;
|
||
width: 100%;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.field input[type="number"]:focus {
|
||
outline: none;
|
||
border-color: var(--sand-2);
|
||
background: #1f1812;
|
||
}
|
||
/* Custom toggle */
|
||
.toggle-wrap {
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: 3px;
|
||
padding: 10px 14px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
height: 42px;
|
||
}
|
||
.toggle-wrap input { display: none; }
|
||
.toggle-pill {
|
||
width: 36px; height: 20px; border-radius: 999px;
|
||
background: #1a1310;
|
||
border: 1px solid var(--line);
|
||
position: relative;
|
||
transition: background .2s, border-color .2s;
|
||
}
|
||
.toggle-pill::after {
|
||
content: "";
|
||
position: absolute; top: 2px; left: 2px;
|
||
width: 14px; height: 14px; border-radius: 50%;
|
||
background: var(--ink-muted);
|
||
transition: transform .2s, background .2s;
|
||
}
|
||
.toggle-wrap.on .toggle-pill {
|
||
background: rgba(224,138,60,0.18);
|
||
border-color: var(--spice);
|
||
}
|
||
.toggle-wrap.on .toggle-pill::after {
|
||
transform: translateX(16px);
|
||
background: var(--spice);
|
||
}
|
||
.toggle-label {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-dim);
|
||
}
|
||
.toggle-wrap.on .toggle-label { color: var(--sand); }
|
||
|
||
/* Totals */
|
||
.totals {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0;
|
||
margin: 28px 0 36px;
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: var(--bg-2);
|
||
}
|
||
.total {
|
||
padding: 22px 26px;
|
||
border-right: 1px solid var(--line-soft);
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent);
|
||
}
|
||
.total:last-child { border-right: none; }
|
||
.total .lbl {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.2em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-muted);
|
||
margin-bottom: 8px;
|
||
}
|
||
.total .val {
|
||
font-family: "Cormorant Garamond", serif;
|
||
font-weight: 500;
|
||
font-size: 42px;
|
||
line-height: 1;
|
||
color: var(--sand);
|
||
}
|
||
.total .val .unit {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
color: var(--ink-muted);
|
||
letter-spacing: 0.15em;
|
||
margin-left: 8px;
|
||
vertical-align: 8px;
|
||
}
|
||
|
||
/* Cards */
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||
gap: 18px;
|
||
}
|
||
.card {
|
||
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: 4px;
|
||
padding: 22px 24px 20px;
|
||
box-shadow: var(--shadow);
|
||
position: relative;
|
||
transition: border-color .15s;
|
||
}
|
||
.card:hover { border-color: var(--line); }
|
||
.card.disabled { opacity: 0.55; }
|
||
|
||
.card-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 14px;
|
||
border-bottom: 1px dashed var(--line-soft);
|
||
}
|
||
.card-name {
|
||
font-family: "Cormorant Garamond", serif;
|
||
font-size: 26px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.card-sym {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10px;
|
||
letter-spacing: 0.2em;
|
||
color: var(--ink-muted);
|
||
text-transform: uppercase;
|
||
}
|
||
.card-include {
|
||
display: flex; align-items: center; gap: 8px;
|
||
cursor: pointer; user-select: none;
|
||
}
|
||
.card-include input { accent-color: var(--spice); }
|
||
.card-include span {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
|
||
color: var(--ink-muted);
|
||
}
|
||
|
||
.row {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.row.single { grid-template-columns: 1fr; }
|
||
.row .field input[type="number"] { font-size: 15px; padding: 8px 10px; }
|
||
|
||
.results {
|
||
margin-top: 18px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--line-soft);
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.result {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.result .rl {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 9.5px; letter-spacing: 0.18em; text-transform: uppercase;
|
||
color: var(--ink-muted);
|
||
}
|
||
.result .rv {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 18px;
|
||
color: var(--ink);
|
||
font-weight: 500;
|
||
}
|
||
.result .rv.warn { color: var(--ember); }
|
||
.result .rv.good { color: var(--green); }
|
||
.result .rv .ru { color: var(--ink-muted); font-size: 11px; margin-left: 3px; }
|
||
|
||
.progress {
|
||
margin-top: 14px;
|
||
height: 4px;
|
||
background: var(--bg-2);
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.progress::before {
|
||
content: ""; position: absolute; inset: 0;
|
||
width: var(--pct, 0%);
|
||
background: linear-gradient(90deg, var(--ember) 0%, var(--spice) 60%, var(--sand) 100%);
|
||
transition: width .25s ease;
|
||
}
|
||
.progress-meta {
|
||
display: flex; justify-content: space-between;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10px; color: var(--ink-muted);
|
||
margin-top: 6px;
|
||
letter-spacing: 0.1em;
|
||
}
|
||
|
||
.reset-bar {
|
||
margin-top: 28px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
color: var(--ink-muted);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px; letter-spacing: 0.14em;
|
||
}
|
||
.reset-bar button {
|
||
background: transparent;
|
||
border: 1px solid var(--line);
|
||
color: var(--ink-dim);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
padding: 8px 16px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
transition: border-color .15s, color .15s, background .15s;
|
||
}
|
||
.reset-bar button:hover {
|
||
border-color: var(--sand-2);
|
||
color: var(--sand);
|
||
background: rgba(224,138,60,0.06);
|
||
}
|
||
|
||
footer.foot {
|
||
margin-top: 64px;
|
||
padding-top: 24px;
|
||
border-top: 1px solid var(--line-soft);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
color: var(--ink-muted);
|
||
letter-spacing: 0.1em;
|
||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
|
||
}
|
||
|
||
/* Subtle decoration */
|
||
.corner {
|
||
position: absolute; width: 14px; height: 14px;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.corner.tl { top: 8px; left: 8px; border-right: none; border-bottom: none; }
|
||
.corner.tr { top: 8px; right: 8px; border-left: none; border-bottom: none; }
|
||
.corner.bl { bottom: 8px; left: 8px; border-right: none; border-top: none; }
|
||
.corner.br { bottom: 8px; right: 8px; border-left: none; border-top: none; }
|
||
|
||
/* Spinner removal on number inputs */
|
||
input[type=number]::-webkit-inner-spin-button,
|
||
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||
input[type=number] { -moz-appearance: textfield; }
|
||
|
||
@media (max-width: 720px) {
|
||
.wrap { padding: 32px 18px 64px; }
|
||
.settings { grid-template-columns: 1fr; gap: 18px; }
|
||
.settings-title { border-right: none; border-bottom: 1px solid var(--line-soft); padding: 0 0 14px; }
|
||
.settings-fields { grid-template-columns: 1fr; }
|
||
.totals { grid-template-columns: 1fr; }
|
||
.total { border-right: none; border-bottom: 1px solid var(--line-soft); }
|
||
.total:last-child { border-bottom: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header class="hero">
|
||
<div class="eyebrow">Lansraad — Specialization Ledger</div>
|
||
<h1>Calculate the XP, quests, and days <em>to your next rank</em>.</h1>
|
||
<p class="lede">Plug in your current level for each specialization and where you want to be. The ledger handles the math — total XP needed, quests to clear it, and how many days that takes at your pace.</p>
|
||
</header>
|
||
|
||
<section class="settings" aria-label="Global settings">
|
||
<div class="settings-title">Pace<br/>& Rate</div>
|
||
<div class="settings-fields">
|
||
<div class="field">
|
||
<label for="xpPerQuest">XP per Quest</label>
|
||
<input id="xpPerQuest" type="number" min="1" value="125" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="questsPerDay">Quests per Day</label>
|
||
<input id="questsPerDay" type="number" min="1" value="5" />
|
||
</div>
|
||
<div class="field">
|
||
<label> </label>
|
||
<label class="toggle-wrap" id="dblToggle" for="doubleXp">
|
||
<input id="doubleXp" type="checkbox" />
|
||
<span class="toggle-pill"></span>
|
||
<span class="toggle-label">Double XP Weekend</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div style="text-align:right;">
|
||
<div style="font-family: 'JetBrains Mono', monospace; font-size: 10.5px; letter-spacing: 0.2em; color: var(--ink-muted); text-transform: uppercase;">Effective</div>
|
||
<div style="font-family: 'Cormorant Garamond', serif; font-size: 32px; color: var(--sand); line-height: 1;"><span id="effectiveXp">125</span> <span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-muted); letter-spacing: 0.15em;">XP/Q</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="totals" aria-label="Totals">
|
||
<div class="total">
|
||
<div class="lbl">Total XP Needed</div>
|
||
<div class="val"><span id="totalXp">0</span></div>
|
||
</div>
|
||
<div class="total">
|
||
<div class="lbl">Total Quests</div>
|
||
<div class="val"><span id="totalQuests">0</span></div>
|
||
</div>
|
||
<div class="total">
|
||
<div class="lbl">Total Days</div>
|
||
<div class="val"><span id="totalDays">0</span><span class="unit">@ pace</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="grid" id="cards"></section>
|
||
|
||
<div class="reset-bar">
|
||
<div>Saved locally · refresh-safe</div>
|
||
<button id="resetBtn">Reset All</button>
|
||
</div>
|
||
|
||
<footer class="foot">
|
||
<div>Lansraad XP table · Levels 1–100</div>
|
||
<div>Unofficial fan-made calculator · not affiliated with the game's publisher</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
// XP required to advance INTO each level (i.e. A[N] is XP to go from N-1 -> N, per the source sheet)
|
||
// Levels 1..100
|
||
const XP_TABLE = [
|
||
100,105,110,116,122,128,135,142,149,157,
|
||
164,171,178,186,194,202,211,220,229,239,
|
||
246,253,260,268,276,284,292,301,310,319,
|
||
326,334,342,350,358,366,375,384,393,402,
|
||
410,418,426,434,442,450,459,468,477,486,
|
||
494,502,510,518,526,534,543,552,561,570,
|
||
575,580,585,590,595,600,606,612,618,624,
|
||
624,624,624,624,624,624,624,624,624,624,
|
||
624,624,624,624,624,624,624,624,624,624,
|
||
624,624,624,624,624,624,624,624,624,624
|
||
];
|
||
const MAX_LEVEL = 100;
|
||
|
||
const SPECS = [
|
||
{ id: 'crafting', name: 'Crafting', sym: 'CRF', cur: 52, into: 66, goal: 66 },
|
||
{ id: 'gathering', name: 'Gathering', sym: 'GTH', cur: 22, into: 23, goal: 23 },
|
||
{ id: 'exploration', name: 'Exploration', sym: 'EXP', cur: 40, into: 41, goal: 41 },
|
||
{ id: 'combat', name: 'Combat', sym: 'CMB', cur: 50, into: 51, goal: 51 },
|
||
{ id: 'sabotage', name: 'Sabotage', sym: 'SAB', cur: 21, into: 27, goal: 27 }
|
||
];
|
||
|
||
const STORAGE_KEY = 'lansraad-calc-v1';
|
||
|
||
// Default state
|
||
const defaultState = () => ({
|
||
xpPerQuest: 125,
|
||
questsPerDay: 5,
|
||
doubleXp: false,
|
||
specs: SPECS.reduce((acc, s) => {
|
||
acc[s.id] = {
|
||
currentLevel: s.cur,
|
||
xpInto: 0,
|
||
desiredLevel: s.goal,
|
||
included: true
|
||
};
|
||
return acc;
|
||
}, {})
|
||
});
|
||
|
||
let state = defaultState();
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||
if (saved && saved.specs) state = { ...defaultState(), ...saved, specs: { ...defaultState().specs, ...saved.specs }};
|
||
} catch(e) {}
|
||
|
||
function save() {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e) {}
|
||
}
|
||
|
||
// XP from current level (with partial XP into next) to desired level
|
||
// Logic from sheet: SUM(A[current+1 .. desired]) - xpInto
|
||
// i.e. you've already earned xpInto toward the next level
|
||
function calcXp(curLvl, xpInto, desiredLvl) {
|
||
curLvl = clamp(Math.floor(curLvl||0), 0, MAX_LEVEL);
|
||
desiredLvl = clamp(Math.floor(desiredLvl||0), 0, MAX_LEVEL);
|
||
xpInto = Math.max(0, Math.floor(xpInto||0));
|
||
if (desiredLvl <= curLvl) return 0;
|
||
let sum = 0;
|
||
for (let lvl = curLvl + 1; lvl <= desiredLvl; lvl++) {
|
||
sum += XP_TABLE[lvl - 1] || 0; // A[lvl] = XP_TABLE[lvl-1]
|
||
}
|
||
return Math.max(0, sum - xpInto);
|
||
}
|
||
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
|
||
|
||
function effectiveXpPerQuest() {
|
||
const base = Math.max(1, Number(state.xpPerQuest) || 1);
|
||
return state.doubleXp ? base * 2 : base;
|
||
}
|
||
|
||
// Render
|
||
const cardsEl = document.getElementById('cards');
|
||
function render() {
|
||
// Settings inputs reflect state
|
||
document.getElementById('xpPerQuest').value = state.xpPerQuest;
|
||
document.getElementById('questsPerDay').value = state.questsPerDay;
|
||
document.getElementById('doubleXp').checked = state.doubleXp;
|
||
document.getElementById('dblToggle').classList.toggle('on', state.doubleXp);
|
||
document.getElementById('effectiveXp').textContent = fmt(effectiveXpPerQuest());
|
||
|
||
// Cards
|
||
cardsEl.innerHTML = SPECS.map(s => {
|
||
const st = state.specs[s.id];
|
||
const xp = calcXp(st.currentLevel, st.xpInto, st.desiredLevel);
|
||
const eff = effectiveXpPerQuest();
|
||
const quests = xp > 0 ? Math.ceil(xp / eff) : 0;
|
||
const qpd = Math.max(1, Number(state.questsPerDay) || 1);
|
||
const days = quests > 0 ? Math.ceil(quests / qpd) : 0;
|
||
|
||
// progress: where the player is within their current level
|
||
const nextLevelXp = XP_TABLE[clamp(st.currentLevel, 0, MAX_LEVEL-1)] || 0;
|
||
const pct = nextLevelXp > 0 ? clamp((st.xpInto / nextLevelXp) * 100, 0, 100) : 0;
|
||
|
||
const valid = st.desiredLevel > st.currentLevel;
|
||
const disabled = !st.included;
|
||
|
||
return `
|
||
<article class="card ${disabled ? 'disabled' : ''}" data-id="${s.id}">
|
||
<span class="corner tl"></span><span class="corner tr"></span>
|
||
<span class="corner bl"></span><span class="corner br"></span>
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-sym">${s.sym}</div>
|
||
<div class="card-name">${s.name}</div>
|
||
</div>
|
||
<label class="card-include">
|
||
<input type="checkbox" data-field="included" ${st.included ? 'checked' : ''} />
|
||
<span>${st.included ? 'Tracking' : 'Skipped'}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="field">
|
||
<label>Current Level</label>
|
||
<input type="number" min="0" max="${MAX_LEVEL}" data-field="currentLevel" value="${st.currentLevel}" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Desired Level</label>
|
||
<input type="number" min="0" max="${MAX_LEVEL}" data-field="desiredLevel" value="${st.desiredLevel}" />
|
||
</div>
|
||
</div>
|
||
<div class="row single">
|
||
<div class="field">
|
||
<label>XP into Next Level</label>
|
||
<input type="number" min="0" data-field="xpInto" value="${st.xpInto}" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="progress" style="--pct: ${pct}%"></div>
|
||
<div class="progress-meta">
|
||
<span>L${st.currentLevel} → L${st.currentLevel+1}</span>
|
||
<span>${fmt(st.xpInto)} / ${fmt(nextLevelXp)} XP</span>
|
||
</div>
|
||
|
||
<div class="results">
|
||
<div class="result">
|
||
<div class="rl">XP Needed</div>
|
||
<div class="rv ${!valid ? 'good' : ''}">${valid ? fmt(xp) : '—'}</div>
|
||
</div>
|
||
<div class="result">
|
||
<div class="rl">Quests</div>
|
||
<div class="rv">${valid ? fmt(quests) : '—'}</div>
|
||
</div>
|
||
<div class="result">
|
||
<div class="rl">Days</div>
|
||
<div class="rv">${valid ? fmt(days) : '—'}</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}).join('');
|
||
|
||
// Totals (across included specs)
|
||
let totalXp = 0;
|
||
SPECS.forEach(s => {
|
||
const st = state.specs[s.id];
|
||
if (!st.included) return;
|
||
totalXp += calcXp(st.currentLevel, st.xpInto, st.desiredLevel);
|
||
});
|
||
const eff = effectiveXpPerQuest();
|
||
const totalQuests = totalXp > 0 ? Math.ceil(totalXp / eff) : 0;
|
||
const qpd = Math.max(1, Number(state.questsPerDay) || 1);
|
||
const totalDays = totalQuests > 0 ? Math.ceil(totalQuests / qpd) : 0;
|
||
|
||
document.getElementById('totalXp').textContent = fmt(totalXp);
|
||
document.getElementById('totalQuests').textContent = fmt(totalQuests);
|
||
document.getElementById('totalDays').textContent = fmt(totalDays);
|
||
}
|
||
|
||
function fmt(n) {
|
||
return Number(n||0).toLocaleString('en-US');
|
||
}
|
||
|
||
// Event delegation for card inputs
|
||
cardsEl.addEventListener('input', e => {
|
||
const t = e.target;
|
||
const field = t.dataset.field;
|
||
if (!field) return;
|
||
const card = t.closest('.card');
|
||
const id = card.dataset.id;
|
||
const st = state.specs[id];
|
||
if (field === 'included') {
|
||
st.included = t.checked;
|
||
} else {
|
||
let v = Number(t.value);
|
||
if (isNaN(v)) v = 0;
|
||
if (field === 'currentLevel' || field === 'desiredLevel') v = clamp(Math.floor(v), 0, MAX_LEVEL);
|
||
if (field === 'xpInto') v = Math.max(0, Math.floor(v));
|
||
st[field] = v;
|
||
}
|
||
save();
|
||
render();
|
||
});
|
||
|
||
cardsEl.addEventListener('change', e => {
|
||
if (e.target.dataset.field === 'included') {
|
||
// re-render to update label text
|
||
render();
|
||
}
|
||
});
|
||
|
||
// Settings handlers
|
||
document.getElementById('xpPerQuest').addEventListener('input', e => {
|
||
state.xpPerQuest = Math.max(1, Number(e.target.value) || 1);
|
||
save(); render();
|
||
});
|
||
document.getElementById('questsPerDay').addEventListener('input', e => {
|
||
state.questsPerDay = Math.max(1, Number(e.target.value) || 1);
|
||
save(); render();
|
||
});
|
||
document.getElementById('doubleXp').addEventListener('change', e => {
|
||
state.doubleXp = e.target.checked;
|
||
save(); render();
|
||
});
|
||
|
||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||
state = defaultState();
|
||
// Zero out the starter goals so it's a clean slate
|
||
Object.values(state.specs).forEach(s => { s.currentLevel = 0; s.desiredLevel = 0; s.xpInto = 0; });
|
||
save(); render();
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|