Files
Thomas e0d1f629db Add NPC/monster spawn browser with map and monster names to webtools
Map names now come from LIST_ZONE.STB (row index = map id, matching
CWorldServer::LoadZoneData). New NPCs tab lists all placed NPCs from
list_npcs with coordinates; new Monsters tab lists all 1,193 named montypes
from LIST_NPC.STB. Both are click-to-copy GM commands (/tele, /mon) for
quick GM navigation/spawning.
2026-07-10 14:20:12 +02:00

620 lines
29 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="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>
<div class="tab" data-view="npcs"><span class="ico">☺</span> NPCs</div>
<div class="tab" data-view="monsters"><span class="ico">☠</span> Monsters</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="side-panel" id="panel-npcs">
<div class="label">Map</div>
<select id="npc-map"></select>
<div class="label">Search by name</div>
<input id="q-npcs" type="text" placeholder="e.g. Lampa" autocomplete="off" />
</div>
<div class="side-panel" id="panel-monsters">
<div class="label">Spawn count</div>
<input id="mon-count" type="number" value="1" min="1" />
<div class="label">AI (0 = default)</div>
<input id="mon-ai" type="number" value="0" min="0" />
<div class="label">Search by name or id</div>
<input id="q-monsters" type="text" placeholder="e.g. jelly, or 210" autocomplete="off" />
</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>Names pulled straight from <code>LIST_ZONE.STB</code> (row index = map id, same as <code>CWorldServer::LoadZoneData</code>
uses server-side); spawn/NPC counts aggregated live from <code>list_special_spawns</code> and <code>list_npcs</code>.</p>
</div>
<div class="map-empty-note">
No minimap images are shipped here — this repo only ships the server side of KTRose, not the client's 3D/texture
assets. A handful of zone names are untranslated Korean (the dev-tool STB just was never localized for those rows).
</div>
<div class="map-grid" id="results-maps"></div>
</section>
<!-- ===================== NPCS ===================== -->
<section class="view" id="view-npcs">
<div class="pagehead">
<h1>Every placed NPC, with exact coordinates</h1>
<p>Straight out of <code>list_npcs</code>. Click a card to copy the matching <code>/tele</code> command
and warp straight to it as GM.</p>
</div>
<div class="resultmeta" id="resultmeta-npcs"></div>
<div class="item-grid" id="results-npcs"></div>
</section>
<!-- ===================== MONSTERS ===================== -->
<section class="view" id="view-monsters">
<div class="pagehead">
<h1>Every monster/NPC type, ready to spawn</h1>
<p>1,193 named entries straight out of <code>LIST_NPC.STB</code> (row index = <code>montype</code>, the same
id <code>/mon</code> and <code>/spawn</code> expect). Click one to copy a ready-to-paste <code>/mon</code>
command that drops it next to you.</p>
</div>
<div class="resultmeta" id="resultmeta-monsters"></div>
<div class="item-grid" id="results-monsters"></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>${m.name || ("Map "+m.id)} <span style="color:var(--ink-faint); font-weight:400;">#${m.id}</span></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);
/* ---------------- NPCS TAB ---------------- */
let NPCS = [];
function renderNpcs(){
const root = document.getElementById("results-npcs");
const meta = document.getElementById("resultmeta-npcs");
const map = document.getElementById("npc-map").value;
const q = document.getElementById("q-npcs").value.trim().toLowerCase();
root.innerHTML = "";
let filtered = NPCS;
if(map !== "__all__") filtered = filtered.filter(n => String(n.map) === map);
if(q) filtered = filtered.filter(n => n.name.toLowerCase().includes(q));
filtered.forEach(n=>{
const el = document.createElement("div");
el.className = "item-card";
const tele = `/tele ${n.map} ${n.x} ${n.y}`;
el.innerHTML = `<div class="nm">${n.name}</div><div class="id">map ${n.map} · x ${n.x} · y ${n.y}</div>`;
el.title = "Click to copy: " + tele;
el.addEventListener("click", ()=>{
copyText(tele);
const nm = el.querySelector(".nm");
const prev = nm.textContent;
nm.textContent = "Copied: " + tele;
setTimeout(()=>{ nm.textContent = prev; }, 1200);
});
root.appendChild(el);
});
if(filtered.length===0) root.innerHTML = `<div class="empty">No NPC matches.</div>`;
meta.textContent = `${filtered.length} of ${NPCS.length} NPCs`;
}
document.getElementById("q-npcs").addEventListener("input", renderNpcs);
document.getElementById("npc-map").addEventListener("change", renderNpcs);
/* ---------------- MONSTERS TAB ---------------- */
let MONSTERS = [];
function renderMonsters(){
const root = document.getElementById("results-monsters");
const meta = document.getElementById("resultmeta-monsters");
const q = document.getElementById("q-monsters").value.trim().toLowerCase();
root.innerHTML = "";
let filtered = MONSTERS;
if(q) filtered = filtered.filter(m => m.name.toLowerCase().includes(q) || String(m.id)===q);
const CAP = 180;
const total = filtered.length;
const shown = filtered.slice(0, CAP);
shown.forEach(m=>{
const el = document.createElement("div");
el.className = "item-card";
el.innerHTML = `<div class="nm">${m.name}</div><div class="id">montype ${m.id}</div>`;
el.addEventListener("click", ()=>{
const count = document.getElementById("mon-count").value || 1;
const ai = document.getElementById("mon-ai").value || 0;
const cmd = `/mon ${m.id} ${count} ${ai}`;
copyText(cmd);
const nm = el.querySelector(".nm");
const prev = nm.textContent;
nm.textContent = "Copied: " + cmd;
setTimeout(()=>{ nm.textContent = prev; }, 1200);
});
root.appendChild(el);
});
if(total===0) root.innerHTML = `<div class="empty">No monster matches.</div>`;
meta.textContent = total > CAP
? `Showing first ${CAP} of ${total} matches — narrow your search to see more.`
: `${total} of ${MONSTERS.length} monster types`;
}
document.getElementById("q-monsters").addEventListener("input", renderMonsters);
document.getElementById("mon-count").addEventListener("input", renderMonsters);
document.getElementById("mon-ai").addEventListener("input", renderMonsters);
/* ---------------- 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();
});
fetch("npcs.json").then(r=>r.json()).then(data=>{
NPCS = data;
const maps = [...new Set(data.map(n=>n.map))].sort((a,b)=>a-b);
const sel = document.getElementById("npc-map");
sel.innerHTML = `<option value="__all__">All maps</option>` +
maps.map(m=>`<option value="${m}">Map ${m}</option>`).join("");
renderNpcs();
});
fetch("monsters.json").then(r=>r.json()).then(data=>{
MONSTERS = data;
renderMonsters();
});
</script>
</body>
</html>