diff --git a/webtools/index.html b/webtools/index.html
index 20d3e24..eb3f255 100644
--- a/webtools/index.html
+++ b/webtools/index.html
@@ -175,6 +175,8 @@
any usable by any GM character
gm 300+ accesslevel ≥ 300
@@ -254,17 +272,39 @@
Every map with active spawn or NPC data
-
Aggregated live from list_special_spawns and list_npcs.
+
Names pulled straight from LIST_ZONE.STB (row index = map id, same as CWorldServer::LoadZoneData
+ uses server-side); spawn/NPC counts aggregated live from list_special_spawns and list_npcs.
- 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.
+ 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).
+
+
+
+
Every placed NPC, with exact coordinates
+
Straight out of list_npcs. Click a card to copy the matching /tele command
+ and warp straight to it as GM.
+
+
+
+
+
+
+
+
+
Every monster/NPC type, ready to spawn
+
1,193 named entries straight out of LIST_NPC.STB (row index = montype, the same
+ id /mon and /spawn expect). Click one to copy a ready-to-paste /mon
+ command that drops it next to you.
+
+
+
+
+
@@ -456,7 +496,7 @@ function renderMaps(){
? `
Spawn points
${m.spawnPoints}
@@ -469,6 +509,81 @@ function renderMaps(){
}
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 = `
${n.name}
map ${n.map} · x ${n.x} · y ${n.y}
`;
+ 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 = `
No NPC matches.
`;
+ 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 = `
${m.name}
montype ${m.id}
`;
+ 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 = `
No monster matches.
`;
+ 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("");
@@ -485,6 +600,20 @@ 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 = `
All maps ` +
+ maps.map(m=>`
Map ${m} `).join("");
+ renderNpcs();
+});
+
+fetch("monsters.json").then(r=>r.json()).then(data=>{
+ MONSTERS = data;
+ renderMonsters();
+});