dune-tools/specialization-calculator/index.html
Vantz Stockwell 98a1792106 Add Dune Awakening character builder + initial project scaffolding
- 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>
2026-05-23 07:30:37 -04:00

662 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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/>&amp; 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>&nbsp;</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 1100</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>