Files
ROSS/webtools/index.html
T
ThomasandClaude Sonnet 5 8486504b49 Merge admin panel into the port-80 webtools site under /admin/
webtools' nginx now reverse-proxies /admin/ to the webadmin container
(docker/conf/webtools-nginx.conf), which no longer publishes its own
port -- everything is reachable through port 80 alongside the GM
toolbox. Added a link to it in webtools/index.html's sidebar.

Flask needs to know it's mounted under /admin/ to generate correct
url_for()/redirect() targets, so app.py now wraps the app in
werkzeug's ProxyFix (x_prefix/x_proto/x_for/x_host) reading the
X-Forwarded-Prefix header nginx sets; verified redirects, form
actions, and links all come back /admin/-prefixed end to end through
port 80.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 08:18:07 +02:00

491 lines
24 KiB
HTML
Raw 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="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KTRose GM Toolbox</title>
<style>
:root{
--bg:#12141a; --bg-raised:#181b23; --bg-card:#1d2029; --line:#2a2e3a;
--ink:#e9e6e2; --ink-dim:#9a99a8; --ink-faint:#65647a;
--thorn:#c2557a; --thorn-dim:#8a4560; --thorn-ink:#e28aa8;
--leaf:#7fb88a; --amber:#d1a054; --danger:#d16060;
--mono: ui-monospace,"Cascadia Code","SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--serif: Georgia,"Iowan Old Style","Palatino Linotype","Book Antiqua",serif;
--sans: -apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--radius:10px;
}
@media (prefers-color-scheme: light){
:root:not([data-theme="dark"]){
--bg:#f7f4ef; --bg-raised:#ffffff; --bg-card:#ffffff; --line:#e2ddd3;
--ink:#231d22; --ink-dim:#645d68; --ink-faint:#8e8790;
--thorn:#a83060; --thorn-dim:#c98aa2; --thorn-ink:#8a1f45;
--leaf:#3e7a4d; --amber:#8a611f; --danger:#a83a3a;
}
}
:root[data-theme="light"]{
--bg:#f7f4ef; --bg-raised:#ffffff; --bg-card:#ffffff; --line:#e2ddd3;
--ink:#231d22; --ink-dim:#645d68; --ink-faint:#8e8790;
--thorn:#a83060; --thorn-dim:#c98aa2; --thorn-ink:#8a1f45;
--leaf:#3e7a4d; --amber:#8a611f; --danger:#a83a3a;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:radial-gradient(1200px 600px at 15% -10%, color-mix(in srgb, var(--thorn) 10%, transparent), transparent) var(--bg);
color:var(--ink); font-family:var(--sans); -webkit-font-smoothing:antialiased;
}
a{color:inherit;}
::selection{background:color-mix(in srgb, var(--thorn) 35%, transparent);}
.shell{display:grid; grid-template-columns:260px 1fr; min-height:100vh;}
@media (max-width:860px){.shell{grid-template-columns:1fr;}}
.side{
border-right:1px solid var(--line); background:var(--bg-raised);
padding:26px 20px; position:sticky; top:0; height:100vh; overflow-y:auto;
display:flex; flex-direction:column; gap:20px;
}
@media (max-width:860px){.side{position:static; height:auto; border-right:none; border-bottom:1px solid var(--line);}}
.brand .mark{
font-family:var(--serif); font-size:1.4rem; font-style:italic;
background:linear-gradient(120deg, var(--thorn), var(--ink) 140%);
-webkit-background-clip:text; background-clip:text; color:transparent;
}
.brand .sub{font-size:0.7rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--ink-faint); margin-top:2px;}
.tabs{display:flex; flex-direction:column; gap:3px;}
.tab, .tab-link{
display:flex; align-items:center; gap:9px; padding:9px 11px; border-radius:8px;
font-size:0.9rem; color:var(--ink-dim); cursor:pointer; border:1px solid transparent;
text-decoration:none;
}
.tab:hover, .tab-link:hover{background:var(--bg-card); color:var(--ink);}
.tab.active{background:var(--bg-card); border-color:var(--line); color:var(--ink); font-weight:600;}
.tab .ico, .tab-link .ico{font-size:1rem; width:1.2em; text-align:center;}
.tab-link{border-top:1px solid var(--line); margin-top:6px; padding-top:12px;}
.side-panel{display:none; flex-direction:column; gap:10px;}
.side-panel.active{display:flex;}
.side-panel input, .side-panel select{
width:100%; background:var(--bg-card); border:1px solid var(--line); color:var(--ink);
padding:9px 11px; border-radius:8px; font-size:0.87rem; font-family:var(--sans);
}
.side-panel input:focus, .side-panel select:focus{outline:2px solid var(--thorn); outline-offset:1px;}
.side-panel .label{font-size:0.68rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--ink-faint); margin:6px 0 -2px;}
.navgroup{display:flex; flex-direction:column; gap:2px; max-height:340px; overflow-y:auto;}
.navlink{
display:flex; align-items:center; justify-content:space-between; padding:6px 10px;
border-radius:7px; font-size:0.83rem; color:var(--ink-dim); cursor:pointer; border:1px solid transparent;
}
.navlink:hover{background:var(--bg-card); color:var(--ink);}
.navlink.on{background:var(--bg-card); border-color:var(--line); color:var(--ink); font-weight:600;}
.navlink .count{font-variant-numeric:tabular-nums; font-size:0.7rem; color:var(--ink-faint);}
.legend{margin-top:auto; padding-top:14px; border-top:1px solid var(--line); font-size:0.7rem; color:var(--ink-faint); display:flex; flex-direction:column; gap:6px;}
.legend .row{display:flex; align-items:center; gap:8px;}
.pill{font-size:0.66rem; letter-spacing:0.06em; text-transform:uppercase; padding:2px 8px; border-radius:999px; font-weight:700; white-space:nowrap;}
.pill-gm{background:color-mix(in srgb, var(--leaf) 22%, var(--bg-card)); color:var(--leaf);}
.pill-dev{background:color-mix(in srgb, var(--amber) 25%, var(--bg-card)); color:var(--amber);}
.pill-any{background:color-mix(in srgb, var(--ink-faint) 20%, var(--bg-card)); color:var(--ink-dim);}
.main{padding:34px 42px 80px; max-width:1040px;}
@media (max-width:640px){.main{padding:22px 16px 60px;}}
.view{display:none;}
.view.active{display:block;}
.pagehead{margin-bottom:26px;}
.pagehead h1{font-family:var(--serif); font-style:italic; font-weight:400; font-size:1.9rem; margin:0 0 8px; text-wrap:balance;}
.pagehead p{margin:0; color:var(--ink-dim); font-size:0.93rem; max-width:66ch; line-height:1.55;}
.pagehead code{font-family:var(--mono); background:var(--bg-card); border:1px solid var(--line); padding:1px 6px; border-radius:5px; font-size:0.85em;}
.resultmeta{font-size:0.78rem; color:var(--ink-faint); margin:20px 0 10px;}
.category h2{
font-size:0.74rem; letter-spacing:0.11em; text-transform:uppercase; color:var(--thorn-dim);
margin:28px 0 12px; padding-bottom:8px; border-bottom:1px solid var(--line);
}
.cmdlist{display:flex; flex-direction:column; gap:9px;}
.cmd{background:var(--bg-card); border:1px solid var(--line); border-radius:var(--radius); padding:13px 15px; display:flex; flex-direction:column; gap:7px;}
.cmd-row{display:flex; align-items:baseline; gap:10px; flex-wrap:wrap;}
.cmd-syntax{font-family:var(--mono); font-size:0.9rem; font-weight:600;}
.cmd-syntax .arg{color:var(--ink-dim); font-weight:400;}
.copybtn{margin-left:auto; font-size:0.7rem; color:var(--ink-faint); border:1px solid var(--line); background:transparent; border-radius:6px; padding:3px 8px; cursor:pointer; font-family:var(--sans);}
.copybtn:hover{color:var(--ink); border-color:var(--ink-faint);}
.copybtn:focus-visible{outline:2px solid var(--thorn); outline-offset:1px;}
.cmd-desc{font-size:0.86rem; color:var(--ink-dim); line-height:1.5; max-width:70ch;}
.cmd-example{font-family:var(--mono); font-size:0.79rem; color:var(--thorn-ink); background:color-mix(in srgb, var(--thorn) 10%, transparent); border-left:2px solid var(--thorn); padding:6px 10px; border-radius:0 6px 6px 0; width:fit-content;}
/* Items */
.item-toolbar{display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:16px;}
.item-toolbar select, .item-toolbar input{
background:var(--bg-card); border:1px solid var(--line); color:var(--ink); padding:8px 10px; border-radius:8px; font-size:0.85rem;
}
.item-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:8px;}
.item-card{
background:var(--bg-card); border:1px solid var(--line); border-radius:9px; padding:10px 12px;
cursor:pointer; display:flex; flex-direction:column; gap:3px;
}
.item-card:hover{border-color:var(--thorn-dim);}
.item-card .nm{font-size:0.87rem; font-weight:600;}
.item-card .id{font-family:var(--mono); font-size:0.72rem; color:var(--ink-faint);}
.item-card.sel{border-color:var(--thorn); background:color-mix(in srgb, var(--thorn) 8%, var(--bg-card));}
.configurator{
position:sticky; bottom:0; margin-top:20px; background:var(--bg-raised); border:1px solid var(--line);
border-radius:var(--radius); padding:16px 18px; box-shadow:0 -8px 24px -12px rgba(0,0,0,0.4);
}
.configurator h3{margin:0 0 10px; font-size:0.95rem; font-family:var(--serif); font-style:italic; font-weight:400;}
.cfg-grid{display:grid; grid-template-columns:repeat(6,1fr); gap:10px;}
@media (max-width:760px){.cfg-grid{grid-template-columns:repeat(2,1fr);}}
.cfg-field label{display:block; font-size:0.66rem; letter-spacing:0.08em; text-transform:uppercase; color:var(--ink-faint); margin-bottom:4px;}
.cfg-field input, .cfg-field select{width:100%; background:var(--bg-card); border:1px solid var(--line); color:var(--ink); padding:7px 9px; border-radius:7px; font-size:0.85rem;}
.cfg-out{display:flex; align-items:center; gap:10px; margin-top:14px; flex-wrap:wrap;}
.cfg-out code{font-family:var(--mono); background:var(--bg-card); border:1px solid var(--line); padding:7px 12px; border-radius:7px; font-size:0.86rem; color:var(--thorn-ink); flex:1; min-width:200px;}
.cfg-out button{background:var(--thorn); color:#fff; border:none; padding:8px 16px; border-radius:7px; font-size:0.85rem; cursor:pointer; font-weight:600;}
.cfg-out button:hover{filter:brightness(1.08);}
.cfg-empty{color:var(--ink-faint); font-size:0.85rem; padding:8px 0;}
/* Maps */
.map-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px;}
.map-card{background:var(--bg-card); border:1px solid var(--line); border-radius:var(--radius); padding:16px;}
.map-card h3{margin:0 0 4px; font-size:1.1rem; font-family:var(--mono);}
.map-card .note{font-size:0.78rem; color:var(--thorn-dim); margin:4px 0 8px;}
.map-stats{display:grid; grid-template-columns:1fr 1fr; gap:6px 14px; font-size:0.82rem; color:var(--ink-dim);}
.map-stats b{color:var(--ink); font-variant-numeric:tabular-nums;}
.map-empty-note{
background:color-mix(in srgb, var(--amber) 12%, var(--bg-card)); border:1px solid color-mix(in srgb, var(--amber) 35%, var(--line));
border-radius:var(--radius); padding:14px 16px; font-size:0.85rem; color:var(--ink-dim); margin-bottom:20px; line-height:1.5;
}
.empty{padding:50px 20px; text-align:center; color:var(--ink-faint); font-size:0.9rem;}
</style>
</head>
<body>
<div class="shell">
<aside class="side">
<div class="brand">
<div class="mark">KTRose Toolbox</div>
<div class="sub">self-hosted · no auth</div>
</div>
<nav class="tabs">
<div class="tab active" data-view="commands"><span class="ico">⌘</span> GM Commands</div>
<div class="tab" data-view="items"><span class="ico">◆</span> Item Configurator</div>
<div class="tab" data-view="maps"><span class="ico">⌗</span> Maps</div>
<a class="tab-link" href="/admin/"><span class="ico">⚙</span> Admin Panel</a>
</nav>
<div class="side-panel active" id="panel-commands">
<div class="label">Search</div>
<input id="q-commands" type="text" placeholder="Search commands…" autocomplete="off" />
<div class="navgroup" id="nav-commands"></div>
</div>
<div class="side-panel" id="panel-items">
<div class="label">Category</div>
<select id="item-cat"></select>
<div class="label">Search by name or id</div>
<input id="q-items" type="text" placeholder="e.g. wooden sword, or 42" autocomplete="off" />
</div>
<div class="side-panel" id="panel-maps">
<div class="label">Sort</div>
<select id="map-sort">
<option value="id">Map ID</option>
<option value="monsters">Monster count</option>
<option value="npcs">NPC count</option>
</select>
</div>
<div class="legend" id="legend-commands">
<div class="row"><span class="pill pill-any">any</span> usable by any GM character</div>
<div class="row"><span class="pill pill-gm">gm 300+</span> accesslevel ≥ 300</div>
<div class="row"><span class="pill pill-dev">dev 900+</span> accesslevel ≥ 900 + IsDev</div>
</div>
</aside>
<main class="main">
<!-- ===================== COMMANDS ===================== -->
<section class="view active" id="view-commands">
<div class="pagehead">
<h1>Every <code>/command</code> the world server understands</h1>
<p>Typed straight into normal chat, leading slash included — e.g. <code>/level 50 thomas</code>.
Pulled directly from <code>gmcmds.cpp</code>. Thomas is flagged accesslevel 900 + IsGM + IsDev,
so everything here is available to that character.</p>
</div>
<div class="resultmeta" id="resultmeta-commands"></div>
<div id="results-commands"></div>
</section>
<!-- ===================== ITEMS ===================== -->
<section class="view" id="view-items">
<div class="pagehead">
<h1>Pick an item, get the exact <code>/item</code> command</h1>
<p>9,951 items straight out of <code>itemlist.xls</code> (the same catalog the client uses), grouped into
the 14 categories the <code>/item</code> and <code>/give2</code> commands expect. Click one to configure
refine/count/etc. and copy the ready-to-paste command.</p>
</div>
<div class="item-toolbar">
<input id="give-to" type="text" placeholder="Leave empty for yourself, or type a charname for /give2" style="flex:1; min-width:240px;" />
</div>
<div class="resultmeta" id="resultmeta-items"></div>
<div class="item-grid" id="results-items"></div>
<div class="configurator" id="configurator" style="display:none;">
<h3 id="cfg-title"></h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Count</label><input id="cfg-amount" type="number" value="1" min="1" /></div>
<div class="cfg-field"><label>Refine (0-9)</label><input id="cfg-refine" type="number" value="0" min="0" max="9" /></div>
<div class="cfg-field"><label>Lifespan</label><input id="cfg-life" type="number" value="100" min="0" /></div>
<div class="cfg-field"><label>Socketed</label><select id="cfg-socket"><option value="0">no</option><option value="1">yes</option></select></div>
<div class="cfg-field"><label>Stats</label><input id="cfg-stats" type="number" value="0" min="0" /></div>
</div>
<div class="cfg-out">
<code id="cfg-command"></code>
<button id="cfg-copy" type="button">Copy command</button>
</div>
</div>
</section>
<!-- ===================== MAPS ===================== -->
<section class="view" id="view-maps">
<div class="pagehead">
<h1>Every map with active spawn or NPC data</h1>
<p>Aggregated live from <code>list_special_spawns</code> and <code>list_npcs</code>.</p>
</div>
<div class="map-empty-note">
No minimap images or display names are shown here — this repo only ships the server side of KTRose, not the
client's 3D/texture assets, and the localized zone-name strings aren't parsed by the server's simplified STB
loader either. What's below (spawn counts, monster totals, coordinate bounds) is real, queried straight from
the running database.
</div>
<div class="map-grid" id="results-maps"></div>
</section>
</main>
</div>
<script src="commands-data.js"></script>
<script>
/* ---------------- shared helpers ---------------- */
function copyText(t){ navigator.clipboard.writeText(t).catch(()=>{}); }
/* ---------------- tab switching ---------------- */
document.querySelectorAll(".tab").forEach(tab=>{
tab.addEventListener("click", ()=>{
document.querySelectorAll(".tab").forEach(t=>t.classList.remove("active"));
document.querySelectorAll(".side-panel").forEach(p=>p.classList.remove("active"));
document.querySelectorAll(".view").forEach(v=>v.classList.remove("active"));
tab.classList.add("active");
const view = tab.dataset.view;
document.getElementById("panel-"+view).classList.add("active");
document.getElementById("view-"+view).classList.add("active");
document.getElementById("legend-commands").style.display = view==="commands" ? "flex" : "none";
});
});
/* ---------------- COMMANDS TAB ---------------- */
function levelClass(l){ if(l.startsWith("dev")) return "pill-dev"; if(l.startsWith("gm")) return "pill-gm"; return "pill-any"; }
function syntaxHTML(item){
let out = `<span>${item.cmd}</span>`;
item.args.forEach(a=>{ out += ` <span class="arg">${a}</span>`; });
return out;
}
function renderCommands(filter){
const root = document.getElementById("results-commands");
const meta = document.getElementById("resultmeta-commands");
root.innerHTML = "";
let total=0, shown=0;
const f=(filter||"").trim().toLowerCase();
COMMANDS.forEach(group=>{
const filtered = group.items.filter(it=>{
total++;
if(!f) return true;
return (it.cmd+" "+it.args.join(" ")+" "+it.desc+" "+it.ex).toLowerCase().includes(f);
});
if(filtered.length===0) return;
shown += filtered.length;
const catEl = document.createElement("div");
catEl.className="category";
catEl.id="cat-"+group.cat.replace(/\W+/g,"-");
catEl.innerHTML = `<h2>${group.cat}</h2>`;
const list = document.createElement("div");
list.className="cmdlist";
filtered.forEach(it=>{
const el = document.createElement("div");
el.className="cmd";
el.innerHTML = `
<div class="cmd-row">
<span class="cmd-syntax">${syntaxHTML(it)}</span>
<span class="pill ${levelClass(it.level)}">${it.level}</span>
<button class="copybtn" type="button">copy</button>
</div>
<div class="cmd-desc">${it.desc}</div>
<div class="cmd-example">${it.ex}</div>`;
el.querySelector(".copybtn").addEventListener("click",(e)=>{
copyText(it.ex); e.target.textContent="copied"; setTimeout(()=>{e.target.textContent="copy";},1200);
});
list.appendChild(el);
});
catEl.appendChild(list);
root.appendChild(catEl);
});
if(shown===0) root.innerHTML = `<div class="empty">No command matches "${filter}".</div>`;
meta.textContent = f ? `${shown} of ${total} commands match` : `${total} commands across ${COMMANDS.length} categories`;
}
function buildCommandsNav(){
const nav = document.getElementById("nav-commands");
COMMANDS.forEach(group=>{
const link = document.createElement("div");
link.className="navlink";
link.innerHTML = `<span>${group.cat}</span><span class="count">${group.items.length}</span>`;
link.addEventListener("click", ()=>{
document.getElementById("q-commands").value="";
renderCommands("");
const t = document.getElementById("cat-"+group.cat.replace(/\W+/g,"-"));
if(t) t.scrollIntoView({behavior:"smooth", block:"start"});
});
nav.appendChild(link);
});
}
document.getElementById("q-commands").addEventListener("input", e=>renderCommands(e.target.value));
/* ---------------- ITEMS TAB ---------------- */
let ITEMS = {};
let selectedItem = null;
function fmtCmd(itemtype, itemnum){
const amount = document.getElementById("cfg-amount").value || 1;
const refine = document.getElementById("cfg-refine").value || 0;
const life = document.getElementById("cfg-life").value || 100;
const socket = document.getElementById("cfg-socket").value || 0;
const stats = document.getElementById("cfg-stats").value || 0;
const target = document.getElementById("give-to").value.trim();
if(target){
return `/give2 ${target} ${itemnum} ${itemtype} ${amount} ${refine} ${life} ${socket} ${stats}`;
}
return `/item ${itemnum} ${itemtype} ${amount} ${refine} ${life} ${socket} ${stats}`;
}
function openConfigurator(itemtype, itemnum, name){
selectedItem = {itemtype, itemnum, name};
document.getElementById("configurator").style.display = "block";
document.getElementById("cfg-title").textContent = `${name} · itemtype ${itemtype} / itemnum ${itemnum}`;
updateConfiguratorOutput();
document.querySelectorAll(".item-card").forEach(c=>c.classList.remove("sel"));
const el = document.querySelector(`.item-card[data-itemtype="${itemtype}"][data-itemnum="${itemnum}"]`);
if(el) el.classList.add("sel");
}
function updateConfiguratorOutput(){
if(!selectedItem) return;
document.getElementById("cfg-command").textContent = fmtCmd(selectedItem.itemtype, selectedItem.itemnum);
}
["cfg-amount","cfg-refine","cfg-life","cfg-socket","cfg-stats"].forEach(id=>{
document.getElementById(id).addEventListener("input", updateConfiguratorOutput);
});
document.getElementById("give-to").addEventListener("input", updateConfiguratorOutput);
document.getElementById("cfg-copy").addEventListener("click", (e)=>{
copyText(document.getElementById("cfg-command").textContent);
e.target.textContent = "copied!"; setTimeout(()=>{e.target.textContent="Copy command";},1200);
});
function renderItems(){
const root = document.getElementById("results-items");
const meta = document.getElementById("resultmeta-items");
const cat = document.getElementById("item-cat").value;
const q = document.getElementById("q-items").value.trim().toLowerCase();
root.innerHTML = "";
if(!q && cat === "__all__"){
meta.textContent = "Pick a category or search by name/id to browse — 9,951 items total.";
return;
}
let pool = [];
const cats = cat === "__all__" ? Object.keys(ITEMS) : [cat];
cats.forEach(k=>{
ITEMS[k].items.forEach(([id,name])=>pool.push({itemtype:+k, id, name, catname:ITEMS[k].name}));
});
let filtered = pool;
if(q){
filtered = pool.filter(it => it.name.toLowerCase().includes(q) || String(it.id)===q);
}
const CAP = 180;
const total = filtered.length;
const shown = filtered.slice(0, CAP);
shown.forEach(it=>{
const el = document.createElement("div");
el.className = "item-card";
el.dataset.itemtype = it.itemtype;
el.dataset.itemnum = it.id;
el.innerHTML = `<div class="nm">${it.name}</div><div class="id">type ${it.itemtype} · id ${it.id}</div>`;
el.addEventListener("click", ()=>openConfigurator(it.itemtype, it.id, it.name));
root.appendChild(el);
});
if(total===0){
root.innerHTML = `<div class="empty">No item matches.</div>`;
}
meta.textContent = total > CAP
? `Showing first ${CAP} of ${total} matches — narrow your search to see more.`
: `${total} item${total===1?"":"s"}`;
}
document.getElementById("q-items").addEventListener("input", renderItems);
document.getElementById("item-cat").addEventListener("change", renderItems);
/* ---------------- MAPS TAB ---------------- */
let MAPS = [];
function renderMaps(){
const root = document.getElementById("results-maps");
const sort = document.getElementById("map-sort").value;
const sorted = [...MAPS].sort((a,b)=>{
if(sort==="monsters") return b.totalMonsters - a.totalMonsters;
if(sort==="npcs") return b.npcCount - a.npcCount;
return a.id - b.id;
});
root.innerHTML = "";
sorted.forEach(m=>{
const el = document.createElement("div");
el.className = "map-card";
const bounds = m.bounds
? `<div>X range</div><b>${m.bounds.minX} – ${m.bounds.maxX}</b><div>Y range</div><b>${m.bounds.minY} – ${m.bounds.maxY}</b>`
: `<div>Coordinates</div><b>no spawn data</b><div></div><b></b>`;
el.innerHTML = `
<h3>Map ${m.id}</h3>
${m.note ? `<div class="note">${m.note}</div>` : ""}
<div class="map-stats">
<div>Spawn points</div><b>${m.spawnPoints}</b>
<div>Monsters (max pop.)</div><b>${m.totalMonsters}</b>
<div>NPCs</div><b>${m.npcCount}</b>
${bounds}
</div>`;
root.appendChild(el);
});
}
document.getElementById("map-sort").addEventListener("change", renderMaps);
/* ---------------- boot ---------------- */
buildCommandsNav();
renderCommands("");
fetch("items.json").then(r=>r.json()).then(data=>{
ITEMS = data;
const sel = document.getElementById("item-cat");
sel.innerHTML = `<option value="__all__">All categories</option>` +
Object.keys(data).sort((a,b)=>+a-+b).map(k=>`<option value="${k}">${data[k].name} (${data[k].items.length})</option>`).join("");
renderItems();
});
fetch("maps.json").then(r=>r.json()).then(data=>{
MAPS = data;
renderMaps();
});
</script>
</body>
</html>