{const{enabled:c}=xs();return c?s.jsx("div",{className:"ms-banner",role:"status",children:"🐸 Glad midsommar! 🌼 Små grodorna, små grodorna… 🥂"}):null},Ld=["🐸","🌼","🌸","🇸🇪","🌿"],Id=()=>{const{enabled:c}=xs();return g.useEffect(()=>{if(!c||window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;const r=document.createElement("div");r.className="ms-rain",document.body.appendChild(r);const m=()=>{const b=document.createElement("span");b.textContent=Ld[Math.floor(Math.random()*Ld.length)],b.style.left=Math.floor(Math.random()*100)+"vw";const j=6+Math.random()*5;b.style.animationDuration=j.toFixed(2)+"s",b.style.fontSize=14+Math.floor(Math.random()*16)+"px",r.appendChild(b),window.setTimeout(()=>b.remove(),j*1e3+250)},o=window.setInterval(m,450);return()=>{window.clearInterval(o),r.remove()}},[c]),null},Ny=({data:c})=>{const r=Ey(),[m,o]=g.useState(!1),[b,j]=g.useState(!1),[M,N]=g.useState(null),y=g.useMemo(()=>Array.from(c.characters.values()).filter(R=>R.telemetry).map(R=>R.telemetry),[c.characters]),T=g.useMemo(()=>new Map(Array.from(c.characters.values()).filter(R=>R.vitals).map(R=>[R.name,R.vitals])),[c.characters]),[q,p]=g.useState("");g.useEffect(()=>{fetch("/api/api-version",{credentials:"include"}).then(R=>R.json()).then(R=>p(R.version??"")).catch(()=>{})},[]);const A=g.useCallback(R=>{N(L=>L===R?null:R)},[]);return s.jsx(U0,{children:s.jsxs("div",{className:"ml-layout",children:[s.jsx(Fd,{}),s.jsx(Id,{}),s.jsx(ay,{players:y,vitals:T,serverHealth:c.serverHealth,totalRares:c.totalRares,totalKills:c.totalKills,getColor:r,onSelectPlayer:A,showHeatmap:m,showPortals:b,onToggleHeatmap:o,onTogglePortals:j,version:q,selectedPlayer:M}),s.jsx(Q0,{players:y,getColor:r,onSelectPlayer:A,showHeatmap:m,showPortals:b,selectedPlayer:M}),s.jsx($d,{characters:c.characters,chatMessages:c.chatMessages,nearbyObjects:c.nearbyObjects,inventoryVersions:c.inventoryVersions,equipmentCantrips:c.equipmentCantrips,characterStats:c.characterStats,socket:c.socketRef.current}),s.jsx(zy,{recentRares:c.recentRares}),s.jsx(Ay,{deathAlerts:c.deathAlerts})]})})};function My(c){const r=g.useRef(null),m=g.useRef(0),o=g.useRef(c);o.current=c;const b=g.useCallback(()=>{var M;if(((M=r.current)==null?void 0:M.readyState)===WebSocket.OPEN)return;const j=new WebSocket(H0());r.current=j,j.addEventListener("message",N=>{try{const y=JSON.parse(N.data);o.current(y)}catch{}}),j.addEventListener("close",()=>{r.current=null,m.current=window.setTimeout(b,2e3)}),j.addEventListener("error",()=>{j.close()})},[]);return g.useEffect(()=>(b(),()=>{var j;clearTimeout(m.current),(j=r.current)==null||j.close(),r.current=null}),[b]),r}function Pd(){const[c,r]=g.useState(new Map),[m,o]=g.useState(null),[b,j]=g.useState(0),[M,N]=g.useState(0),[y,T]=g.useState([]),q=g.useRef(new Map),[p,A]=g.useState(0),[R,L]=g.useState(new Map),$=g.useRef(new Map),[Y,C]=g.useState(0),V=g.useRef(new Map),[F,zt]=g.useState(0),[Ut,lt]=g.useState([]),[at,Z]=g.useState(new Map),J=g.useRef(c);J.current=c;const P=g.useCallback((_,B)=>{r(Q=>{const H=new Map(Q),w=H.get(_)??{name:_,telemetry:null,vitals:null,combat:null,lastUpdate:0};return H.set(_,B(w)),H})},[]),Rt=g.useCallback(_=>{var B,Q;if(_.type){if(_.type==="telemetry"){const H=_;P(H.character_name,w=>({...w,telemetry:H,lastUpdate:Date.now()}))}else if(_.type==="vitals"){const H=_,w=(B=J.current.get(H.character_name))==null?void 0:B.vitals;w&&(w.vitae??0)===0&&(H.vitae??0)>0&<(ot=>[...ot,{character_name:H.character_name,vitae:H.vitae,timestamp:new Date().toISOString()}].slice(-50)),P(H.character_name,ot=>({...ot,vitals:H,lastUpdate:Date.now()}))}else if(_.type==="combat_stats"){const H=_;P(H.character_name,w=>({...w,combat:H,lastUpdate:Date.now()}))}else if(_.type==="rare"){const H=_;T(w=>[H,...w].slice(0,50))}else if(_.type==="inventory_delta"){const H=_;H.character_name&&L(w=>{const ot=new Map(w);return ot.set(H.character_name,(ot.get(H.character_name)??0)+1),ot})}else if(_.type==="character_stats"){const H=_;V.current.set(H.character_name,_),zt(w=>w+1)}else if(_.type==="equipment_cantrip_state"){const H=_;$.current.set(H.character_name,H),C(w=>w+1)}else if(_.type==="dungeon_map"){const H=_;H.landblock&&(window.__dungeonMapCache||(window.__dungeonMapCache={}),window.__dungeonMapCache[H.landblock]=H)}else if(_.type==="nearby_objects"){const H=_;if(Z(w=>{const ot=new Map(w);return ot.set(H.character_name,H),ot}),H.is_dungeon&&H.landblock&&!((Q=window.__dungeonMapCache)!=null&&Q[H.landblock])){const w=jt.current;(w==null?void 0:w.readyState)===WebSocket.OPEN&&w.send(JSON.stringify({type:"request_dungeon_map",landblock:H.landblock}))}}else if(_.type==="chat"){const H=_,w=q.current.get(H.character_name)??[];w.push({text:H.text,color:H.color,timestamp:H.timestamp}),w.length>1e3&&w.splice(0,w.length-1e3),q.current.set(H.character_name,w),A(ot=>ot+1)}}},[P]),jt=My(Rt);g.useEffect(()=>{const _=async()=>{try{const Q=await J0();r(H=>{var ot;const w=new Map(H);for(const dt of Q.players??[]){const et=w.get(dt.character_name);w.set(dt.character_name,{name:dt.character_name,telemetry:dt,vitals:(et==null?void 0:et.vitals)??null,combat:(et==null?void 0:et.combat)??null,lastUpdate:Date.now()})}for(const dt of w.keys())(ot=Q.players)!=null&&ot.some(et=>et.character_name===dt)||w.delete(dt);return w})}catch{}};_();const B=setInterval(_,5e3);return()=>clearInterval(B)},[]),g.useEffect(()=>{const _=async()=>{try{const Q=await W0();for(const H of Q.stats??[])P(H.character_name,w=>({...w,combat:{...H,type:"combat_stats"}}))}catch{}};_();const B=setInterval(_,3e4);return()=>clearInterval(B)},[P]),g.useEffect(()=>{const _=async()=>{try{o(await $0())}catch{}};_();const B=setInterval(_,3e4);return()=>clearInterval(B)},[]),g.useEffect(()=>{const _=async()=>{try{const[Q,H]=await Promise.all([F0(),I0()]);j(Q.all_time??0),N(H.total??0)}catch{}};_();const B=setInterval(_,3e5);return()=>clearInterval(B)},[]);const Dl=g.useMemo(()=>q.current,[p]),Al=g.useMemo(()=>$.current,[Y]),xl=g.useMemo(()=>V.current,[F]);return{characters:c,serverHealth:m,totalRares:b,totalKills:M,recentRares:y,chatMessages:Dl,nearbyObjects:at,inventoryVersions:R,equipmentCantrips:Al,characterStats:xl,deathAlerts:Ut,socketRef:jt}}const _s=({characters:c})=>{const[r,m]=g.useState("kph"),[o,b]=g.useState(!1),[j,M]=g.useState(null),N=g.useMemo(()=>{const p=Array.from(c.values()).filter(A=>A.telemetry).map(A=>{var L,$,Y;const R=A.telemetry;return{name:A.name,kills:R.kills??0,kph:parseInt(R.kills_per_hour)||0,totalKills:R.total_kills??0,rares:R.total_rares??0,sessionRares:R.session_rares??0,deaths:parseInt(R.deaths)||0,totalDeaths:parseInt(R.total_deaths)||0,uptime:((L=R.onlinetime)==null?void 0:L.replace(/^00\./,""))??"",state:R.vt_state??"idle",tapers:parseInt(R.prismatic_taper_count)||0,hp:(($=A.vitals)==null?void 0:$.health_percentage)??0,vitae:((Y=A.vitals)==null?void 0:Y.vitae)??0}});return p.sort((A,R)=>{let L=0;switch(r){case"name":L=A.name.localeCompare(R.name);break;case"kills":L=A.kills-R.kills;break;case"kph":L=A.kph-R.kph;break;case"rares":L=A.rares-R.rares;break;case"deaths":L=A.totalDeaths-R.totalDeaths;break;case"uptime":L=A.uptime.localeCompare(R.uptime);break;case"state":L=A.state.localeCompare(R.state);break}return o?L:-L}),p},[c,r,o]),y=p=>{r===p?b(!o):(m(p),b(!1))},T=p=>({padding:"4px 6px",cursor:"pointer",userSelect:"none",color:r===p?"#6af":"#888",fontSize:"0.65rem",fontWeight:600,whiteSpace:"nowrap",borderBottom:"1px solid #444"}),q=p=>r===p?o?" ▲":" ▼":"";return s.jsxs("div",{style:{flex:1,overflow:"auto",fontSize:"0.73rem"},children:[s.jsxs("table",{style:{width:"auto",borderCollapse:"collapse"},children:[s.jsx("thead",{children:s.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[s.jsxs("th",{style:{...T("name"),textAlign:"left"},onClick:()=>y("name"),children:["Character",q("name")]}),s.jsxs("th",{style:{...T("state"),textAlign:"center"},onClick:()=>y("state"),children:["State",q("state")]}),s.jsxs("th",{style:{...T("kph"),textAlign:"right"},onClick:()=>y("kph"),children:["KPH",q("kph")]}),s.jsxs("th",{style:{...T("kills"),textAlign:"right"},onClick:()=>y("kills"),children:["Session",q("kills")]}),s.jsx("th",{style:{textAlign:"right",padding:"4px 6px",color:"#888",fontSize:"0.65rem",fontWeight:600,borderBottom:"1px solid #444"},children:"Total"}),s.jsxs("th",{style:{...T("rares"),textAlign:"right"},onClick:()=>y("rares"),children:["Rares",q("rares")]}),s.jsxs("th",{style:{...T("deaths"),textAlign:"right"},onClick:()=>y("deaths"),children:["Deaths",q("deaths")]}),s.jsxs("th",{style:{...T("uptime"),textAlign:"right"},onClick:()=>y("uptime"),children:["Uptime",q("uptime")]}),s.jsx("th",{style:{textAlign:"right",padding:"4px 6px",color:"#888",fontSize:"0.65rem",fontWeight:600,borderBottom:"1px solid #444"},children:"HP%"}),s.jsx("th",{style:{textAlign:"right",padding:"4px 6px",color:"#888",fontSize:"0.65rem",fontWeight:600,borderBottom:"1px solid #444"},children:"Vitae"}),s.jsx("th",{style:{textAlign:"right",padding:"4px 6px",color:"#888",fontSize:"0.65rem",fontWeight:600,borderBottom:"1px solid #444"},children:"Tapers"})]})}),s.jsx("tbody",{children:N.map(p=>{const A=p.state.toLowerCase(),R=A==="combat"||A==="hunt",L=j===p.name;return s.jsxs("tr",{onClick:()=>M(L?null:p.name),style:{borderBottom:"1px solid #1a1a1a",cursor:"pointer",background:L?"rgba(102, 170, 255, 0.18)":void 0,outline:L?"1px solid rgba(102, 170, 255, 0.55)":void 0,outlineOffset:"-1px"},children:[s.jsx("td",{style:{padding:"3px 10px 3px 6px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap"},children:p.name}),s.jsx("td",{style:{textAlign:"center",padding:"3px 6px"},children:s.jsx("span",{style:{fontSize:"0.6rem",padding:"1px 6px",borderRadius:3,background:R?"rgba(68,204,68,0.15)":A==="idle"||A==="default"?"rgba(100,100,100,0.2)":"rgba(204,68,68,0.15)",color:R?"#4c4":A==="idle"||A==="default"?"#888":"#c44"},children:p.state})}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:"#4c4",fontVariantNumeric:"tabular-nums"},children:p.kph.toLocaleString()}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:"#ccc",fontVariantNumeric:"tabular-nums"},children:p.kills.toLocaleString()}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:"#888",fontVariantNumeric:"tabular-nums"},children:p.totalKills.toLocaleString()}),s.jsxs("td",{style:{textAlign:"right",padding:"3px 6px",color:"#fc0",fontVariantNumeric:"tabular-nums"},children:[p.rares,p.sessionRares>0?` (${p.sessionRares})`:""]}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:p.totalDeaths>0?"#c66":"#555",fontVariantNumeric:"tabular-nums"},children:p.totalDeaths}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:"#888",fontVariantNumeric:"tabular-nums"},children:p.uptime}),s.jsxs("td",{style:{textAlign:"right",padding:"3px 6px",fontVariantNumeric:"tabular-nums",color:p.hp>80?"#4c4":p.hp>40?"#ca0":"#c44"},children:[p.hp.toFixed(0),"%"]}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",fontVariantNumeric:"tabular-nums",color:p.vitae>0?"#f66":"#333"},children:p.vitae>0?`${p.vitae}%`:""}),s.jsx("td",{style:{textAlign:"right",padding:"3px 6px",color:"#888",fontVariantNumeric:"tabular-nums"},children:p.tapers.toLocaleString()})]},p.name)})})]}),N.length===0&&s.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No characters online"})]})},Cy=({id:c,zIndex:r,characters:m})=>s.jsx(kd,{id:c,title:"Player Dashboard",zIndex:r,width:850,height:500,children:s.jsx(_s,{characters:m})}),Oy=Object.freeze(Object.defineProperty({__proto__:null,PlayerDashboardContent:_s,PlayerDashboardWindow:Cy},Symbol.toStringTag,{value:"Module"})),Dy=()=>{const c=Pd(),[r,m]=g.useState("");g.useEffect(()=>{const b=document.title;return document.title="Overlord Dashboard",()=>{document.title=b}},[]),g.useEffect(()=>{fetch("/api/api-version",{credentials:"include"}).then(b=>b.json()).then(b=>m(b.version??"")).catch(()=>{})},[]);const o=Array.from(c.characters.values()).filter(b=>b.telemetry).length;return s.jsxs("div",{className:"ml-dashboard-page",children:[s.jsx(Fd,{}),s.jsx(Id,{}),s.jsxs("header",{className:"ml-dashboard-header",children:[s.jsx("span",{className:"ml-dashboard-title",children:"👥 Player Dashboard"}),s.jsxs("span",{className:"ml-dashboard-count",children:[o," online"]}),s.jsx("span",{style:{flex:1}}),r&&s.jsxs("span",{className:"ml-dashboard-version",children:["v",r]})]}),s.jsx("main",{className:"ml-dashboard-main",children:s.jsx(_s,{characters:c.characters})})]})},je={text:"",characters:"all",itemType:"all",weaponType:"",slots:[],cantrips:[],spellContains:"",ratings:{},itemSet:"",equippedOnly:!1,bonded:!1,attuned:!1,rare:!1,maxLevel:"",minValue:"",minWorkmanship:"",maxBurden:"",sortBy:"name",sortDir:"asc",page:1},W=c=>({value:c,label:c.replace(/^Legendary /,"")}),Uy=[{group:"Attributes",items:[W("Legendary Strength"),W("Legendary Endurance"),W("Legendary Quickness"),W("Legendary Coordination"),W("Legendary Willpower"),W("Legendary Focus")]},{group:"Weapon skills",items:[W("Legendary Heavy Weapon Aptitude"),W("Legendary Light Weapon Aptitude"),W("Legendary Finesse Weapon Aptitude"),W("Legendary Missile Weapon Aptitude"),W("Legendary Two Handed Combat Aptitude"),W("Legendary Dual Wield Aptitude"),W("Legendary Shield Aptitude"),W("Legendary Sneak Attack Prowess"),W("Legendary Dirty Fighting Prowess"),W("Legendary Recklessness Prowess"),W("Legendary Defender"),W("Legendary Blood Thirst")]},{group:"Magic",items:[W("Legendary War Magic Aptitude"),W("Legendary Void Magic Aptitude"),W("Legendary Creature Enchantment Aptitude"),W("Legendary Item Enchantment Aptitude"),W("Legendary Life Magic Aptitude"),W("Legendary Mana Conversion Prowess"),W("Legendary Arcane Prowess"),W("Legendary Hermetic Link"),W("Legendary Spirit Thirst"),W("Legendary Magic Resistance")]},{group:"Utility",items:[W("Legendary Summoning Prowess"),W("Legendary Healing Prowess"),W("Legendary Leadership"),W("Legendary Deception Prowess"),W("Legendary Person Attunement"),W("Legendary Magic Item Tinkering Expertise")]},{group:"Defense",items:[W("Legendary Invulnerability"),W("Legendary Impenetrability"),W("Legendary Impregnability"),W("Legendary Armor")]},{group:"Wards & Banes",items:[W("Legendary Flame Ward"),W("Legendary Frost Ward"),W("Legendary Acid Ward"),W("Legendary Storm Ward"),W("Legendary Slashing Ward"),W("Legendary Piercing Ward"),W("Legendary Bludgeoning Ward"),W("Legendary Piercing Bane"),W("Legendary Storm Bane")]}],Ry=["Ring","Bracelet","Neck","Trinket","Cloak"],By=["Head","Chest","Abdomen","Upper Arms","Lower Arms","Hands","Upper Legs","Lower Legs","Feet","Shield"],qy=[{value:"",label:"All weapons"},{value:"heavy",label:"Heavy"},{value:"light",label:"Light"},{value:"finesse",label:"Finesse"},{value:"two_handed",label:"Two-handed"},{value:"bow",label:"Bow"},{value:"crossbow",label:"Crossbow"},{value:"thrown",label:"Thrown"},{value:"caster",label:"Wand/Staff/Orb"}],ps=[{param:"min_damage_rating",label:"Damage rating",common:!0},{param:"min_crit_damage_rating",label:"Crit damage",common:!0},{param:"min_heal_boost_rating",label:"Heal boost",common:!0},{param:"min_vitality_rating",label:"Vitality",common:!0},{param:"min_armor",label:"Armor level"},{param:"min_damage_resist_rating",label:"Damage resist"},{param:"min_crit_resist_rating",label:"Crit resist"},{param:"min_crit_damage_resist_rating",label:"Crit dmg resist"},{param:"min_healing_resist_rating",label:"Healing resist"},{param:"min_nether_resist_rating",label:"Nether resist"},{param:"min_healing_rating",label:"Healing rating"},{param:"min_dot_resist_rating",label:"DoT resist"},{param:"min_life_resist_rating",label:"Life resist"},{param:"min_sneak_attack_rating",label:"Sneak attack"},{param:"min_recklessness_rating",label:"Recklessness"},{param:"min_deception_rating",label:"Deception"},{param:"min_pk_damage_rating",label:"PK damage"},{param:"min_pk_damage_resist_rating",label:"PK dmg resist"},{param:"min_gear_pk_damage_rating",label:"Gear PK dmg"},{param:"min_gear_pk_damage_resist_rating",label:"Gear PK resist"},{param:"min_tinks",label:"Tinks"}],bs=[{key:"name",label:"Item",sortKey:"name",defaultVisible:!0},{key:"character_name",label:"Character",sortKey:"character_name",defaultVisible:!0},{key:"slot_name",label:"Slot",defaultVisible:!0},{key:"value",label:"Value",sortKey:"value",defaultVisible:!0},{key:"wield_level",label:"Wield",sortKey:"level",defaultVisible:!0},{key:"spell_names",label:"Spells / Cantrips",sortKey:"spell_names",defaultVisible:!0},{key:"armor_level",label:"Armor",sortKey:"armor",defaultVisible:!1},{key:"max_damage",label:"Max Dmg",sortKey:"damage",defaultVisible:!1},{key:"workmanship",label:"Work",sortKey:"workmanship",defaultVisible:!1},{key:"item_set_name",label:"Set",sortKey:"item_set",defaultVisible:!1},{key:"object_class_name",label:"Type",sortKey:"item_type_name",defaultVisible:!1},{key:"burden",label:"Burden",defaultVisible:!1},{key:"condition_percent",label:"Cond %",defaultVisible:!1},{key:"last_updated",label:"Updated",sortKey:"last_updated",defaultVisible:!1}],Hy=200,tm="inv.visibleColumns";function Ly(c){const r={};for(const o of Object.keys(je))JSON.stringify(c[o])!==JSON.stringify(je[o])&&(r[o]=c[o]);const m=new URL(window.location.href);m.searchParams.set("view","inventory"),Object.keys(r).length?m.searchParams.set("q",JSON.stringify(r)):m.searchParams.delete("q");try{window.history.replaceState(null,"",m)}catch{}}const Yy=new Set(["all","armor","jewelry","weapon","clothing","shirt","pants"]),Vy=new Set(["asc","desc"]);function Yd(c){return Array.isArray(c)&&c.every(r=>typeof r=="string")}function Xy(c,r){switch(c){case"characters":return r==="all"||Yd(r)?r:void 0;case"slots":case"cantrips":return Yd(r)?r:void 0;case"itemType":return typeof r=="string"&&Yy.has(r)?r:void 0;case"sortDir":return typeof r=="string"&&Vy.has(r)?r:void 0;case"ratings":{if(typeof r!="object"||r===null||Array.isArray(r))return;const m={};for(const[o,b]of Object.entries(r))(b===""||typeof b=="number")&&(m[o]=b);return m}case"text":case"weaponType":case"spellContains":case"itemSet":case"sortBy":return typeof r=="string"?r:void 0;case"equippedOnly":case"bonded":case"attuned":case"rare":return typeof r=="boolean"?r:void 0;case"maxLevel":case"minValue":case"minWorkmanship":case"maxBurden":return r===""||typeof r=="number"?r:void 0;case"page":return typeof r=="number"&&Number.isInteger(r)&&r>0?r:void 0;default:return}}function wy(){try{const c=new URLSearchParams(window.location.search).get("q");if(!c)return je;const r=JSON.parse(c);if(typeof r!="object"||r===null||Array.isArray(r))return je;const m={...je};for(const o of Object.keys(je)){if(!(o in r))continue;const b=Xy(o,r[o]);b!==void 0&&(m[o]=b)}return m}catch{return je}}function Gy(c){const r=new URLSearchParams;switch(c.characters==="all"?r.set("include_all_characters","true"):c.characters.length===1?r.set("character",c.characters[0]):r.set("characters",c.characters.join(",")),c.text&&r.set("text",c.text),c.itemType){case"armor":r.set("armor_only","true");break;case"jewelry":r.set("jewelry_only","true");break;case"clothing":r.set("clothing_only","true");break;case"shirt":r.set("shirt_only","true");break;case"pants":r.set("pants_only","true");break;case"weapon":r.set("weapon_only","true"),c.weaponType&&r.set("weapon_type",c.weaponType);break}c.slots.length&&r.set("slot_names",c.slots.join(",")),c.cantrips.length&&r.set("legendary_cantrips",c.cantrips.join(",")),c.spellContains&&r.set("spell_contains",c.spellContains);for(const[m,o]of Object.entries(c.ratings))o!==""&&r.set(m,String(o));return c.itemSet&&r.set("item_set",c.itemSet),c.equippedOnly&&r.set("equipment_status","equipped"),c.bonded&&r.set("bonded","true"),c.attuned&&r.set("attuned","true"),c.rare&&r.set("is_rare","true"),c.maxLevel!==""&&r.set("max_level",String(c.maxLevel)),c.minValue!==""&&r.set("min_value",String(c.minValue)),c.minWorkmanship!==""&&r.set("min_workmanship",String(c.minWorkmanship)),c.maxBurden!==""&&r.set("max_burden",String(c.maxBurden)),r.set("sort_by",c.sortBy),r.set("sort_dir",c.sortDir),r.set("page",String(c.page)),r.set("limit",String(Hy)),r}function Qy(){const[c,r]=g.useState(wy),[m,o]=g.useState(null),[b,j]=g.useState(!1),[M,N]=g.useState(null),[y,T]=g.useState(null),q=g.useRef(null),p=g.useCallback(R=>{r(L=>({...L,page:"page"in R?L.page:1,...R}))},[]),A=g.useCallback(()=>r(je),[]);return g.useEffect(()=>{const R=setTimeout(async()=>{var Y;Ly(c),(Y=q.current)==null||Y.abort();const L=new AbortController;q.current=L,j(!0);const $=performance.now();try{const C=await fetch(`/api/inv/search/items?${Gy(c)}`,{credentials:"include",signal:L.signal});if(!C.ok)throw new Error(`search: HTTP ${C.status}`);const V=await C.json();if(V.error)throw new Error(V.error);o(V),N(null),T(Math.round(performance.now()-$))}catch(C){(C==null?void 0:C.name)!=="AbortError"&&N(String((C==null?void 0:C.message)??C))}finally{q.current===L&&j(!1)}},400);return()=>clearTimeout(R)},[c]),{filters:c,update:p,reset:A,result:m,loading:b,error:M,queryMs:y}}function Zy({search:c}){const{filters:r,update:m}=c,[o,b]=g.useState([]),[j,M]=g.useState(new Set),[N,y]=g.useState("");g.useEffect(()=>{It("/inv/characters/list").then(A=>b(A.characters.map(R=>R.character_name).sort((R,L)=>R.localeCompare(L)))).catch(()=>{}),It("/live").then(A=>M(new Set(A.players.map(R=>R.character_name)))).catch(()=>{})},[]);const T=g.useMemo(()=>r.characters==="all"?new Set(o):new Set(r.characters),[r.characters,o]),q=A=>{const R=new Set(T);R.has(A)?R.delete(A):R.add(A),m({characters:R.size===o.length?"all":[...R]})},p=o.filter(A=>A.toLowerCase().includes(N.toLowerCase()));return s.jsxs(ze,{title:"Characters",defaultOpen:!0,badge:r.characters==="all"?"All":r.characters.length,children:[s.jsx("input",{className:"inv-minisearch",placeholder:"filter characters…",value:N,onChange:A=>y(A.target.value)}),s.jsxs("div",{className:"inv-links",children:[s.jsx("span",{className:"inv-linky",onClick:()=>m({characters:"all"}),children:"All"})," · ",s.jsx("span",{className:"inv-linky",onClick:()=>m({characters:[]}),children:"None"})," · ",s.jsx("span",{className:"inv-linky",onClick:()=>m({characters:o.filter(A=>j.has(A))}),children:"Online"})]}),p.map(A=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:T.has(A),onChange:()=>q(A)}),A," ",j.has(A)&&s.jsx("span",{className:"inv-online"})]},A))]})}function Ky({search:c}){const{filters:r,update:m}=c,[o,b]=g.useState(""),j=N=>m({cantrips:r.cantrips.includes(N)?r.cantrips.filter(y=>y!==N):[...r.cantrips,N]}),M=N=>N.toLowerCase().includes(o.toLowerCase());return s.jsxs(ze,{title:"Cantrips",defaultOpen:!0,badge:r.cantrips.length||void 0,children:[s.jsx("input",{className:"inv-minisearch",placeholder:"find cantrip… e.g. invuln",value:o,onChange:N=>b(N.target.value)}),r.cantrips.length>0&&s.jsxs(s.Fragment,{children:[s.jsx("div",{className:"inv-subhead",children:"Selected"}),r.cantrips.map(N=>s.jsxs("label",{className:"inv-fitem inv-gold",children:[s.jsx("input",{type:"checkbox",checked:!0,onChange:()=>j(N)}),N.replace(/^Legendary /,"")]},N))]}),Uy.map(N=>{const y=N.items.filter(T=>!r.cantrips.includes(T.value)&&M(T.label));return y.length?s.jsxs("div",{children:[s.jsx("div",{className:"inv-subhead",children:N.group}),y.map(T=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:!1,onChange:()=>j(T.value)})," ",T.label]},T.value))]},N.group):null}),s.jsx("div",{className:"inv-subhead",children:"Any-tier spell search"}),s.jsx("input",{className:"inv-minisearch",placeholder:"spell name contains… e.g. Epic Invuln",value:r.spellContains,onChange:N=>m({spellContains:N.target.value})})]})}function ze(c){const[r,m]=g.useState(c.defaultOpen??!1);return s.jsxs("div",{className:`inv-grp${r?" inv-open":""}`,children:[s.jsxs("div",{className:"inv-grp-head",onClick:()=>m(o=>!o),children:[s.jsx("span",{className:"inv-arrow",children:"▶"})," ",c.title,c.badge?s.jsx("span",{className:"inv-badge",children:c.badge}):null]}),r&&s.jsx("div",{className:"inv-grp-body",children:c.children})]})}function ky({search:c}){const{filters:r,update:m}=c,[o,b]=g.useState(!1),[j,M]=g.useState(!1),[N,y]=g.useState([]),[T,q]=g.useState("");g.useEffect(()=>{It("/inv/sets/list").then(C=>y(C.sets)).catch(()=>{})},[]);const p=C=>m({slots:r.slots.includes(C)?r.slots.filter(V=>V!==C):[...r.slots,C]}),A=(C,V)=>m({ratings:{...r.ratings,[C]:V===""?"":Number(V)}}),R=Object.values(r.ratings).filter(C=>C!=="").length,L=[r.equippedOnly,r.bonded,r.attuned,r.rare].filter(Boolean).length,$=[r.maxLevel,r.minValue,r.minWorkmanship,r.maxBurden].filter(C=>C!=="").length,Y=[["all","All items"],["armor","Armor"],["jewelry","Jewelry"],["weapon","Weapons"],["clothing","Clothing"],["shirt","Shirts"],["pants","Pants"]];return s.jsxs("div",{className:"inv-sidebar",children:[s.jsx(Zy,{search:c}),s.jsxs(ze,{title:"Item type",defaultOpen:!0,badge:r.itemType!=="all"?1:void 0,children:[Y.map(([C,V])=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"radio",name:"inv-t",checked:r.itemType===C,onChange:()=>m({itemType:C,weaponType:""})})," ",V]},C)),r.itemType==="weapon"&&s.jsx("select",{className:"inv-minisearch",value:r.weaponType,onChange:C=>m({weaponType:C.target.value}),children:qy.map(C=>s.jsx("option",{value:C.value,children:C.label},C.value))})]}),s.jsxs(ze,{title:"Slots",defaultOpen:!0,badge:r.slots.length||void 0,children:[Ry.map(C=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.slots.includes(C),onChange:()=>p(C)})," ",C]},C)),s.jsxs("span",{className:"inv-linky",onClick:()=>b(C=>!C),children:[o?"hide":"show"," armor slots ",o?"▴":"▾"]}),o&&By.map(C=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.slots.includes(C),onChange:()=>p(C)})," ",C]},C))]}),s.jsx(Ky,{search:c}),s.jsxs(ze,{title:"Ratings",badge:R||void 0,children:[ps.filter(C=>j||C.common).map(C=>s.jsxs("div",{className:"inv-range",children:[s.jsxs("label",{children:[C.label," ≥"]}),s.jsx("input",{type:"number",value:r.ratings[C.param]??"",placeholder:"min",onChange:V=>A(C.param,V.target.value)})]},C.param)),s.jsx("span",{className:"inv-linky",onClick:()=>M(C=>!C),children:j?"common ratings ▴":`all ${ps.length} ratings ▾`})]}),s.jsxs(ze,{title:"Equipment sets",badge:r.itemSet?1:void 0,children:[s.jsx("input",{className:"inv-minisearch",placeholder:"find set…",value:T,onChange:C=>q(C.target.value)}),s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"radio",name:"inv-set",checked:r.itemSet==="",onChange:()=>m({itemSet:""})})," Any set"]}),N.filter(C=>C.id.toLowerCase().includes(T.toLowerCase())).map(C=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"radio",name:"inv-set",checked:r.itemSet===C.id,onChange:()=>m({itemSet:C.id})})," ",C.id," ",s.jsxs("span",{className:"inv-dim",children:["(",C.item_count,")"]})]},C.id))]}),s.jsxs(ze,{title:"Item state",badge:L||void 0,children:[s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.equippedOnly,onChange:C=>m({equippedOnly:C.target.checked})})," Equipped only"]}),s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.bonded,onChange:C=>m({bonded:C.target.checked})})," Bonded"]}),s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.attuned,onChange:C=>m({attuned:C.target.checked})})," Attuned"]}),s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:r.rare,onChange:C=>m({rare:C.target.checked})})," Rare"]})]}),s.jsxs(ze,{title:"Reqs & value",badge:$||void 0,children:[s.jsxs("div",{className:"inv-range",children:[s.jsx("label",{children:"Wield lvl ≤"}),s.jsx("input",{type:"number",value:r.maxLevel,placeholder:"max",onChange:C=>m({maxLevel:C.target.value===""?"":Number(C.target.value)})})]}),s.jsxs("div",{className:"inv-range",children:[s.jsx("label",{children:"Value ≥"}),s.jsx("input",{type:"number",value:r.minValue,placeholder:"min",onChange:C=>m({minValue:C.target.value===""?"":Number(C.target.value)})})]}),s.jsxs("div",{className:"inv-range",children:[s.jsx("label",{children:"Workmanship ≥"}),s.jsx("input",{type:"number",value:r.minWorkmanship,placeholder:"min",onChange:C=>m({minWorkmanship:C.target.value===""?"":Number(C.target.value)})})]}),s.jsxs("div",{className:"inv-range",children:[s.jsx("label",{children:"Burden ≤"}),s.jsx("input",{type:"number",value:r.maxBurden,placeholder:"max",onChange:C=>m({maxBurden:C.target.value===""?"":Number(C.target.value)})})]})]})]})}function Jy({search:c}){const{filters:r,update:m}=c,o=[];r.text&&o.push({label:`"${r.text}"`,remove:()=>m({text:""})}),r.characters!=="all"&&o.push({label:`${r.characters.length} character${r.characters.length===1?"":"s"}`,remove:()=>m({characters:"all"})}),r.itemType!=="all"&&o.push({label:r.itemType[0].toUpperCase()+r.itemType.slice(1)+(r.weaponType?`: ${r.weaponType}`:""),remove:()=>m({itemType:"all",weaponType:""})});for(const b of r.slots)o.push({label:`Slot: ${b}`,remove:()=>m({slots:r.slots.filter(j=>j!==b)})});for(const b of r.cantrips)o.push({label:b.replace(/^Legendary /,"Leg. "),gold:!0,remove:()=>m({cantrips:r.cantrips.filter(j=>j!==b)})});r.spellContains&&o.push({label:`Spell: ${r.spellContains}`,remove:()=>m({spellContains:""})});for(const[b,j]of Object.entries(r.ratings))if(j!==""){const M=ps.find(N=>N.param===b);o.push({label:`${(M==null?void 0:M.label)??b} ≥ ${j}`,remove:()=>{const{[b]:N,...y}=r.ratings;m({ratings:y})}})}return r.itemSet&&o.push({label:`Set: ${r.itemSet}`,remove:()=>m({itemSet:""})}),r.equippedOnly&&o.push({label:"Equipped",remove:()=>m({equippedOnly:!1})}),r.bonded&&o.push({label:"Bonded",remove:()=>m({bonded:!1})}),r.attuned&&o.push({label:"Attuned",remove:()=>m({attuned:!1})}),r.rare&&o.push({label:"Rare",remove:()=>m({rare:!1})}),r.maxLevel!==""&&o.push({label:`Wield ≤ ${r.maxLevel}`,remove:()=>m({maxLevel:""})}),r.minValue!==""&&o.push({label:`Value ≥ ${r.minValue}`,remove:()=>m({minValue:""})}),r.minWorkmanship!==""&&o.push({label:`Work ≥ ${r.minWorkmanship}`,remove:()=>m({minWorkmanship:""})}),r.maxBurden!==""&&o.push({label:`Burden ≤ ${r.maxBurden}`,remove:()=>m({maxBurden:""})}),o.length?s.jsxs("div",{className:"inv-chipsrow",children:[s.jsx("span",{className:"inv-chips-lbl",children:"ACTIVE"}),o.map((b,j)=>s.jsxs("span",{className:`inv-chip${b.gold?" inv-chip-gold":""}`,children:[b.label," ",s.jsx("span",{className:"inv-chip-x",onClick:b.remove,children:"×"})]},j))]}):null}function Wy(){try{const c=localStorage.getItem(tm);if(c)return new Set(JSON.parse(c))}catch{}return new Set(bs.filter(c=>c.defaultVisible).map(c=>c.key))}function $y(c,r){switch(r){case"name":return s.jsxs(s.Fragment,{children:[c.name,c.is_equipped&&s.jsx("span",{className:"inv-equipped",children:" ⚔"})]});case"spell_names":return s.jsx("span",{className:"inv-spells",children:(c.spell_names??[]).map((m,o)=>s.jsxs("span",{children:[o>0&&", ",s.jsx("span",{className:/legendary/i.test(m)?"inv-leg":"",children:m})]},o))});case"value":return c.value!=null?c.value.toLocaleString():"—";case"last_updated":return c.last_updated?c.last_updated.slice(0,16).replace("T"," "):"—";default:{const m=c[r];return m==null||m===-1?"—":String(m)}}}function Fy({search:c,selected:r,onSelect:m}){const{filters:o,update:b,result:j}=c,[M,N]=g.useState(Wy),[y,T]=g.useState(!1),q=g.useRef(null),p=g.useMemo(()=>bs.filter(Y=>M.has(Y.key)),[M]),A=(j==null?void 0:j.items)??[];g.useEffect(()=>{try{localStorage.setItem(tm,JSON.stringify([...M]))}catch{}},[M]),g.useEffect(()=>{const Y=C=>{var zt,Ut;if(((zt=C.target)==null?void 0:zt.tagName)==="INPUT"||((Ut=C.target)==null?void 0:Ut.tagName)==="SELECT")return;if(C.key==="Escape"){m(null);return}if(C.key!=="ArrowDown"&&C.key!=="ArrowUp")return;C.preventDefault();const V=r?A.indexOf(r):-1,F=C.key==="ArrowDown"?Math.min(V+1,A.length-1):Math.max(V-1,0);A[F]&&m(A[F])};return window.addEventListener("keydown",Y),()=>window.removeEventListener("keydown",Y)},[A,r,m]);const R=Y=>{Y&&(o.sortBy===Y?b({sortDir:o.sortDir==="asc"?"desc":"asc"}):b({sortBy:Y,sortDir:"asc"}))},L=j?Math.max(1,Math.ceil(j.total_count/j.limit)):1,$=Y=>{var C;b({page:Math.min(Math.max(1,Y),L)}),(C=q.current)==null||C.scrollTo(0,0)};return s.jsxs("div",{className:"inv-results",ref:q,children:[s.jsxs("div",{className:"inv-colpicker-anchor",children:[s.jsx("button",{className:"inv-btn inv-colpicker-btn",onClick:()=>T(Y=>!Y),children:"⚙ Columns"}),y&&s.jsx("div",{className:"inv-colpicker",children:bs.map(Y=>s.jsxs("label",{className:"inv-fitem",children:[s.jsx("input",{type:"checkbox",checked:M.has(Y.key),onChange:()=>{const C=new Set(M);C.has(Y.key)?C.delete(Y.key):C.add(Y.key),N(C)}})," ",Y.label]},Y.key))})]}),s.jsxs("table",{children:[s.jsx("thead",{children:s.jsx("tr",{children:p.map(Y=>s.jsx("th",{className:o.sortBy===Y.sortKey?`inv-sorted-${o.sortDir}`:"",onClick:()=>R(Y.sortKey),children:Y.label},Y.key))})}),s.jsxs("tbody",{children:[A.map((Y,C)=>s.jsx("tr",{className:Y===r?"inv-sel":"",onClick:()=>m(Y===r?null:Y),children:p.map(V=>s.jsx("td",{children:$y(Y,V.key)},V.key))},C)),!A.length&&s.jsx("tr",{children:s.jsx("td",{colSpan:p.length,className:"inv-dim",children:"No items match."})})]})]}),s.jsxs("div",{className:"inv-pager",children:[s.jsx("span",{className:"inv-pg",onClick:()=>$(o.page-1),children:"◀"}),s.jsxs("span",{children:["page ",(j==null?void 0:j.page)??1," / ",L]}),s.jsx("span",{className:"inv-pg",onClick:()=>$(o.page+1),children:"▶"}),s.jsxs("span",{className:"inv-pager-right",children:[(j==null?void 0:j.limit)??200," / page"]})]})]})}function Ol({k:c,v:r}){return s.jsxs("div",{className:"inv-kv",children:[s.jsx("span",{children:c}),s.jsx("b",{children:r??"—"})]})}function Iy({item:c,onClose:r}){var m,o;return s.jsxs("div",{className:"inv-detail",children:[s.jsx("span",{className:"inv-closex",onClick:r,children:"×"}),s.jsx("h3",{children:c.name}),s.jsxs("div",{className:"inv-detail-sub",children:[c.slot_name??c.object_class_name??""," · ",c.is_equipped?"⚔ Equipped":"📦 Inventory",c.is_rare&&" · ★ Rare"]}),s.jsx(Ol,{k:"Character",v:c.character_name}),s.jsx(Ol,{k:"Value",v:(m=c.value)==null?void 0:m.toLocaleString()}),s.jsx(Ol,{k:"Burden",v:c.burden}),s.jsx(Ol,{k:"Wield req",v:c.wield_level?`Level ${c.wield_level}`:"—"}),s.jsx(Ol,{k:"Workmanship",v:c.workmanship??"—"}),c.armor_level!=null&&c.armor_level>0&&s.jsx(Ol,{k:"Armor",v:c.armor_level}),c.max_damage!=null&&c.max_damage>0&&s.jsx(Ol,{k:"Max damage",v:c.max_damage}),c.condition_percent!=null&&s.jsx(Ol,{k:"Condition",v:`${c.condition_percent}%`}),c.item_set_name&&s.jsx(Ol,{k:"Set",v:c.item_set_name}),(c.is_bonded||c.is_attuned)&&s.jsx(Ol,{k:"Binding",v:[c.is_bonded&&"Bonded",c.is_attuned&&"Attuned"].filter(Boolean).join(", ")}),s.jsx("hr",{}),s.jsxs("div",{className:"inv-sphead",children:["Spells (",((o=c.spell_names)==null?void 0:o.length)??0,")"]}),(c.spell_names??[]).map((b,j)=>s.jsx("div",{className:`inv-sp${/legendary/i.test(b)?" inv-leg":""}`,children:b},j)),s.jsx("div",{className:"inv-hint",children:"↑/↓ next item · Esc close"})]})}function Vd(c){return`${c.name} ${c.character_name} ${c.last_updated??""}`}class Py extends g.Component{constructor(r){super(r),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(r,m){console.error("Inventory search crashed:",r,m)}render(){return this.state.hasError?s.jsx("div",{className:"inv-page",children:s.jsxs("div",{className:"inv-error",style:{margin:"auto",textAlign:"center",padding:24},children:[s.jsx("p",{style:{marginBottom:12},children:"Something went wrong loading the inventory search page."}),s.jsx("button",{className:"inv-btn",onClick:()=>{window.location.href="/?view=inventory"},children:"Reset filters"})]})}):this.props.children}}function tv(){const c=Qy(),[r,m]=g.useState(null);return g.useEffect(()=>{var M;if(!r)return;const o=((M=c.result)==null?void 0:M.items)??[],b=Vd(r),j=o.find(N=>Vd(N)===b);j&&j!==r?m(j):j||m(null)},[c.result]),s.jsxs("div",{className:"inv-page",children:[s.jsxs("div",{className:"inv-topbar",children:[s.jsx("span",{className:"inv-title",children:"⚔ Inventory Search"}),s.jsx("input",{className:"inv-searchbox",placeholder:"Search name or material…",value:c.filters.text,onChange:o=>c.update({text:o.target.value})}),s.jsx("button",{className:"inv-btn",onClick:()=>{c.reset(),m(null)},children:"Reset"}),s.jsx("span",{className:"inv-count",children:c.error?s.jsx("span",{className:"inv-error",children:c.error}):c.result?s.jsxs(s.Fragment,{children:[s.jsx("b",{children:c.result.total_count.toLocaleString()})," items",c.queryMs!=null&&s.jsxs(s.Fragment,{children:[" · ",c.queryMs," ms"]}),c.loading&&" · …"]}):"loading…"})]}),s.jsx(Jy,{search:c}),s.jsxs("div",{className:"inv-main",children:[s.jsx(ky,{search:c}),s.jsx(Fy,{search:c,selected:r,onSelect:m}),r&&s.jsx(Iy,{item:r,onClose:()=>m(null)})]})]})}function lv(){return s.jsx(Py,{children:s.jsx(tv,{})})}function ev(){const c=new URLSearchParams(window.location.search).get("view");return s.jsx(X0,{children:c==="dashboard"?s.jsx(Dy,{}):c==="inventory"?s.jsx(lv,{}):s.jsx(av,{})})}function av(){const c=Pd();return s.jsx(Ny,{data:c})}D0.createRoot(document.getElementById("root")).render(s.jsx(g.StrictMode,{children:s.jsx(ev,{})}));"serviceWorker"in navigator&&navigator.serviceWorker.register("/sw.js").catch(()=>{});export{kd as D,It as a,cv as b,uv as c,iv as d,ly as e,fv as f,rv as g,ov as h,s as j,sv as l,g as r,An as u};
diff --git a/static/index.html b/static/index.html
index 2fd48ca3..c14350ad 100644
--- a/static/index.html
+++ b/static/index.html
@@ -8,7 +8,7 @@
-
+
diff --git a/static/script.js b/static/script.js
new file mode 100644
index 00000000..f7518b6f
--- /dev/null
+++ b/static/script.js
@@ -0,0 +1,4940 @@
+/*
+ * script.js - Frontend logic for Dereth Tracker Single-Page Application.
+ * Handles WebSocket communication, UI rendering of player lists, map display,
+ * and user interactions (filtering, sorting, chat, stats windows).
+ */
+/**
+ * script.js - Frontend controller for Dereth Tracker SPA
+ *
+ * Responsibilities:
+ * - Establish WebSocket connections to receive live telemetry and chat data
+ * - Fetch and render live player lists, trails, and map dots
+ * - Handle user interactions: filtering, sorting, selecting players
+ * - Manage dynamic UI components: chat windows, stats panels, tooltips
+ * - Provide smooth pan/zoom of map overlay using CSS transforms
+ *
+ * Structure:
+ * 1. DOM references and constant definitions
+ * 2. Color palette and assignment logic
+ * 3. Sorting and filtering setup
+ * 4. Utility functions (coordinate mapping, color hashing)
+ * 5. UI window creation (stats, chat)
+ * 6. Rendering functions for list and map
+ * 7. Event listeners for map interactions and WebSocket messages
+ */
+/* ---------- Debug configuration ---------------------------------- */
+const DEBUG = false;
+function debugLog(...args) { if (DEBUG) console.log(...args); }
+
+function handleError(context, error, showUI = false) {
+ console.error(`[${context}]`, error);
+ if (showUI) {
+ const msg = document.createElement('div');
+ msg.className = 'error-toast';
+ msg.textContent = `${context}: ${error.message || 'Unknown error'}`;
+ document.body.appendChild(msg);
+ setTimeout(() => msg.remove(), GLOW_DURATION_MS);
+ }
+}
+
+/* ---------- DOM references --------------------------------------- */
+const wrap = document.getElementById('mapContainer');
+const group = document.getElementById('mapGroup');
+const img = document.getElementById('map');
+const dots = document.getElementById('dots');
+const trailsContainer = document.getElementById('trails');
+const list = document.getElementById('playerList');
+const btnContainer = document.getElementById('sortButtons');
+const tooltip = document.getElementById('tooltip');
+const coordinates = document.getElementById('coordinates');
+
+/* ---------- Element Pooling System for Performance ------------- */
+// Pools for reusing DOM elements to eliminate recreation overhead
+const elementPools = {
+ dots: [],
+ listItems: [],
+ activeDots: new Set(),
+ activeListItems: new Set()
+};
+
+// Performance tracking
+let performanceStats = {
+ // Lifetime totals
+ dotsCreated: 0,
+ dotsReused: 0,
+ listItemsCreated: 0,
+ listItemsReused: 0,
+ // Per-render stats (reset each render)
+ renderDotsCreated: 0,
+ renderDotsReused: 0,
+ renderListItemsCreated: 0,
+ renderListItemsReused: 0,
+ lastRenderTime: 0,
+ renderCount: 0
+};
+
+function createNewDot() {
+ const dot = document.createElement('div');
+ dot.className = 'dot';
+ performanceStats.dotsCreated++;
+ performanceStats.renderDotsCreated++;
+
+ // Add event listeners once when creating
+ dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData));
+ dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData));
+ dot.addEventListener('mouseleave', hideTooltip);
+ dot.addEventListener('click', () => {
+ if (dot.playerData) {
+ const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns);
+ selectPlayer(dot.playerData, x, y);
+ }
+ });
+
+ return dot;
+}
+
+function createNewListItem() {
+ const li = document.createElement('li');
+ li.className = 'player-item';
+ performanceStats.listItemsCreated++;
+ performanceStats.renderListItemsCreated++;
+
+ // Create the grid content container
+ const gridContent = document.createElement('div');
+ gridContent.className = 'grid-content';
+ li.appendChild(gridContent);
+
+ // Create buttons once and keep them (no individual event listeners needed)
+ const buttonsContainer = document.createElement('div');
+ buttonsContainer.className = 'buttons-container';
+
+ const chatBtn = document.createElement('button');
+ chatBtn.className = 'chat-btn';
+ chatBtn.textContent = 'Chat';
+ chatBtn.addEventListener('click', (e) => {
+ debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget);
+ e.stopPropagation();
+ // Try button's own playerData first, fallback to DOM traversal
+ const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
+ debugLog('🔥 Player data found:', playerData);
+ if (playerData) {
+ debugLog('🔥 Opening chat for:', playerData.character_name);
+ showChatWindow(playerData.character_name);
+ } else {
+ debugLog('🔥 No player data found!');
+ }
+ });
+
+ const statsBtn = document.createElement('button');
+ statsBtn.className = 'stats-btn';
+ statsBtn.textContent = 'Stats';
+ statsBtn.addEventListener('click', (e) => {
+ debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget);
+ e.stopPropagation();
+ // Try button's own playerData first, fallback to DOM traversal
+ const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
+ debugLog('📊 Player data found:', playerData);
+ if (playerData) {
+ debugLog('📊 Opening stats for:', playerData.character_name);
+ showStatsWindow(playerData.character_name);
+ } else {
+ debugLog('📊 No player data found!');
+ }
+ });
+
+ const inventoryBtn = document.createElement('button');
+ inventoryBtn.className = 'inventory-btn';
+ inventoryBtn.textContent = 'Inventory';
+ inventoryBtn.addEventListener('click', (e) => {
+ debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget);
+ e.stopPropagation();
+ // Try button's own playerData first, fallback to DOM traversal
+ const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
+ debugLog('🎒 Player data found:', playerData);
+ if (playerData) {
+ debugLog('🎒 Opening inventory for:', playerData.character_name);
+ showInventoryWindow(playerData.character_name);
+ } else {
+ debugLog('🎒 No player data found!');
+ }
+ });
+
+ const charBtn = document.createElement('button');
+ charBtn.className = 'char-btn';
+ charBtn.textContent = 'Char';
+ charBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
+ if (playerData) {
+ showCharacterWindow(playerData.character_name);
+ }
+ });
+
+ const radarBtn = document.createElement('button');
+ radarBtn.className = 'radar-btn';
+ radarBtn.textContent = 'Radar';
+ radarBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
+ if (playerData) {
+ showRadarWindow(playerData.character_name);
+ }
+ });
+
+ buttonsContainer.appendChild(chatBtn);
+ buttonsContainer.appendChild(statsBtn);
+ buttonsContainer.appendChild(inventoryBtn);
+ buttonsContainer.appendChild(charBtn);
+ buttonsContainer.appendChild(radarBtn);
+ li.appendChild(buttonsContainer);
+
+ // Store references for easy access
+ li.gridContent = gridContent;
+ li.chatBtn = chatBtn;
+ li.statsBtn = statsBtn;
+ li.inventoryBtn = inventoryBtn;
+ li.charBtn = charBtn;
+ li.radarBtn = radarBtn;
+
+ return li;
+}
+
+function returnToPool() {
+ // Return unused dots to pool
+ elementPools.activeDots.forEach(dot => {
+ if (!dot.parentNode) {
+ elementPools.dots.push(dot);
+ elementPools.activeDots.delete(dot);
+ }
+ });
+
+ // Return unused list items to pool
+ elementPools.activeListItems.forEach(li => {
+ if (!li.parentNode) {
+ elementPools.listItems.push(li);
+ elementPools.activeListItems.delete(li);
+ }
+ });
+}
+
+/* ---------- Event Delegation System ---------------------------- */
+// Single event delegation handler for all player list interactions
+function setupEventDelegation() {
+ list.addEventListener('click', e => {
+ const li = e.target.closest('li.player-item');
+ if (!li || !li.playerData) return;
+
+ const player = li.playerData;
+ const { x, y } = worldToPx(player.ew, player.ns);
+
+ // Handle player selection (clicking anywhere else on the item, not on buttons)
+ // Button clicks are now handled by direct event listeners
+ if (!e.target.closest('button')) {
+ selectPlayer(player, x, y);
+ }
+ });
+}
+
+// Initialize event delegation when DOM is ready
+document.addEventListener('DOMContentLoaded', setupEventDelegation);
+
+// Global drag system to prevent event listener accumulation
+let currentDragWindow = null;
+let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
+
+function makeDraggable(win, header) {
+ if (!window.__chatZ) window.__chatZ = 10000;
+ header.style.cursor = 'move';
+
+ const bringToFront = () => {
+ window.__chatZ += 1;
+ win.style.zIndex = window.__chatZ;
+ };
+
+ header.addEventListener('mousedown', e => {
+ if (e.target.closest('button')) return;
+ e.preventDefault();
+ currentDragWindow = win;
+ bringToFront();
+ dragStartX = e.clientX;
+ dragStartY = e.clientY;
+ dragStartLeft = win.offsetLeft;
+ dragStartTop = win.offsetTop;
+ document.body.classList.add('noselect');
+ });
+
+ // Touch support
+ header.addEventListener('touchstart', e => {
+ if (e.touches.length !== 1 || e.target.closest('button')) return;
+ currentDragWindow = win;
+ bringToFront();
+ const t = e.touches[0];
+ dragStartX = t.clientX;
+ dragStartY = t.clientY;
+ dragStartLeft = win.offsetLeft;
+ dragStartTop = win.offsetTop;
+ });
+}
+
+// Global mouse handlers (only added once)
+window.addEventListener('mousemove', e => {
+ if (!currentDragWindow) return;
+ const dx = e.clientX - dragStartX;
+ const dy = e.clientY - dragStartY;
+ currentDragWindow.style.left = `${dragStartLeft + dx}px`;
+ currentDragWindow.style.top = `${dragStartTop + dy}px`;
+});
+
+window.addEventListener('mouseup', () => {
+ if (currentDragWindow) {
+ currentDragWindow = null;
+ document.body.classList.remove('noselect');
+ }
+});
+
+window.addEventListener('touchmove', e => {
+ if (!currentDragWindow || e.touches.length !== 1) return;
+ const t = e.touches[0];
+ const dx = t.clientX - dragStartX;
+ const dy = t.clientY - dragStartY;
+ currentDragWindow.style.left = `${dragStartLeft + dx}px`;
+ currentDragWindow.style.top = `${dragStartTop + dy}px`;
+});
+
+window.addEventListener('touchend', () => {
+ currentDragWindow = null;
+});
+// Filter input for player names (starts-with filter)
+let currentFilter = '';
+const filterInput = document.getElementById('playerFilter');
+if (filterInput) {
+ filterInput.addEventListener('input', e => {
+ currentFilter = e.target.value.toLowerCase().trim();
+ renderList();
+ });
+}
+
+// WebSocket for chat and commands
+let socket;
+// Keep track of open chat windows: character_name -> DOM element
+const chatWindows = {};
+// Keep track of open stats windows: character_name -> DOM element
+const statsWindows = {};
+// Keep track of open inventory windows: character_name -> DOM element
+const inventoryWindows = {};
+const equipmentCantripStates = {};
+
+/**
+ * ---------- Application Constants -----------------------------
+ * Defines key parameters for map rendering, data polling, and UI limits.
+ *
+ * MAX_Z: Maximum altitude difference considered (filter out outliers by Z)
+ * FOCUS_ZOOM: Zoom level when focusing on a selected character
+ * POLL_MS: Millisecond interval to fetch live player data and trails
+ * MAP_BOUNDS: World coordinate bounds for the game map (used for projection)
+ * API_BASE: Prefix for AJAX endpoints (set when behind a proxy)
+ * MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage
+ * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code
+ */
+/* ---------- constants ------------------------------------------- */
+const MAX_Z = 20;
+const FOCUS_ZOOM = 3; // zoom level when you click a name
+const POLL_MS = 2000;
+const POLL_RARES_MS = 300000; // 5 minutes
+const POLL_KILLS_MS = 300000; // 5 minutes
+const POLL_HEALTH_MS = 30000; // 30 seconds
+const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time
+const GLOW_DURATION_MS = 5000; // Player glow after rare find
+const MAX_HEATMAP_POINTS = 50000;
+const HEATMAP_HOURS = 24;
+// UtilityBelt's more accurate coordinate bounds
+const MAP_BOUNDS = {
+ west: -102.1,
+ east: 102.1,
+ north: 102.1,
+ south: -102.1
+};
+
+// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
+// If serving APIs at root, leave empty
+const API_BASE = '';
+// Maximum number of lines to retain in each chat window scrollback
+const MAX_CHAT_LINES = 1000;
+// Map numeric chat color codes to CSS hex colors
+const CHAT_COLOR_MAP = {
+ 0: '#00FF00', // Broadcast
+ 2: '#FFFFFF', // Speech
+ 3: '#FFD700', // Tell
+ 4: '#CCCC00', // OutgoingTell
+ 5: '#FF00FF', // System
+ 6: '#FF0000', // Combat
+ 7: '#00CCFF', // Magic
+ 8: '#DDDDDD', // Channel
+ 9: '#FF9999', // ChannelSend
+ 10: '#FFFF33', // Social
+ 11: '#CCFF33', // SocialSend
+ 12: '#FFFFFF', // Emote
+ 13: '#00FFFF', // Advancement
+ 14: '#66CCFF', // Abuse
+ 15: '#FF0000', // Help
+ 16: '#33FF00', // Appraisal
+ 17: '#0099FF', // Spellcasting
+ 18: '#FF6600', // Allegiance
+ 19: '#CC66FF', // Fellowship
+ 20: '#00FF00', // WorldBroadcast
+ 21: '#FF0000', // CombatEnemy
+ 22: '#FF33CC', // CombatSelf
+ 23: '#00CC00', // Recall
+ 24: '#00FF00', // Craft
+ 25: '#00FF66', // Salvaging
+ 27: '#FFFFFF', // General
+ 28: '#33FF33', // Trade
+ 29: '#CCCCCC', // LFG
+ 30: '#CC00CC', // Roleplay
+ 31: '#FFFF00' // AdminTell
+};
+
+/* ---------- Heat Map Globals ---------- */
+let heatmapCanvas, heatmapCtx;
+let heatmapEnabled = false;
+let heatmapData = null;
+let heatTimeout = null;
+const HEAT_PADDING = 50; // px beyond viewport to still draw
+const HEAT_THROTTLE = 16; // ~60 fps
+
+/* ---------- Portal Map Globals ---------- */
+let portalEnabled = false;
+let portalData = null;
+let portalContainer = null;
+
+/**
+ * ---------- Player Color Assignment ----------------------------
+ * Uses a predefined accessible color palette for player dots to ensure
+ * high contrast and colorblind-friendly display. Once the palette
+ * is exhausted, falls back to a deterministic hash-to-hue function.
+ */
+/* ---------- player/dot color assignment ------------------------- */
+// A base palette of distinct, color-blind-friendly colors
+const PALETTE = [
+ // Original colorblind-friendly base palette
+ '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
+
+ // Extended high-contrast colors
+ '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff',
+ '#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488',
+
+ // Darker variants
+ '#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc',
+ '#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366',
+
+ // Brighter variants
+ '#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff',
+ '#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa',
+
+ // Additional distinct colors
+ '#990099', '#009900', '#000099', '#990000', '#009999',
+ '#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055',
+
+ // Light pastels for contrast
+ '#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff',
+ '#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc'
+];
+// Map from character name to assigned color
+const colorMap = {};
+// Next index to pick from PALETTE
+let nextPaletteIndex = 0;
+/**
+ * Assigns or returns a consistent color for a given name.
+ * Uses a fixed palette first, then falls back to hue hashing.
+ */
+function getColorFor(name) {
+ if (colorMap[name]) {
+ return colorMap[name];
+ }
+ let color;
+ if (nextPaletteIndex < PALETTE.length) {
+ color = PALETTE[nextPaletteIndex++];
+ } else {
+ // Fallback: hash to HSL hue
+ color = hue(name);
+ }
+ colorMap[name] = color;
+ return color;
+}
+
+/*
+ * ---------- Sort Configuration -------------------------------
+ * Defines available sort criteria for the active player list:
+ * - name: alphabetical ascending
+ * - kph: kills per hour descending
+ * - kills: total kills descending
+ * - rares: rare events found during current session descending
+ * Each option includes a label for UI display and a comparator function.
+ */
+/* ---------- sort configuration ---------------------------------- */
+const sortOptions = [
+ {
+ value: "name",
+ label: "Name",
+ comparator: (a, b) => a.character_name.localeCompare(b.character_name)
+ },
+ {
+ value: "kph",
+ label: "KPH",
+ comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
+ },
+ {
+ value: "kills",
+ label: "S.Kills",
+ comparator: (a, b) => b.kills - a.kills
+ },
+ {
+ value: "rares",
+ label: "S.Rares",
+ comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
+ },
+ {
+ value: "total_kills",
+ label: "T.Kills",
+ comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0)
+ },
+ {
+ value: "kpr",
+ label: "KPR",
+ comparator: (a, b) => {
+ const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity;
+ const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity;
+ return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding)
+ }
+ }
+];
+
+let currentSort = sortOptions[0];
+let currentPlayers = [];
+
+/* ---------- generate segmented buttons -------------------------- */
+sortOptions.forEach(opt => {
+ const btn = document.createElement('div');
+ btn.className = 'btn';
+ btn.textContent = opt.label;
+ btn.dataset.value = opt.value;
+ if (opt.value === currentSort.value) btn.classList.add('active');
+
+ btn.addEventListener('click', () => {
+ btnContainer.querySelectorAll('.btn')
+ .forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ currentSort = opt;
+ renderList();
+ });
+
+ btnContainer.appendChild(btn);
+});
+
+/* ---------- map & state variables ------------------------------- */
+let imgW = 0, imgH = 0;
+let scale = 1, offX = 0, offY = 0, minScale = 1;
+let dragging = false, sx = 0, sy = 0;
+let selected = "";
+const pollIntervals = [];
+
+/* ---------- utility functions ----------------------------------- */
+const hue = name => {
+ let h = 0;
+ for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
+ return `hsl(${Math.abs(h) % 360},72%,50%)`;
+};
+
+const loc = (ns, ew) =>
+ `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
+ + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;
+
+function worldToPx(ew, ns) {
+ const x = ((ew - MAP_BOUNDS.west)
+ / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
+ const y = ((MAP_BOUNDS.north - ns)
+ / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
+ return { x, y };
+}
+
+function pxToWorld(x, y) {
+ // Convert screen coordinates to map image coordinates
+ const mapX = (x - offX) / scale;
+ const mapY = (y - offY) / scale;
+
+ // Convert map image coordinates to world coordinates
+ const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
+ const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
+
+ return { ew, ns };
+}
+
+/* ---------- Heat Map Functions ---------- */
+
+function initHeatMap() {
+ heatmapCanvas = document.getElementById('heatmapCanvas');
+ if (!heatmapCanvas) {
+ console.error('Heat map canvas not found');
+ return;
+ }
+
+ heatmapCtx = heatmapCanvas.getContext('2d');
+
+ const toggle = document.getElementById('heatmapToggle');
+ if (toggle) {
+ toggle.addEventListener('change', e => {
+ heatmapEnabled = e.target.checked;
+ if (heatmapEnabled) {
+ fetchHeatmapData();
+ } else {
+ clearHeatmap();
+ }
+ });
+ }
+
+ window.addEventListener('resize', debounce(() => {
+ if (heatmapEnabled && heatmapData) {
+ renderHeatmap();
+ }
+ }, 250));
+}
+
+async function fetchHeatmapData() {
+ try {
+ const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`);
+ if (!response.ok) {
+ throw new Error(`Heat map API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ heatmapData = data.spawn_points; // [{ew, ns, intensity}]
+ debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`);
+ renderHeatmap();
+ } catch (err) {
+ handleError('Heatmap', err);
+ }
+}
+
+function renderHeatmap() {
+ if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) {
+ return;
+ }
+
+ // Set canvas size to match map dimensions (1:1 DPI)
+ heatmapCanvas.width = imgW;
+ heatmapCanvas.height = imgH;
+ heatmapCtx.clearRect(0, 0, imgW, imgH);
+
+ // Current visible map rect in px for viewport culling
+ const vw = wrap.clientWidth;
+ const vh = wrap.clientHeight;
+ const viewL = -offX / scale;
+ const viewT = -offY / scale;
+ const viewR = viewL + vw / scale;
+ const viewB = viewT + vh / scale;
+
+ // Render heat map points with viewport culling
+ for (const point of heatmapData) {
+ const { x, y } = worldToPx(point.ew, point.ns);
+
+ // Skip points outside visible area (with padding for smooth edges)
+ if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING ||
+ y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) {
+ continue;
+ }
+
+ // Smaller, more precise spots to clearly show individual spawn locations
+ const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
+
+ // Sharp gradient with distinct boundaries between spawn points
+ const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius);
+ gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center
+ gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange
+ gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
+
+ heatmapCtx.fillStyle = gradient;
+ heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
+ }
+}
+
+function clearHeatmap() {
+ if (heatmapCtx && heatmapCanvas) {
+ heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
+ }
+}
+
+/* ---------- Portal Map Functions ---------- */
+function initPortalMap() {
+ portalContainer = document.getElementById('portals');
+ if (!portalContainer) {
+ console.error('Portal container not found');
+ return;
+ }
+
+ const toggle = document.getElementById('portalToggle');
+ if (toggle) {
+ toggle.addEventListener('change', e => {
+ portalEnabled = e.target.checked;
+ if (portalEnabled) {
+ fetchPortalData();
+ } else {
+ clearPortals();
+ }
+ });
+ }
+}
+
+async function fetchPortalData() {
+ try {
+ const response = await fetch(`${API_BASE}/portals`);
+ if (!response.ok) {
+ throw new Error(`Portal API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}]
+ debugLog(`Loaded ${portalData.length} portals from last hour`);
+ renderPortals();
+ } catch (err) {
+ handleError('Portals', err);
+ }
+}
+
+function parseCoordinate(coord) {
+ // Handle both formats:
+ // String format: "42.3N", "15.7S", "33.7E", "28.2W"
+ // Numeric format: "-96.9330958" (already signed)
+
+ // Check if it's already a number
+ if (typeof coord === 'number') {
+ return coord;
+ }
+
+ // Check if it's a numeric string
+ const numericValue = parseFloat(coord);
+ if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) {
+ return numericValue;
+ }
+
+ // Parse string format like "42.3N"
+ const match = coord.match(/^([0-9.]+)([NSEW])$/);
+ if (!match) return 0;
+
+ const value = parseFloat(match[1]);
+ const direction = match[2];
+
+ if (direction === 'S' || direction === 'W') {
+ return -value;
+ }
+ return value;
+}
+
+function renderPortals() {
+ if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) {
+ return;
+ }
+
+ // Clear existing portals
+ clearPortals();
+
+ for (const portal of portalData) {
+ // Extract coordinates from new API format
+ const ns = portal.coordinates.ns;
+ const ew = portal.coordinates.ew;
+
+ // Convert to pixel coordinates
+ const { x, y } = worldToPx(ew, ns);
+
+ // Create portal icon
+ const icon = document.createElement('div');
+ icon.className = 'portal-icon';
+ icon.style.left = `${x}px`;
+ icon.style.top = `${y}px`;
+ icon.title = `${portal.portal_name} (discovered by ${portal.discovered_by})`;
+
+ portalContainer.appendChild(icon);
+ }
+
+ debugLog(`Rendered ${portalData.length} portal icons`);
+}
+
+function clearPortals() {
+ if (portalContainer) {
+ portalContainer.innerHTML = '';
+ }
+}
+
+function debounce(fn, ms) {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), ms);
+ };
+}
+
+/**
+ * Create or show a draggable window. Returns { win, content, isNew }.
+ * If window already exists, brings it to front and returns isNew: false.
+ */
+function createWindow(id, title, className, options = {}) {
+ const { onClose } = options;
+
+ // Check if window already exists - bring to front
+ const existing = document.getElementById(id);
+ if (existing) {
+ existing.style.display = 'flex';
+ if (!window.__chatZ) window.__chatZ = 10000;
+ window.__chatZ += 1;
+ existing.style.zIndex = window.__chatZ;
+ return { win: existing, content: existing.querySelector('.window-content'), isNew: false };
+ }
+
+ // Create new window
+ if (!window.__chatZ) window.__chatZ = 10000;
+ window.__chatZ += 1;
+
+ const win = document.createElement('div');
+ win.id = id;
+ win.className = className;
+ win.style.display = 'flex';
+ win.style.zIndex = window.__chatZ;
+
+ const header = document.createElement('div');
+ header.className = 'chat-header';
+
+ const titleSpan = document.createElement('span');
+ titleSpan.textContent = title;
+ header.appendChild(titleSpan);
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'chat-close-btn';
+ closeBtn.textContent = '\u00D7';
+ closeBtn.addEventListener('click', () => {
+ win.style.display = 'none';
+ if (onClose) onClose();
+ });
+ header.appendChild(closeBtn);
+
+ const content = document.createElement('div');
+ content.className = 'window-content';
+
+ win.appendChild(header);
+ win.appendChild(content);
+ document.body.appendChild(win);
+ makeDraggable(win, header);
+
+ return { win, content, isNew: true };
+}
+
+// Show or create a stats window for a character
+function showStatsWindow(name) {
+ debugLog('showStatsWindow called for:', name);
+ const windowId = `statsWindow-${name}`;
+
+ const { win, content, isNew } = createWindow(
+ windowId, `Stats: ${name}`, 'stats-window'
+ );
+
+ if (!isNew) {
+ debugLog('Existing stats window found, showing it');
+ return;
+ }
+
+ win.dataset.character = name;
+ statsWindows[name] = win;
+
+ // Time period controls
+ const controls = document.createElement('div');
+ controls.className = 'stats-controls';
+ const timeRanges = [
+ { label: '1H', value: 'now-1h' },
+ { label: '6H', value: 'now-6h' },
+ { label: '24H', value: 'now-24h' },
+ { label: '7D', value: 'now-7d' }
+ ];
+
+ // Stats content container (iframes grid)
+ const statsContent = document.createElement('div');
+ statsContent.className = 'chat-messages';
+ statsContent.textContent = 'Loading stats...';
+
+ timeRanges.forEach(range => {
+ const btn = document.createElement('button');
+ btn.className = 'time-range-btn';
+ btn.textContent = range.label;
+ if (range.value === 'now-24h') btn.classList.add('active');
+ btn.addEventListener('click', () => {
+ controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ updateStatsTimeRange(statsContent, name, range.value);
+ });
+ controls.appendChild(btn);
+ });
+
+ content.appendChild(controls);
+ content.appendChild(statsContent);
+
+ debugLog('Stats window created for:', name);
+ // Load initial stats with default 24h range
+ updateStatsTimeRange(statsContent, name, 'now-24h');
+}
+
+function updateStatsTimeRange(content, name, timeRange) {
+ content.innerHTML = '';
+ const panels = [
+ { title: 'Kills per Hour', id: 1 },
+ { title: 'Memory (MB)', id: 2 },
+ { title: 'CPU (%)', id: 3 },
+ { title: 'Mem Handles', id: 4 }
+ ];
+ panels.forEach(p => {
+ const iframe = document.createElement('iframe');
+ iframe.src =
+ `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` +
+ `?panelId=${p.id}` +
+ `&var-character=${encodeURIComponent(name)}` +
+ `&from=${timeRange}` +
+ `&to=now` +
+ `&theme=light`;
+ iframe.setAttribute('title', p.title);
+ iframe.width = '350';
+ iframe.height = '200';
+ iframe.frameBorder = '0';
+ iframe.allowFullscreen = true;
+ content.appendChild(iframe);
+ });
+}
+
+// Show or create an inventory window for a character
+/**
+ * Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot.
+ * Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... }
+ * Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} }
+ * Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... }
+ *
+ * MyWorldObject uses -1 as sentinel for "not set" on int/double properties.
+ */
+function normalizeInventoryItem(item) {
+ if (!item) return item;
+ if (item.name && item.item_id) return item;
+
+ // MyWorldObject uses -1 as "not set" sentinel — filter those out
+ const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined;
+
+ if (!item.item_id) item.item_id = item.Id;
+ if (!item.icon) item.icon = item.Icon;
+ if (!item.object_class) item.object_class = item.ObjectClass;
+ if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData;
+
+ const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null;
+ const material = item.Material || null;
+ if (material) {
+ item.material = material;
+ item.material_name = material;
+ }
+
+ // Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service
+ if (baseName) {
+ if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) {
+ item.name = material + ' ' + baseName;
+ } else {
+ item.name = baseName;
+ }
+ }
+
+ const iv = item.IntValues || {};
+ if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']);
+ if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']);
+
+ // Container/equipment tracking
+ if (item.container_id === undefined) item.container_id = item.ContainerId || 0;
+ if (item.current_wielded_location === undefined) {
+ item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0;
+ }
+ if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']);
+
+ const armor = v(item.ArmorLevel);
+ if (armor !== undefined) item.armor_level = armor;
+
+ const maxDmg = v(item.MaxDamage);
+ if (maxDmg !== undefined) item.max_damage = maxDmg;
+
+ const dmgBonus = v(item.DamageBonus);
+ if (dmgBonus !== undefined) item.damage_bonus = dmgBonus;
+
+ const atkBonus = v(item.AttackBonus);
+ if (atkBonus !== undefined) item.attack_bonus = atkBonus;
+
+ const elemDmg = v(item.ElementalDmgBonus);
+ if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg;
+
+ const meleeD = v(item.MeleeDefenseBonus);
+ if (meleeD !== undefined) item.melee_defense_bonus = meleeD;
+
+ const magicD = v(item.MagicDBonus);
+ if (magicD !== undefined) item.magic_defense_bonus = magicD;
+
+ const missileD = v(item.MissileDBonus);
+ if (missileD !== undefined) item.missile_defense_bonus = missileD;
+
+ const manaC = v(item.ManaCBonus);
+ if (manaC !== undefined) item.mana_conversion_bonus = manaC;
+
+ const wieldLvl = v(item.WieldLevel);
+ if (wieldLvl !== undefined) item.wield_level = wieldLvl;
+
+ const skillLvl = v(item.SkillLevel);
+ if (skillLvl !== undefined) item.skill_level = skillLvl;
+
+ const loreLvl = v(item.LoreRequirement);
+ if (loreLvl !== undefined) item.lore_requirement = loreLvl;
+
+ if (item.EquipSkill) item.equip_skill = item.EquipSkill;
+ if (item.Mastery) item.mastery = item.Mastery;
+ if (item.ItemSet) item.item_set = item.ItemSet;
+ if (item.Imbue) item.imbue = item.Imbue;
+
+ const tinks = v(item.Tinks);
+ if (tinks !== undefined) item.tinks = tinks;
+
+ const work = v(item.Workmanship);
+ if (work !== undefined) item.workmanship = work;
+
+ const damR = v(item.DamRating);
+ if (damR !== undefined) item.damage_rating = damR;
+
+ const critR = v(item.CritRating);
+ if (critR !== undefined) item.crit_rating = critR;
+
+ const healR = v(item.HealBoostRating);
+ if (healR !== undefined) item.heal_boost_rating = healR;
+
+ const vitalR = v(item.VitalityRating);
+ if (vitalR !== undefined) item.vitality_rating = vitalR;
+
+ const critDmgR = v(item.CritDamRating);
+ if (critDmgR !== undefined) item.crit_damage_rating = critDmgR;
+
+ const damResR = v(item.DamResistRating);
+ if (damResR !== undefined) item.damage_resist_rating = damResR;
+
+ const critResR = v(item.CritResistRating);
+ if (critResR !== undefined) item.crit_resist_rating = critResR;
+
+ const critDmgResR = v(item.CritDamResistRating);
+ if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR;
+
+ if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) {
+ item.spells = item.Spells;
+ }
+
+ return item;
+}
+
+/**
+ * Create a single inventory slot DOM element from item data.
+ * Used by both initial inventory load and live delta updates.
+ */
+function createInventorySlot(item) {
+ const slot = document.createElement('div');
+ slot.className = 'inventory-slot';
+ slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0);
+
+ // Create layered icon container
+ const iconContainer = document.createElement('div');
+ iconContainer.className = 'item-icon-composite';
+
+ // Get base icon ID with portal.dat offset
+ const iconRaw = item.icon || item.Icon || 0;
+ const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+
+ // Check for overlay and underlay from enhanced format or legacy format
+ let overlayIconId = null;
+ let underlayIconId = null;
+
+ // Enhanced format (inventory service) - check for proper icon overlay/underlay properties
+ if (item.icon_overlay_id && item.icon_overlay_id > 0) {
+ overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+
+ if (item.icon_underlay_id && item.icon_underlay_id > 0) {
+ underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+
+ // Fallback: Enhanced format (inventory service) - check spells object for decal info
+ if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') {
+ if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) {
+ overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) {
+ underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ } else if (item.IntValues) {
+ // Raw delta format from plugin - IntValues directly on item
+ if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) {
+ overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) {
+ underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ } else if (item.item_data) {
+ // Legacy format - parse item_data
+ try {
+ const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data;
+ if (itemData.IntValues) {
+ if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
+ overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) {
+ underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to parse item data for', item.name || item.Name);
+ }
+ }
+
+ // Create underlay (bottom layer)
+ if (underlayIconId) {
+ const underlayImg = document.createElement('img');
+ underlayImg.className = 'icon-underlay';
+ underlayImg.src = `/icons/${underlayIconId}.png`;
+ underlayImg.alt = 'underlay';
+ underlayImg.onerror = function() { this.style.display = 'none'; };
+ iconContainer.appendChild(underlayImg);
+ }
+
+ // Create base icon (middle layer)
+ const baseImg = document.createElement('img');
+ baseImg.className = 'icon-base';
+ baseImg.src = `/icons/${baseIconId}.png`;
+ baseImg.alt = item.name || item.Name || 'Unknown Item';
+ baseImg.onerror = function() { this.src = '/icons/06000133.png'; };
+ iconContainer.appendChild(baseImg);
+
+ // Create overlay (top layer)
+ if (overlayIconId) {
+ const overlayImg = document.createElement('img');
+ overlayImg.className = 'icon-overlay';
+ overlayImg.src = `/icons/${overlayIconId}.png`;
+ overlayImg.alt = 'overlay';
+ overlayImg.onerror = function() { this.style.display = 'none'; };
+ iconContainer.appendChild(overlayImg);
+ }
+
+ // Create tooltip data (handle both inventory-service format and raw plugin format)
+ const itemName = item.name || item.Name || 'Unknown Item';
+ slot.dataset.name = itemName;
+ slot.dataset.value = item.value || item.Value || 0;
+ slot.dataset.burden = item.burden || item.Burden || 0;
+
+ // Store enhanced data for tooltips
+ if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
+ const enhancedData = {};
+ const possibleProps = [
+ 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus',
+ 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name',
+ 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks',
+ 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating',
+ 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells',
+ 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage',
+ 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana',
+ 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus',
+ 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id'
+ ];
+ possibleProps.forEach(prop => {
+ if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
+ enhancedData[prop] = item[prop];
+ }
+ });
+ slot.dataset.enhancedData = JSON.stringify(enhancedData);
+ } else {
+ slot.dataset.enhancedData = JSON.stringify({});
+ }
+
+ // Add tooltip on hover
+ slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot));
+ slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot));
+ slot.addEventListener('mouseleave', hideInventoryTooltip);
+
+ slot.appendChild(iconContainer);
+
+ // Add stack count if > 1
+ const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1;
+ if (stackCount > 1) {
+ const countEl = document.createElement('div');
+ countEl.className = 'inventory-count';
+ countEl.textContent = stackCount;
+ slot.appendChild(countEl);
+ }
+
+ return slot;
+}
+
+/**
+ * Equipment slots mapping for the AC inventory layout.
+ * Grid matches the real AC "Equipment Slots Enabled" paperdoll view.
+ *
+ * Layout (6 cols × 6 rows):
+ * Col: 1 2 3 4 5 6
+ * Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red)
+ * Row 2: Trinket — ChestArmor — — Cloak
+ * Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt)
+ * Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants)
+ * Row 5: — Hands — LowerLegArmor — —
+ * Row 6: Shield — — Feet Weapon Ammo
+ */
+const EQUIP_SLOTS = {
+ // Row 1: Necklace, Head, 3× Aetheria/Sigil
+ 32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear
+ 1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear
+ 268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne
+ 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo
+ 1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree
+
+ // Row 2: Trinket, Chest Armor, Cloak
+ 67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne
+ 2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor
+ 512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor
+ 134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak
+
+ // Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt
+ 65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft
+ 4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor
+ 1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor
+ 8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor
+ 131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight
+ 2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear
+
+ // Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants
+ 262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft
+ 32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear
+ 16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor
+ 524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight
+ 4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear
+
+ // Row 5: Feet
+ 256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear
+
+ // Row 6: Shield, Weapon, Ammo
+ 2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield
+ 1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon
+ 4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon
+ 16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held
+ 33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded
+ 8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition
+};
+
+const SLOT_COLORS = {};
+// Purple: jewelry
+[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple');
+// Blue: armor
+[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue');
+// Teal: clothing/misc
+[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal');
+// Dark blue: weapons/combat
+[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue');
+
+/**
+ * Handle live inventory delta updates from WebSocket.
+ * Updates the inventory grid for a character if their inventory window is open.
+ */
+function updateInventoryLive(delta) {
+ const name = delta.character_name;
+ const win = inventoryWindows[name];
+ if (!win || !win._inventoryState) {
+ return;
+ }
+
+ const state = win._inventoryState;
+ const getItemId = (d) => {
+ if (d.item) return d.item.item_id || d.item.Id || d.item.id;
+ return d.item_id;
+ };
+
+ const itemId = getItemId(delta);
+
+ if (delta.action === 'remove') {
+ state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId);
+ } else if (delta.action === 'add' || delta.action === 'update') {
+ normalizeInventoryItem(delta.item);
+ const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId);
+ if (existingIdx >= 0) {
+ state.items[existingIdx] = delta.item;
+ } else {
+ state.items.push(delta.item);
+ }
+ }
+
+ renderInventoryState(state);
+}
+
+function renderInventoryState(state) {
+ // 1. Clear equipment slots
+ state.slotMap.forEach((slotEl) => {
+ slotEl.innerHTML = '';
+ const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
+ slotEl.className = `inv-equip-slot empty ${colorClass}`;
+ delete slotEl.dataset.itemId;
+ });
+
+ // 2. Identify containers (object_class === 10) by item_id for sidebar
+ // These are packs/sacks/pouches/foci that appear in inventory as items
+ // but should ONLY show in the pack sidebar, not in the item grid.
+ const containers = []; // container objects (object_class=10)
+ const containerItemIds = new Set(); // item_ids of containers (to exclude from grid)
+
+ state.items.forEach(item => {
+ if (item.object_class === 10) {
+ containers.push(item);
+ containerItemIds.add(item.item_id);
+ }
+ });
+
+ // 3. Separate equipped items from pack items, excluding containers from grid
+ const packItems = new Map(); // container_id → [items] (non-container items only)
+
+ // Determine the character body container_id: items with wielded_location > 0
+ // share a container_id that is NOT 0 and NOT a pack's item_id.
+ // We treat non-wielded items from the body container as "main backpack" items.
+ let bodyContainerId = null;
+ state.items.forEach(item => {
+ if (item.current_wielded_location && item.current_wielded_location > 0) {
+ const cid = item.container_id;
+ if (cid && cid !== 0 && !containerItemIds.has(cid)) {
+ bodyContainerId = cid;
+ }
+ }
+ });
+
+ state.items.forEach(item => {
+ // Skip container objects — they go in sidebar only
+ if (containerItemIds.has(item.item_id)) return;
+
+ if (item.current_wielded_location && item.current_wielded_location > 0) {
+ const mask = item.current_wielded_location;
+ const isArmor = item.object_class === 2;
+
+ // For armor (object_class=2): render in ALL matching slots (multi-slot display)
+ // For everything else (clothing, jewelry, weapons): place in first matching slot only
+ if (isArmor) {
+ Object.keys(EQUIP_SLOTS).forEach(m => {
+ const slotMask = parseInt(m);
+ if ((mask & slotMask) === slotMask) {
+ const slotDef = EQUIP_SLOTS[slotMask];
+ const key = `${slotDef.row}-${slotDef.col}`;
+ if (state.slotMap.has(key)) {
+ const slotEl = state.slotMap.get(key);
+ if (!slotEl.dataset.itemId) {
+ slotEl.innerHTML = '';
+ const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
+ slotEl.className = `inv-equip-slot equipped ${colorClass}`;
+ slotEl.dataset.itemId = item.item_id;
+ slotEl.appendChild(createInventorySlot(item));
+ }
+ }
+ }
+ });
+ } else {
+ // Non-armor: find the first matching slot by exact mask key, then by bit overlap
+ let placed = false;
+ // Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly)
+ if (EQUIP_SLOTS[mask]) {
+ const slotDef = EQUIP_SLOTS[mask];
+ const key = `${slotDef.row}-${slotDef.col}`;
+ if (state.slotMap.has(key)) {
+ const slotEl = state.slotMap.get(key);
+ if (!slotEl.dataset.itemId) {
+ slotEl.innerHTML = '';
+ const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
+ slotEl.className = `inv-equip-slot equipped ${colorClass}`;
+ slotEl.dataset.itemId = item.item_id;
+ slotEl.appendChild(createInventorySlot(item));
+ placed = true;
+ }
+ }
+ }
+ // If no exact match, find first matching bit in EQUIP_SLOTS
+ if (!placed) {
+ for (const m of Object.keys(EQUIP_SLOTS)) {
+ const slotMask = parseInt(m);
+ if ((mask & slotMask) === slotMask) {
+ const slotDef = EQUIP_SLOTS[slotMask];
+ const key = `${slotDef.row}-${slotDef.col}`;
+ if (state.slotMap.has(key)) {
+ const slotEl = state.slotMap.get(key);
+ if (!slotEl.dataset.itemId) {
+ slotEl.innerHTML = '';
+ const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
+ slotEl.className = `inv-equip-slot equipped ${colorClass}`;
+ slotEl.dataset.itemId = item.item_id;
+ slotEl.appendChild(createInventorySlot(item));
+ placed = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // Non-equipped, non-container → pack item. Group by container_id.
+ let cid = item.container_id || 0;
+ // Items on the character body (not wielded) → treat as main backpack (cid=0)
+ if (bodyContainerId !== null && cid === bodyContainerId) cid = 0;
+ if (!packItems.has(cid)) packItems.set(cid, []);
+ packItems.get(cid).push(item);
+ }
+ });
+
+ const stats = characterStats[state.characterName] || {};
+ const burdenUnits = Number(stats.burden_units || 0);
+ const encumbranceCapacity = Number(stats.encumbrance_capacity || 0);
+ const burdenPct = encumbranceCapacity > 0
+ ? Math.max(0, Math.min(200, (burdenUnits / encumbranceCapacity) * 100))
+ : 0;
+ const burdenDisplay = Math.floor(burdenPct);
+ state.burdenLabel.textContent = `${burdenDisplay}%`;
+ state.burdenLabel.title = burdenUnits > 0 && encumbranceCapacity > 0
+ ? `${burdenUnits.toLocaleString()} / ${encumbranceCapacity.toLocaleString()}`
+ : '';
+ // Fill height: map 0-200% burden onto 0-100% bar height
+ state.burdenFill.style.height = `${burdenPct / 2}%`;
+ // Color by threshold
+ state.burdenFill.style.backgroundColor = burdenPct > 150
+ ? '#b7432c'
+ : burdenPct > 100
+ ? '#d8a431'
+ : '#2e8b57';
+
+ // 4. Sort containers for stable sidebar order (by unsigned item_id)
+ containers.sort((a, b) => {
+ const ua = a.item_id >>> 0;
+ const ub = b.item_id >>> 0;
+ return ua - ub;
+ });
+
+ // 5. Render packs in sidebar
+ state.packList.innerHTML = '';
+
+ // Helper: compute icon URL from raw icon id
+ const iconUrl = (iconRaw) => {
+ if (!iconRaw) return '/icons/06001080.png';
+ const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
+ return `/icons/${hex}.png`;
+ };
+
+ // --- Main backpack (container_id === 0, non-containers) ---
+ const mainPackEl = document.createElement('div');
+ mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`;
+ const mainPackImg = document.createElement('img');
+ mainPackImg.src = '/icons/0600127E.png';
+ mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; };
+
+ const mainFillCont = document.createElement('div');
+ mainFillCont.className = 'inv-pack-fill-container';
+ const mainFill = document.createElement('div');
+ mainFill.className = 'inv-pack-fill';
+
+ // Main backpack items = container_id 0, excluding container objects
+ const mainPackItems = packItems.get(0) || [];
+ const mainPct = Math.min(100, (mainPackItems.length / 102) * 100);
+ mainFill.style.height = `${mainPct}%`;
+
+ mainFillCont.appendChild(mainFill);
+ mainPackEl.appendChild(mainPackImg);
+ mainPackEl.appendChild(mainFillCont);
+
+ mainPackEl.onclick = () => {
+ state.activePack = null;
+ renderInventoryState(state);
+ };
+ state.packList.appendChild(mainPackEl);
+
+ // --- Sub-packs: each container object (object_class=10) ---
+ containers.forEach(container => {
+ const cid = container.item_id; // items inside this pack have container_id = this item_id
+ const packEl = document.createElement('div');
+ packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`;
+ const packImg = document.createElement('img');
+ // Use the container's actual icon from the API
+ packImg.src = iconUrl(container.icon);
+ packImg.onerror = function() { this.src = '/icons/06001080.png'; };
+
+ const fillCont = document.createElement('div');
+ fillCont.className = 'inv-pack-fill-container';
+ const fill = document.createElement('div');
+ fill.className = 'inv-pack-fill';
+
+ const pItems = packItems.get(cid) || [];
+ const capacity = container.items_capacity || 24; // default pack capacity in AC
+ const pPct = Math.min(100, (pItems.length / capacity) * 100);
+ fill.style.height = `${pPct}%`;
+
+ fillCont.appendChild(fill);
+ packEl.appendChild(packImg);
+ packEl.appendChild(fillCont);
+
+ packEl.onclick = () => {
+ state.activePack = cid;
+ renderInventoryState(state);
+ };
+ state.packList.appendChild(packEl);
+ });
+
+ // 6. Render item grid
+ state.itemGrid.innerHTML = '';
+ let itemsToShow = [];
+ if (state.activePack === null) {
+ // Main backpack: non-container items with container_id === 0
+ itemsToShow = mainPackItems;
+ state.contentsHeader.textContent = 'Contents of Backpack';
+ } else {
+ // Sub-pack: items with matching container_id
+ itemsToShow = packItems.get(state.activePack) || [];
+ // Use the container's name for the header
+ const activeContainer = containers.find(c => c.item_id === state.activePack);
+ state.contentsHeader.textContent = activeContainer
+ ? `Contents of ${activeContainer.name}`
+ : 'Contents of Pack';
+ }
+
+ const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6);
+ for (let i = 0; i < numCells; i++) {
+ const cell = document.createElement('div');
+ if (i < itemsToShow.length) {
+ cell.className = 'inv-item-slot occupied';
+ const itemNode = createInventorySlot(itemsToShow[i]);
+ cell.appendChild(itemNode);
+ } else {
+ cell.className = 'inv-item-slot';
+ }
+ state.itemGrid.appendChild(cell);
+ }
+
+ renderInventoryManaPanel(state);
+}
+
+function getManaTrackedItems(state) {
+ if (!state || !state.items) return [];
+
+ const overlayItems = equipmentCantripStates[state.characterName]?.items;
+ const overlayMap = new Map();
+ if (Array.isArray(overlayItems)) {
+ overlayItems.forEach(item => {
+ if (item && item.item_id != null) {
+ overlayMap.set(Number(item.item_id), item);
+ }
+ });
+ }
+
+ const snapshotMs = Date.now();
+ return state.items
+ .filter(item => (item.current_wielded_location || 0) > 0)
+ .filter(item => overlayMap.has(Number(item.item_id)))
+ .map(item => {
+ const result = { ...item };
+ const overlay = overlayMap.get(Number(item.item_id)) || {};
+ result.mana_state = overlay.state || 'unknown';
+ if (overlay.current_mana !== undefined && overlay.current_mana !== null) {
+ result.current_mana = overlay.current_mana;
+ }
+ if (overlay.max_mana !== undefined && overlay.max_mana !== null) {
+ result.max_mana = overlay.max_mana;
+ }
+ if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) {
+ result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds;
+ result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null;
+ }
+
+ if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) {
+ const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN;
+ if (!Number.isNaN(snapshotUtc)) {
+ const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000));
+ result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0);
+ } else {
+ result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds;
+ }
+ } else {
+ result.live_mana_time_remaining_seconds = null;
+ }
+ return result;
+ })
+ .sort((a, b) => {
+ const aRemaining = a.live_mana_time_remaining_seconds;
+ const bRemaining = b.live_mana_time_remaining_seconds;
+ if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || '');
+ if (aRemaining === null) return 1;
+ if (bRemaining === null) return -1;
+ if (aRemaining !== bRemaining) return aRemaining - bRemaining;
+ return (a.name || '').localeCompare(b.name || '');
+ });
+}
+
+function formatManaRemaining(totalSeconds) {
+ if (totalSeconds === null || totalSeconds === undefined) return '--';
+ const safeSeconds = Math.max(0, Math.floor(totalSeconds));
+ const hours = Math.floor(safeSeconds / 3600);
+ const minutes = Math.floor((safeSeconds % 3600) / 60);
+ return `${hours}h${String(minutes).padStart(2, '0')}m`;
+}
+
+function renderInventoryManaPanel(state) {
+ if (!state || !state.manaListBody || !state.manaSummary) return;
+
+ const items = getManaTrackedItems(state);
+ state.manaListBody.innerHTML = '';
+
+ if (items.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'inv-mana-empty';
+ empty.textContent = 'No equipped mana-bearing items';
+ state.manaListBody.appendChild(empty);
+ state.manaSummary.textContent = 'Mana: 0 tracked';
+ return;
+ }
+
+ const activeCount = items.filter(item => item.mana_state === 'active').length;
+ const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length;
+ state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`;
+
+ items.forEach(item => {
+ const row = document.createElement('div');
+ row.className = 'inv-mana-row';
+
+ const iconWrap = document.createElement('div');
+ iconWrap.className = 'inv-mana-icon';
+ const iconSlot = createInventorySlot(item);
+ iconSlot.classList.add('mana-slot');
+ iconWrap.appendChild(iconSlot);
+
+ const nameEl = document.createElement('div');
+ nameEl.className = 'inv-mana-name';
+ nameEl.textContent = item.name || item.Name || 'Unknown Item';
+
+ const stateEl = document.createElement('div');
+ const stateName = item.mana_state || 'unknown';
+ stateEl.className = `inv-mana-state-dot mana-state-${stateName}`;
+ stateEl.title = stateName.replace(/_/g, ' ');
+
+ const manaEl = document.createElement('div');
+ manaEl.className = 'inv-mana-value';
+ if (item.current_mana !== undefined && item.max_mana !== undefined) {
+ manaEl.textContent = `${item.current_mana} / ${item.max_mana}`;
+ } else if (item.mana_display) {
+ manaEl.textContent = item.mana_display;
+ } else {
+ manaEl.textContent = '--';
+ }
+
+ const timeEl = document.createElement('div');
+ timeEl.className = 'inv-mana-time';
+ timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds);
+
+ row.appendChild(iconWrap);
+ row.appendChild(nameEl);
+ row.appendChild(stateEl);
+ row.appendChild(manaEl);
+ row.appendChild(timeEl);
+ state.manaListBody.appendChild(row);
+ });
+
+}
+
+function showInventoryWindow(name) {
+ debugLog('showInventoryWindow called for:', name);
+ const windowId = `inventoryWindow-${name}`;
+
+ const { win, content, isNew } = createWindow(
+ windowId, `Inventory: ${name}`, 'inventory-window'
+ );
+
+ if (!isNew) {
+ debugLog('Existing inventory window found, showing it');
+ return;
+ }
+
+ win.dataset.character = name;
+ inventoryWindows[name] = win;
+
+ const loading = document.createElement('div');
+ loading.className = 'inventory-loading';
+ loading.textContent = 'Loading inventory...';
+ content.appendChild(loading);
+
+ win.style.width = '572px';
+ win.style.height = '720px';
+
+ const invContent = document.createElement('div');
+ invContent.className = 'inventory-content';
+ invContent.style.display = 'none';
+ content.appendChild(invContent);
+
+ const equipGrid = document.createElement('div');
+ equipGrid.className = 'inv-equipment-grid';
+
+ const slotMap = new Map();
+ const createdSlots = new Set();
+
+ Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => {
+ const key = `${slotDef.row}-${slotDef.col}`;
+ if (!createdSlots.has(key)) {
+ createdSlots.add(key);
+ const slotEl = document.createElement('div');
+ const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue';
+ slotEl.className = `inv-equip-slot empty ${colorClass}`;
+ slotEl.style.left = `${(slotDef.col - 1) * 44}px`;
+ slotEl.style.top = `${(slotDef.row - 1) * 44}px`;
+ slotEl.dataset.pos = key;
+ equipGrid.appendChild(slotEl);
+ slotMap.set(key, slotEl);
+ }
+ });
+
+ const sidebar = document.createElement('div');
+ sidebar.className = 'inv-sidebar';
+
+ const manaPanel = document.createElement('div');
+ manaPanel.className = 'inv-mana-panel';
+ const manaHeader = document.createElement('div');
+ manaHeader.className = 'inv-mana-header';
+ manaHeader.textContent = 'Mana';
+ const manaSummary = document.createElement('div');
+ manaSummary.className = 'inv-mana-summary';
+ manaSummary.textContent = 'Mana: loading';
+ const manaListBody = document.createElement('div');
+ manaListBody.className = 'inv-mana-list';
+ manaPanel.appendChild(manaHeader);
+ manaPanel.appendChild(manaSummary);
+ manaPanel.appendChild(manaListBody);
+
+ const burdenContainer = document.createElement('div');
+ burdenContainer.className = 'inv-burden-bar';
+ const burdenFill = document.createElement('div');
+ burdenFill.className = 'inv-burden-fill';
+ const burdenLabel = document.createElement('div');
+ burdenLabel.className = 'inv-burden-label';
+ burdenLabel.textContent = 'Burden';
+ burdenContainer.appendChild(burdenFill);
+ sidebar.appendChild(burdenLabel);
+ sidebar.appendChild(burdenContainer);
+
+ const packList = document.createElement('div');
+ packList.className = 'inv-pack-list';
+ sidebar.appendChild(packList);
+
+ const leftColumn = document.createElement('div');
+ leftColumn.className = 'inv-left-column';
+
+ const contentsHeader = document.createElement('div');
+ contentsHeader.className = 'inv-contents-header';
+ contentsHeader.textContent = 'Contents of Backpack';
+
+ const itemGrid = document.createElement('div');
+ itemGrid.className = 'inv-item-grid';
+
+ leftColumn.appendChild(equipGrid);
+ leftColumn.appendChild(contentsHeader);
+ leftColumn.appendChild(itemGrid);
+
+ invContent.appendChild(leftColumn);
+ invContent.appendChild(sidebar);
+ invContent.appendChild(manaPanel);
+
+ const resizeGrip = document.createElement('div');
+ resizeGrip.className = 'inv-resize-grip';
+ win.appendChild(resizeGrip);
+
+ let resizing = false;
+ let startY, startH;
+
+ resizeGrip.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ resizing = true;
+ startY = e.clientY;
+ startH = win.offsetHeight;
+ document.body.style.cursor = 'ns-resize';
+ document.body.style.userSelect = 'none';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!resizing) return;
+ const newH = Math.max(400, startH + (e.clientY - startY));
+ win.style.height = newH + 'px';
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (!resizing) return;
+ resizing = false;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ });
+
+ win._inventoryState = {
+ windowEl: win,
+ items: [],
+ activePack: null,
+ slotMap: slotMap,
+ equipGrid: equipGrid,
+ itemGrid: itemGrid,
+ packList: packList,
+ burdenFill: burdenFill,
+ burdenLabel: burdenLabel,
+ contentsHeader: contentsHeader,
+ manaPanel: manaPanel,
+ manaSummary: manaSummary,
+ manaListBody: manaListBody,
+ characterName: name
+ };
+
+ fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`)
+ .then(response => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json();
+ })
+ .then(data => {
+ loading.style.display = 'none';
+ invContent.style.display = 'flex';
+
+ data.items.forEach(i => normalizeInventoryItem(i));
+ win._inventoryState.items = data.items;
+
+ renderInventoryState(win._inventoryState);
+ })
+ .catch(err => {
+ handleError('Inventory', err, true);
+ loading.textContent = `Failed to load inventory: ${err.message}`;
+ });
+
+ if (!characterStats[name]) {
+ fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data && !data.error) {
+ characterStats[name] = data;
+ if (win._inventoryState) {
+ renderInventoryState(win._inventoryState);
+ }
+ }
+ })
+ .catch(() => {});
+ }
+
+ if (!equipmentCantripStates[name]) {
+ fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data && !data.error) {
+ equipmentCantripStates[name] = data;
+ if (win._inventoryState) {
+ renderInventoryState(win._inventoryState);
+ }
+ }
+ })
+ .catch(() => {});
+ }
+
+ debugLog('Inventory window created for:', name);
+}
+
+// === TreeStats Property ID Mappings ===
+const TS_AUGMENTATIONS = {
+ 218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement",
+ 221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will",
+ 224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence",
+ 227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule",
+ 230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment",
+ 233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune",
+ 236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance",
+ 239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner",
+ 241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler",
+ 244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless",
+ 294: "Master of the Steel Circle", 295: "Master of the Focused Eye",
+ 296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer",
+ 298: "Iron Skin of the Invincible", 299: "Jack of All Trades",
+ 300: "Infused Void Magic", 301: "Infused War Magic",
+ 302: "Infused Life Magic", 309: "Infused Item Magic",
+ 310: "Infused Creature Magic", 326: "Clutch of the Miser",
+ 328: "Enduring Enchantment"
+};
+const TS_AURAS = {
+ 333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution",
+ 336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow",
+ 340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization",
+ 365: "World"
+};
+const TS_RATINGS = {
+ 370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance",
+ 374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost",
+ 379: "Vitality"
+};
+const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" };
+const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" };
+const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" };
+const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" };
+
+function _tsSocietyRank(v) {
+ if (v >= 1001) return "Master";
+ if (v >= 301) return "Lord";
+ if (v >= 151) return "Knight";
+ if (v >= 31) return "Adept";
+ return "Initiate";
+}
+
+function _tsSetupTabs(container) {
+ const tabs = container.querySelectorAll('.ts-tab');
+ const boxes = container.querySelectorAll('.ts-box');
+ tabs.forEach((tab, i) => {
+ tab.addEventListener('click', () => {
+ tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); });
+ boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); });
+ tab.classList.remove('inactive'); tab.classList.add('active');
+ if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); }
+ });
+ });
+}
+
+function showCharacterWindow(name) {
+ debugLog('showCharacterWindow called for:', name);
+ const windowId = `characterWindow-${name}`;
+
+ const { win, content, isNew } = createWindow(
+ windowId, `Character: ${name}`, 'character-window'
+ );
+
+ if (!isNew) {
+ debugLog('Existing character window found, showing it');
+ return;
+ }
+
+ win.dataset.character = name;
+ characterWindows[name] = win;
+
+ const esc = CSS.escape(name);
+ content.innerHTML = `
+
+
+
Total XP: \u2014
+
Unassigned XP: \u2014
+
Luminance: \u2014
+
Deaths: \u2014
+
+
+
+
+
Attributes
+
Skills
+
Titles
+
+
+
+
+
Health
+
+
\u2014 / \u2014
+
+
+
Stamina
+
+
\u2014 / \u2014
+
+
+
Mana
+
+
\u2014 / \u2014
+
+
+
+ Attribute Creation Base
+ \u2014
+ \u2014
+ \u2014
+ \u2014
+ \u2014
+ \u2014
+
+
+
+
+
+
+
+
+
+
Augmentations
+
Ratings
+
Other
+
+
+
+
+
+
+
+
Allegiance
+
Awaiting data...
+
+ `;
+
+ // Wire up tab switching
+ const leftTabs = document.getElementById(`charTabLeft-${esc}`);
+ const rightTabs = document.getElementById(`charTabRight-${esc}`);
+ if (leftTabs) _tsSetupTabs(leftTabs);
+ if (rightTabs) _tsSetupTabs(rightTabs);
+
+ // Fetch existing data from API
+ fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data && !data.error) {
+ characterStats[name] = data;
+ updateCharacterWindow(name, data);
+ }
+ })
+ .catch(err => handleError('Character stats', err));
+
+ // If we already have vitals from the live stream, apply them
+ if (characterVitals[name]) {
+ updateCharacterVitals(name, characterVitals[name]);
+ }
+}
+
+function updateCharacterWindow(name, data) {
+ const esc = CSS.escape(name);
+ const fmt = n => n != null ? n.toLocaleString() : '\u2014';
+
+ // -- Header --
+ const header = document.getElementById(`charHeader-${esc}`);
+ if (header) {
+ const level = data.level || '?';
+ const race = data.race || '';
+ const gender = data.gender || '';
+ const parts = [gender, race].filter(Boolean).join(' ');
+ header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...';
+ const levelSpan = header.querySelector('.ts-level');
+ if (levelSpan) levelSpan.textContent = level;
+ }
+
+ // -- XP / Luminance row --
+ const xplum = document.getElementById(`charXpLum-${esc}`);
+ if (xplum) {
+ const divs = xplum.querySelectorAll('div');
+ if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`;
+ if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`;
+ if (divs[2]) {
+ const lum = data.luminance_earned != null && data.luminance_total != null
+ ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}`
+ : '\u2014';
+ divs[2].textContent = `Luminance: ${lum}`;
+ }
+ if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`;
+ }
+
+ // -- Attributes table --
+ const attribTable = document.getElementById(`charAttribTable-${esc}`);
+ if (attribTable && data.attributes) {
+ const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self'];
+ const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)');
+ order.forEach((attr, i) => {
+ if (rows[i] && data.attributes[attr]) {
+ const cells = rows[i].querySelectorAll('td');
+ if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014';
+ if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014';
+ }
+ });
+ }
+
+ // -- Vitals table (base values) --
+ const vitalsTable = document.getElementById(`charVitalsTable-${esc}`);
+ if (vitalsTable && data.vitals) {
+ const vOrder = ['health', 'stamina', 'mana'];
+ const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)');
+ vOrder.forEach((v, i) => {
+ if (vRows[i] && data.vitals[v]) {
+ const cells = vRows[i].querySelectorAll('td');
+ if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014';
+ }
+ });
+ }
+
+ // -- Skill credits --
+ const creditsTable = document.getElementById(`charCredits-${esc}`);
+ if (creditsTable) {
+ const cell = creditsTable.querySelector('td.ts-headerright');
+ if (cell) cell.textContent = fmt(data.skill_credits);
+ }
+
+ // -- Skills tab --
+ const skillsBox = document.getElementById(`charSkills-${esc}`);
+ if (skillsBox && data.skills) {
+ const grouped = { Specialized: [], Trained: [] };
+ for (const [skill, info] of Object.entries(data.skills)) {
+ const training = info.training || 'Untrained';
+ if (training === 'Untrained' || training === 'Unusable') continue;
+ const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+ if (grouped[training]) grouped[training].push({ name: displayName, base: info.base });
+ }
+ for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name));
+
+ let html = '';
+ html += 'Skill Level ';
+ if (grouped.Specialized.length) {
+ for (const s of grouped.Specialized) {
+ html += `${s.name} ${s.base} `;
+ }
+ }
+ if (grouped.Trained.length) {
+ for (const s of grouped.Trained) {
+ html += `${s.name} ${s.base} `;
+ }
+ }
+ html += '
';
+ skillsBox.innerHTML = html;
+ }
+
+ // -- Titles tab --
+ const titlesBox = document.getElementById(`charTitles-${esc}`);
+ if (titlesBox) {
+ const statsData = data.stats_data || data;
+ const titles = statsData.titles;
+ if (titles && titles.length > 0) {
+ let html = '';
+ for (const t of titles) html += `
${t}
`;
+ html += '
';
+ titlesBox.innerHTML = html;
+ } else {
+ titlesBox.innerHTML = 'No titles data
';
+ }
+ }
+
+ // -- Properties-based tabs (Augmentations, Ratings, Other) --
+ const statsData = data.stats_data || data;
+ const props = statsData.properties || {};
+
+ // Augmentations tab
+ const augsBox = document.getElementById(`charAugs-${esc}`);
+ if (augsBox) {
+ let augRows = [], auraRows = [];
+ for (const [id, val] of Object.entries(props)) {
+ const nid = parseInt(id);
+ if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val });
+ if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val });
+ }
+ if (augRows.length || auraRows.length) {
+ let html = '';
+ if (augRows.length) {
+ html += 'Augmentations
';
+ html += 'Name Uses ';
+ for (const a of augRows) html += `${a.name} ${a.uses} `;
+ html += '
';
+ }
+ if (auraRows.length) {
+ html += 'Auras
';
+ html += 'Name Uses ';
+ for (const a of auraRows) html += `${a.name} ${a.uses} `;
+ html += '
';
+ }
+ augsBox.innerHTML = html;
+ } else {
+ augsBox.innerHTML = 'No augmentation data
';
+ }
+ }
+
+ // Ratings tab
+ const ratingsBox = document.getElementById(`charRatings-${esc}`);
+ if (ratingsBox) {
+ let rows = [];
+ for (const [id, val] of Object.entries(props)) {
+ const nid = parseInt(id);
+ if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val });
+ }
+ if (rows.length) {
+ let html = 'Rating Value ';
+ for (const r of rows) html += `${r.name} ${r.value} `;
+ html += '
';
+ ratingsBox.innerHTML = html;
+ } else {
+ ratingsBox.innerHTML = 'No rating data
';
+ }
+ }
+
+ // Other tab (General, Masteries, Society)
+ const otherBox = document.getElementById(`charOther-${esc}`);
+ if (otherBox) {
+ let html = '';
+
+ // General section
+ let generalRows = [];
+ if (data.birth) generalRows.push({ name: 'Birth', value: data.birth });
+ if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
+ for (const [id, val] of Object.entries(props)) {
+ const nid = parseInt(id);
+ if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val });
+ }
+ if (generalRows.length) {
+ html += 'General
';
+ html += '';
+ for (const r of generalRows) html += `${r.name} ${r.value} `;
+ html += '
';
+ }
+
+ // Masteries section
+ let masteryRows = [];
+ for (const [id, val] of Object.entries(props)) {
+ const nid = parseInt(id);
+ if (TS_MASTERIES[nid]) {
+ const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`;
+ masteryRows.push({ name: TS_MASTERIES[nid], value: mName });
+ }
+ }
+ if (masteryRows.length) {
+ html += 'Masteries
';
+ html += '';
+ for (const m of masteryRows) html += `${m.name} ${m.value} `;
+ html += '
';
+ }
+
+ // Society section
+ let societyRows = [];
+ for (const [id, val] of Object.entries(props)) {
+ const nid = parseInt(id);
+ if (TS_SOCIETY[nid] && val > 0) {
+ societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val });
+ }
+ }
+ if (societyRows.length) {
+ html += 'Society
';
+ html += '';
+ for (const s of societyRows) html += `${s.name} ${s.rank} (${s.value}) `;
+ html += '
';
+ }
+
+ otherBox.innerHTML = html || 'No additional data
';
+ }
+
+ // -- Allegiance section --
+ const allegDiv = document.getElementById(`charAllegiance-${esc}`);
+ if (allegDiv && data.allegiance) {
+ const a = data.allegiance;
+ let html = 'Allegiance
';
+ html += '';
+ if (a.name) html += `Name ${a.name} `;
+ if (a.monarch) html += `Monarch ${a.monarch.name || '\u2014'} `;
+ if (a.patron) html += `Patron ${a.patron.name || '\u2014'} `;
+ if (a.rank !== undefined) html += `Rank ${a.rank} `;
+ if (a.followers !== undefined) html += `Followers ${a.followers} `;
+ html += '
';
+ allegDiv.innerHTML = html;
+ }
+}
+
+function updateCharacterVitals(name, vitals) {
+ const esc = CSS.escape(name);
+ const vitalsDiv = document.getElementById(`charVitals-${esc}`);
+ if (!vitalsDiv) return;
+
+ const vitalElements = vitalsDiv.querySelectorAll('.ts-vital');
+
+ if (vitalElements[0]) {
+ const fill = vitalElements[0].querySelector('.ts-vital-fill');
+ const txt = vitalElements[0].querySelector('.ts-vital-text');
+ if (fill) fill.style.width = `${vitals.health_percentage || 0}%`;
+ if (txt && vitals.health_current !== undefined) {
+ txt.textContent = `${vitals.health_current} / ${vitals.health_max}`;
+ }
+ }
+ if (vitalElements[1]) {
+ const fill = vitalElements[1].querySelector('.ts-vital-fill');
+ const txt = vitalElements[1].querySelector('.ts-vital-text');
+ if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`;
+ if (txt && vitals.stamina_current !== undefined) {
+ txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`;
+ }
+ }
+ if (vitalElements[2]) {
+ const fill = vitalElements[2].querySelector('.ts-vital-fill');
+ const txt = vitalElements[2].querySelector('.ts-vital-text');
+ if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`;
+ if (txt && vitals.mana_current !== undefined) {
+ txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`;
+ }
+ }
+}
+
+// Inventory tooltip functions
+let inventoryTooltip = null;
+
+function showInventoryTooltip(e, slot) {
+ if (!inventoryTooltip) {
+ inventoryTooltip = document.createElement('div');
+ inventoryTooltip.className = 'inventory-tooltip';
+ document.body.appendChild(inventoryTooltip);
+ }
+
+ const name = slot.dataset.name;
+ const value = parseInt(slot.dataset.value) || 0;
+ const burden = parseInt(slot.dataset.burden) || 0;
+
+ // Build enhanced tooltip
+ let tooltipHTML = `${name}
`;
+
+ // Basic stats
+ tooltipHTML += ``;
+
+ // Enhanced data from inventory service
+ if (slot.dataset.enhancedData) {
+ try {
+ const enhanced = JSON.parse(slot.dataset.enhancedData);
+
+ // Only show enhanced sections if we have enhanced data
+ if (Object.keys(enhanced).length > 0) {
+
+ // Helper function to check for valid values
+ const isValid = (val) => val !== undefined && val !== null && val !== -1 && val !== -1.0;
+
+ // Weapon-specific stats section (for weapons)
+ if (enhanced.damage_range || enhanced.speed_text || enhanced.equip_skill_name) {
+ tooltipHTML += ``;
+
+ // Skill requirement
+ if (enhanced.equip_skill_name) {
+ tooltipHTML += `
Skill: ${enhanced.equip_skill_name}
`;
+ }
+
+ // Damage with type
+ if (enhanced.damage_range && enhanced.damage_type) {
+ const damageText = enhanced.damage_type !== 'Physical' ?
+ `${enhanced.damage_range}, ${enhanced.damage_type}` :
+ enhanced.damage_range;
+ tooltipHTML += `
Damage: ${damageText}
`;
+ }
+
+ // Speed
+ if (enhanced.speed_text) {
+ tooltipHTML += `
Speed: ${enhanced.speed_text}
`;
+ }
+
+ // Attack and defense bonuses (as percentages)
+ if (isValid(enhanced.attack_bonus)) {
+ const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1);
+ if (attackPercent !== "0.0") {
+ tooltipHTML += `
Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%
`;
+ }
+ }
+
+ // Defense bonuses
+ if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) {
+ const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1);
+ if (defensePercent !== "0.0") {
+ tooltipHTML += `
Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%
`;
+ }
+ }
+
+ // Magic defense bonus
+ if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) {
+ const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1);
+ if (magicDefensePercent !== "0.0") {
+ tooltipHTML += `
Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%
`;
+ }
+ }
+
+ // Elemental damage vs monsters
+ if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) {
+ const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1);
+ if (elementalPercent !== "0.0") {
+ tooltipHTML += `
Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%
`;
+ }
+ }
+
+ tooltipHTML += `
`;
+ }
+
+ // Traditional combat stats section (for non-weapons or additional stats)
+ const combatProps = [];
+ if (isValid(enhanced.armor_level)) combatProps.push(`Armor Level: ${enhanced.armor_level}`);
+ if (!enhanced.damage_range && isValid(enhanced.max_damage)) combatProps.push(`Max Damage: ${enhanced.max_damage}`);
+ if (!enhanced.attack_bonus && isValid(enhanced.damage_bonus)) combatProps.push(`Damage Bonus: ${enhanced.damage_bonus.toFixed(1)}`);
+
+ if (combatProps.length > 0) {
+ tooltipHTML += ``;
+ }
+
+ // Requirements section
+ const reqProps = [];
+ if (isValid(enhanced.wield_level)) reqProps.push(`Level Required: ${enhanced.wield_level}`);
+ if (isValid(enhanced.skill_level)) reqProps.push(`Skill Level: ${enhanced.skill_level}`);
+ if (enhanced.equip_skill_name) reqProps.push(`Skill: ${enhanced.equip_skill_name}`);
+ if (isValid(enhanced.lore_requirement)) reqProps.push(`Lore: ${enhanced.lore_requirement}`);
+
+ if (reqProps.length > 0) {
+ tooltipHTML += ``;
+ }
+
+ // Enhancement section
+ const enhanceProps = [];
+ if (enhanced.material_name) enhanceProps.push(`Material: ${enhanced.material_name}`);
+ if (enhanced.imbue) enhanceProps.push(`Imbue: ${enhanced.imbue}`);
+ if (enhanced.item_set) enhanceProps.push(`Set: ${enhanced.item_set}`);
+ if (isValid(enhanced.tinks)) enhanceProps.push(`Tinks: ${enhanced.tinks}`);
+ // Use workmanship_text if available, otherwise numeric value
+ if (enhanced.workmanship_text) {
+ enhanceProps.push(`Workmanship: ${enhanced.workmanship_text}`);
+ } else if (isValid(enhanced.workmanship)) {
+ enhanceProps.push(`Workmanship: ${enhanced.workmanship.toFixed(1)}`);
+ }
+
+ if (enhanceProps.length > 0) {
+ tooltipHTML += ``;
+ }
+
+ // Ratings section
+ const ratingProps = [];
+ if (isValid(enhanced.damage_rating)) ratingProps.push(`Damage Rating: ${enhanced.damage_rating}`);
+ if (isValid(enhanced.crit_rating)) ratingProps.push(`Crit Rating: ${enhanced.crit_rating}`);
+ if (isValid(enhanced.heal_boost_rating)) ratingProps.push(`Heal Boost: ${enhanced.heal_boost_rating}`);
+
+ if (ratingProps.length > 0) {
+ tooltipHTML += ``;
+ }
+
+ // Spells section (condensed list)
+ if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
+ const spellNames = enhanced.spells.spells.map(spell => spell.name).join(', ');
+ tooltipHTML += ``;
+ }
+
+ // Mana and Spellcraft section
+ if (enhanced.mana_display || enhanced.spellcraft) {
+ tooltipHTML += ``;
+ }
+
+ // Detailed Spell Descriptions section
+ if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
+ tooltipHTML += ``;
+ }
+
+ // Object class info
+ if (enhanced.object_class_name) {
+ tooltipHTML += ``;
+ }
+
+ } // End of enhanced data check
+
+ } catch (e) {
+ console.warn('Failed to parse enhanced tooltip data', e);
+ }
+ }
+
+ inventoryTooltip.innerHTML = tooltipHTML;
+
+ // Position tooltip near cursor
+ const x = e.clientX + 10;
+ const y = e.clientY + 10;
+ inventoryTooltip.style.left = `${x}px`;
+ inventoryTooltip.style.top = `${y}px`;
+ inventoryTooltip.style.display = 'block';
+}
+
+function hideInventoryTooltip() {
+ if (inventoryTooltip) {
+ inventoryTooltip.style.display = 'none';
+ }
+}
+
+
+const applyTransform = () =>
+ group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
+
+function clampPan() {
+ if (!imgW) return;
+ const r = wrap.getBoundingClientRect();
+ const vw = r.width, vh = r.height;
+ const mw = imgW * scale, mh = imgH * scale;
+
+ offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX));
+ offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY));
+}
+
+function updateView() {
+ clampPan();
+ applyTransform();
+
+ // Throttled heat map re-rendering during pan/zoom
+ if (heatmapEnabled && heatmapData && !heatTimeout) {
+ heatTimeout = setTimeout(() => {
+ renderHeatmap();
+ heatTimeout = null;
+ }, HEAT_THROTTLE);
+ }
+}
+
+let pendingFrame = null;
+function scheduleViewUpdate() {
+ if (!pendingFrame) {
+ pendingFrame = requestAnimationFrame(() => {
+ updateView();
+ pendingFrame = null;
+ });
+ }
+}
+
+function fitToWindow() {
+ const r = wrap.getBoundingClientRect();
+ scale = Math.min(r.width / imgW, r.height / imgH);
+ minScale = scale;
+ updateView();
+}
+
+/* ---------- tooltip handlers ------------------------------------ */
+function showTooltip(evt, p) {
+ tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`;
+ const r = wrap.getBoundingClientRect();
+ tooltip.style.left = `${evt.clientX - r.left + 10}px`;
+ tooltip.style.top = `${evt.clientY - r.top + 10}px`;
+ tooltip.style.display = 'block';
+}
+function hideTooltip() {
+ tooltip.style.display = 'none';
+}
+
+/* ---------- polling and initialization -------------------------- */
+async function pollLive() {
+ try {
+ const [liveRes, trailsRes] = await Promise.all([
+ fetch(`${API_BASE}/live/`),
+ fetch(`${API_BASE}/trails/?seconds=600`),
+ ]);
+ const { players } = await liveRes.json();
+ const { trails } = await trailsRes.json();
+ currentPlayers = players;
+ renderTrails(trails);
+ renderList();
+ } catch (e) {
+ handleError('Player update', e);
+ }
+}
+
+async function pollTotalRares() {
+ try {
+ const response = await fetch(`${API_BASE}/total-rares/`);
+ const data = await response.json();
+ updateTotalRaresDisplay(data);
+ } catch (e) {
+ handleError('Rare counter', e);
+ }
+}
+
+function updateTotalRaresDisplay(data) {
+ const countElement = document.getElementById('totalRaresCount');
+ if (countElement && data.all_time !== undefined && data.today !== undefined) {
+ const allTimeFormatted = data.all_time.toLocaleString();
+ const todayFormatted = data.today.toLocaleString();
+ countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`;
+ }
+}
+
+async function pollTotalKills() {
+ try {
+ const response = await fetch(`${API_BASE}/total-kills/`);
+ const data = await response.json();
+ updateTotalKillsDisplay(data);
+ } catch (e) {
+ handleError('Kill counter', e);
+ }
+}
+
+function updateTotalKillsDisplay(data) {
+ const killsElement = document.getElementById('totalKillsCount');
+ if (killsElement && data.total !== undefined) {
+ killsElement.textContent = data.total.toLocaleString();
+ }
+}
+
+async function pollServerHealth() {
+ try {
+ const response = await fetch(`${API_BASE}/server-health`);
+ const data = await response.json();
+ updateServerStatusDisplay(data);
+ } catch (e) {
+ handleError('Server health', e);
+ updateServerStatusDisplay({ status: 'error' });
+ }
+}
+
+function updateServerStatusDisplay(data) {
+ const statusDot = document.getElementById('statusDot');
+ const statusText = document.getElementById('statusText');
+ const playerCount = document.getElementById('playerCount');
+ const latencyMs = document.getElementById('latencyMs');
+ const uptime = document.getElementById('uptime');
+ const lastRestart = document.getElementById('lastRestart');
+
+ if (!statusDot || !statusText) return;
+
+ // Update status indicator
+ const status = data.status || 'unknown';
+ statusDot.className = `status-dot status-${status}`;
+ statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
+
+ // Update player count
+ if (playerCount) {
+ playerCount.textContent = data.player_count !== null && data.player_count !== undefined ? data.player_count : '-';
+ }
+
+ // Update latency
+ if (latencyMs) {
+ latencyMs.textContent = data.latency_ms ? Math.round(data.latency_ms) : '-';
+ }
+
+ // Update uptime
+ if (uptime) {
+ uptime.textContent = data.uptime || '-';
+ }
+
+ // Update last restart with Stockholm timezone (24h format, no year)
+ if (lastRestart) {
+ if (data.last_restart) {
+ const restartDate = new Date(data.last_restart);
+ const formattedDate = restartDate.toLocaleString('sv-SE', {
+ timeZone: 'Europe/Stockholm',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ });
+ lastRestart.textContent = formattedDate;
+ } else {
+ lastRestart.textContent = 'Unknown';
+ }
+ }
+}
+
+function handleServerStatusUpdate(msg) {
+ // Handle real-time server status updates via WebSocket
+ if (msg.status === 'up' && msg.message) {
+ // Show notification for server coming back online
+ debugLog(`Server Status: ${msg.message}`);
+ }
+
+ // Trigger an immediate server health poll to refresh the display
+ pollServerHealth();
+}
+
+function startPolling() {
+ // Clear any existing intervals first (prevents leak on re-init)
+ pollIntervals.forEach(id => clearInterval(id));
+ pollIntervals.length = 0;
+
+ // Initial fetches
+ pollLive();
+ pollTotalRares();
+ pollTotalKills();
+ pollServerHealth();
+
+ // Set up recurring polls
+ pollIntervals.push(setInterval(pollLive, POLL_MS));
+ pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS));
+ pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS));
+ pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS));
+}
+
+img.onload = () => {
+ imgW = img.naturalWidth;
+ imgH = img.naturalHeight;
+ // size the SVG trails container to match the map dimensions
+ if (trailsContainer) {
+ trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`);
+ trailsContainer.setAttribute('width', `${imgW}`);
+ trailsContainer.setAttribute('height', `${imgH}`);
+ }
+ fitToWindow();
+ startPolling();
+ initWebSocket();
+ initHeatMap();
+ initPortalMap();
+};
+
+// Ensure server health polling starts regardless of image loading
+document.addEventListener('DOMContentLoaded', () => {
+ // Start server health polling immediately on DOM ready
+ pollServerHealth();
+});
+
+/* ---------- rendering sorted list & dots ------------------------ */
+/**
+ * Filter and sort the currentPlayers, then render them.
+ */
+function renderList() {
+ // Filter by name prefix
+ const filtered = currentPlayers.filter(p =>
+ p.character_name.toLowerCase().startsWith(currentFilter)
+ );
+ // Sort filtered list
+ filtered.sort(currentSort.comparator);
+ const sorted = filtered;
+ render(sorted);
+}
+
+// Track when user might be interacting to avoid DOM manipulation during clicks
+let userInteracting = false;
+let interactionTimeout = null;
+
+// Add global mousedown/mouseup tracking to detect when user is clicking
+document.addEventListener('mousedown', () => {
+ userInteracting = true;
+ if (interactionTimeout) clearTimeout(interactionTimeout);
+});
+
+document.addEventListener('mouseup', () => {
+ // Give a small buffer after mouseup to ensure click events complete
+ if (interactionTimeout) clearTimeout(interactionTimeout);
+ interactionTimeout = setTimeout(() => {
+ userInteracting = false;
+ }, 50); // 50ms buffer
+});
+
+function render(players) {
+ const startTime = performance.now();
+ debugLog('🔄 RENDER STARTING:', new Date().toISOString());
+
+ // If user is actively clicking, defer this render briefly
+ if (userInteracting) {
+ debugLog('🔄 RENDER DEFERRED: User interaction detected');
+ setTimeout(() => render(players), 100);
+ return;
+ }
+
+ // Reset per-render stats
+ performanceStats.renderDotsCreated = 0;
+ performanceStats.renderDotsReused = 0;
+ performanceStats.renderListItemsCreated = 0;
+ performanceStats.renderListItemsReused = 0;
+ performanceStats.renderCount++;
+
+ // Get existing elements and map them by player name for reuse
+ const existingDots = Array.from(dots.children);
+ const existingListItems = Array.from(list.children);
+
+ // Create maps for efficient lookup by player name
+ const dotsByPlayer = new Map();
+ const listItemsByPlayer = new Map();
+
+ existingDots.forEach(dot => {
+ if (dot.playerData && dot.playerData.character_name) {
+ dotsByPlayer.set(dot.playerData.character_name, dot);
+ }
+ });
+
+ existingListItems.forEach(li => {
+ if (li.playerData && li.playerData.character_name) {
+ listItemsByPlayer.set(li.playerData.character_name, li);
+ }
+ });
+
+
+ // DON'T clear containers - we need to reuse elements
+
+ // Update header with active player count
+ const header = document.getElementById('activePlayersHeader');
+ if (header) {
+ header.textContent = `Active Mosswart Enjoyers (${players.length})`;
+ }
+
+ // Calculate and update server KPH
+ const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0);
+ const kphElement = document.getElementById('serverKphCount');
+ if (kphElement) {
+ // Format with commas and one decimal place for EPIC display
+ const formattedKPH = totalKPH.toLocaleString('en-US', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1
+ });
+ kphElement.textContent = formattedKPH;
+
+ // Add extra epic effect for high KPH
+ const container = document.getElementById('serverKphCounter');
+ if (container) {
+ if (totalKPH > 5000) {
+ container.classList.add('ultra-epic');
+ } else {
+ container.classList.remove('ultra-epic');
+ }
+ }
+ }
+
+ // Total kills is now fetched from the /total-kills/ API endpoint
+ // (see pollTotalKills function) to include ALL characters, not just online ones
+
+ players.forEach((p) => {
+ const { x, y } = worldToPx(p.ew, p.ns);
+
+ // Reuse existing dot by player name or create new one
+ let dot = dotsByPlayer.get(p.character_name);
+ if (!dot) {
+ dot = createNewDot();
+ dots.appendChild(dot);
+ } else {
+ performanceStats.dotsReused++;
+ performanceStats.renderDotsReused++;
+ // Remove from the map so we don't count it as unused later
+ dotsByPlayer.delete(p.character_name);
+ }
+
+ // Update dot properties
+ dot.style.left = `${x}px`;
+ dot.style.top = `${y}px`;
+ dot.style.background = getColorFor(p.character_name);
+ dot.playerData = p; // Store for event handlers
+
+ // Update highlight state
+ if (p.character_name === selected) {
+ dot.classList.add('highlight');
+ } else {
+ dot.classList.remove('highlight');
+ }
+
+ // Reuse existing list item by player name or create new one
+ let li = listItemsByPlayer.get(p.character_name);
+ if (!li) {
+ li = createNewListItem();
+ list.appendChild(li);
+ } else {
+ performanceStats.listItemsReused++;
+ performanceStats.renderListItemsReused++;
+ // Remove from the map so we don't count it as unused later
+ listItemsByPlayer.delete(p.character_name);
+ }
+
+ const color = getColorFor(p.character_name);
+ li.style.borderLeftColor = color;
+ li.playerData = p; // Store for event handlers BEFORE any DOM movement
+
+ // Also store playerData directly on buttons for more reliable access
+ if (li.chatBtn) li.chatBtn.playerData = p;
+ if (li.statsBtn) li.statsBtn.playerData = p;
+ if (li.inventoryBtn) li.inventoryBtn.playerData = p;
+ if (li.radarBtn) li.radarBtn.playerData = p;
+
+ // Only reorder element if it's actually out of place for current sort order
+ // Check if this element needs to be moved to maintain sort order
+ const expectedIndex = players.indexOf(p);
+ const currentIndex = Array.from(list.children).indexOf(li);
+
+ if (currentIndex !== expectedIndex && li.parentNode) {
+ // Find the correct position to insert
+ if (expectedIndex === players.length - 1) {
+ // Should be last - only move if it's not already last
+ if (li !== list.lastElementChild) {
+ list.appendChild(li);
+ }
+ } else {
+ // Should be at a specific position
+ const nextPlayer = players[expectedIndex + 1];
+ const nextElement = Array.from(list.children).find(el =>
+ el.playerData && el.playerData.character_name === nextPlayer.character_name
+ );
+ if (nextElement && li.nextElementSibling !== nextElement) {
+ list.insertBefore(li, nextElement);
+ }
+ }
+ }
+
+ // Calculate KPR (Kills Per Rare)
+ const playerTotalKills = p.total_kills || 0;
+ const totalRares = p.total_rares || 0;
+ const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞';
+
+ // Update only the grid content via innerHTML (buttons preserved)
+ li.gridContent.innerHTML = `
+ ${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)}
+ ${createVitalsHTML(p.character_name)}
+ ${p.kills}
+ ${p.total_kills || 0}
+ ${p.kills_per_hour}
+ ${p.session_rares}/${p.total_rares}
+ ${kpr}
+ ${p.vt_state}
+ ${p.onlinetime}
+ ${p.deaths}/${p.total_deaths || 0}
+ ${p.prismatic_taper_count || 0}
+ `;
+
+ // Color the metastate pill according to its value
+ const metaSpan = li.querySelector('.stat.meta');
+ if (metaSpan) {
+ const goodStates = ['default', 'default2', 'hunt', 'combat'];
+ const state = (p.vt_state || '').toString().toLowerCase();
+ metaSpan.classList.remove('green', 'red'); // Clear previous
+ if (goodStates.includes(state)) {
+ metaSpan.classList.add('green');
+ } else {
+ metaSpan.classList.add('red');
+ }
+ }
+
+ // Update selected state
+ if (p.character_name === selected) {
+ li.classList.add('selected');
+ } else {
+ li.classList.remove('selected');
+ }
+ });
+
+ // Remove unused elements (any elements left in the maps are unused)
+ // These are dots for players that are no longer in the current player list
+ dotsByPlayer.forEach((dot, playerName) => {
+ dots.removeChild(dot);
+ });
+
+ // These are list items for players that are no longer in the current player list
+ listItemsByPlayer.forEach((li, playerName) => {
+ list.removeChild(li);
+ });
+
+ // Update performance stats
+ performanceStats.lastRenderTime = performance.now() - startTime;
+
+ // Determine optimization status
+ const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated;
+ const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused;
+ const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0;
+ const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0;
+
+ // Choose icon and color
+ let statusIcon = '🚀';
+ let colorStyle = '';
+ if (isOptimized) {
+ statusIcon = '✨';
+ colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;';
+ } else if (isPartiallyOptimized) {
+ statusIcon = '⚡';
+ colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;';
+ } else {
+ statusIcon = '🔥';
+ colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;';
+ }
+
+ // Performance stats are tracked but not logged to keep console clean
+ // Optimization is achieving 100% element reuse consistently
+
+ const renderTime = performance.now() - startTime;
+ debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms');
+}
+
+/* ---------- rendering trails ------------------------------- */
+function renderTrails(trailData) {
+ trailsContainer.innerHTML = '';
+ // Build point strings directly - avoid intermediate arrays
+ const byChar = {};
+ for (const pt of trailData) {
+ const { x, y } = worldToPx(pt.ew, pt.ns);
+ const key = pt.character_name;
+ if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 };
+ else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; }
+ }
+ for (const name in byChar) {
+ if (byChar[name].count < 2) continue;
+ const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ poly.setAttribute('points', byChar[name].points);
+ poly.setAttribute('stroke', getColorFor(name));
+ poly.setAttribute('fill', 'none');
+ poly.setAttribute('class', 'trail-path');
+ trailsContainer.appendChild(poly);
+ }
+}
+/* ---------- selection centering, focus zoom & blink ------------ */
+function selectPlayer(p, x, y) {
+ selected = p.character_name;
+ // set focus zoom
+ scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
+ // center on the player
+ const r = wrap.getBoundingClientRect();
+ offX = r.width / 2 - x * scale;
+ offY = r.height / 2 - y * scale;
+ updateView();
+ renderList(); // keep sorted + highlight
+}
+
+/*
+ * ---------- Chat & Command WebSocket Handlers ------------------
+ * Maintains a persistent WebSocket connection to the /ws/live endpoint
+ * for receiving chat messages and sending user commands to plugin clients.
+ * Reconnects automatically on close and logs errors.
+ */
+// Initialize WebSocket for chat and command streams
+function initWebSocket() {
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
+ socket = new WebSocket(wsUrl);
+ socket.addEventListener('message', evt => {
+ let msg;
+ try { msg = JSON.parse(evt.data); } catch { return; }
+ if (msg.type === 'chat') {
+ appendChatMessage(msg);
+ } else if (msg.type === 'vitals') {
+ updateVitalsDisplay(msg);
+ } else if (msg.type === 'rare') {
+ triggerEpicRareNotification(msg.character_name, msg.name);
+ } else if (msg.type === 'character_stats') {
+ characterStats[msg.character_name] = msg;
+ updateCharacterWindow(msg.character_name, msg);
+ if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
+ renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
+ }
+ } else if (msg.type === 'equipment_cantrip_state') {
+ equipmentCantripStates[msg.character_name] = msg;
+ if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
+ renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
+ }
+ } else if (msg.type === 'inventory_delta') {
+ updateInventoryLive(msg);
+ } else if (msg.type === 'server_status') {
+ handleServerStatusUpdate(msg);
+ } else if (msg.type === 'nearby_objects') {
+ updateRadarWindow(msg);
+ } else if (msg.type === 'dungeon_map') {
+ if (msg.landblock) {
+ dungeonMapCache[msg.landblock] = msg;
+ // Update active radar window for this character
+ const rw = radarWindows[msg.character_name];
+ if (rw) rw._radarDungeonLandblock = msg.landblock;
+ }
+ } else if (msg.type === 'combat_stats') {
+ liveCombatStats[msg.character_name] = msg;
+ updateCombatStatsWindows(msg.character_name);
+ } else if (msg.type === 'share_peer_removed') {
+ removeVitalSharingPeer(msg.character_name);
+ } else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
+ updateVitalSharingPeer(msg);
+ }
+ });
+ socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
+ socket.addEventListener('error', e => handleError('WebSocket', e));
+}
+
+// Display or create a chat window for a character
+function showChatWindow(name) {
+ debugLog('showChatWindow called for:', name);
+ const windowId = `chatWindow-${name}`;
+
+ const { win, content, isNew } = createWindow(
+ windowId, `Chat: ${name}`, 'chat-window'
+ );
+
+ if (!isNew) {
+ debugLog('Existing chat window found, showing it');
+ return;
+ }
+
+ win.dataset.character = name;
+ chatWindows[name] = win;
+
+ // Messages container
+ const msgs = document.createElement('div');
+ msgs.className = 'chat-messages';
+ content.appendChild(msgs);
+
+ // Input form
+ const form = document.createElement('form');
+ form.className = 'chat-form';
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'chat-input';
+ input.placeholder = 'Enter chat...';
+ form.appendChild(input);
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (!text) return;
+ // Send command envelope: player_name and command only
+ socket.send(JSON.stringify({ player_name: name, command: text }));
+ input.value = '';
+ });
+ content.appendChild(form);
+
+ debugLog('Chat window created for:', name);
+}
+
+// Append a chat message to the correct window
+/**
+ * Append a chat message to the correct window, optionally coloring the text.
+ * msg: { type: 'chat', character_name, text, color? }
+ */
+function appendChatMessage(msg) {
+ const { character_name: name, text, color } = msg;
+ const win = chatWindows[name];
+ if (!win) return;
+ const msgs = win.querySelector('.chat-messages');
+ const p = document.createElement('div');
+ if (color !== undefined) {
+ let c = color;
+ if (typeof c === 'number') {
+ // map numeric chat code to configured color, or fallback to raw hex
+ if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
+ c = CHAT_COLOR_MAP[c];
+ } else {
+ c = '#' + c.toString(16).padStart(6, '0');
+ }
+ }
+ p.style.color = c;
+ }
+ p.textContent = text;
+ msgs.appendChild(p);
+ // Enforce max number of lines in scrollback
+ while (msgs.children.length > MAX_CHAT_LINES) {
+ msgs.removeChild(msgs.firstChild);
+ }
+ // Scroll to bottom
+ msgs.scrollTop = msgs.scrollHeight;
+}
+
+/* ---------- pan & zoom handlers -------------------------------- */
+wrap.addEventListener('wheel', e => {
+ e.preventDefault();
+ if (!imgW) return;
+
+ const r = wrap.getBoundingClientRect();
+ const mx = (e.clientX - r.left - offX) / scale;
+ const my = (e.clientY - r.top - offY) / scale;
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
+ let ns = scale * factor;
+ ns = Math.max(minScale, Math.min(MAX_Z, ns));
+
+ offX -= mx * (ns - scale);
+ offY -= my * (ns - scale);
+ scale = ns;
+ scheduleViewUpdate();
+}, { passive: false });
+
+wrap.addEventListener('mousedown', e => {
+ dragging = true; sx = e.clientX; sy = e.clientY;
+ wrap.classList.add('dragging');
+});
+window.addEventListener('mousemove', e => {
+ if (!dragging) return;
+ offX += e.clientX - sx; offY += e.clientY - sy;
+ sx = e.clientX; sy = e.clientY;
+ scheduleViewUpdate();
+});
+window.addEventListener('mouseup', () => {
+ dragging = false; wrap.classList.remove('dragging');
+});
+
+wrap.addEventListener('touchstart', e => {
+ if (e.touches.length !== 1) return;
+ dragging = true;
+ sx = e.touches[0].clientX; sy = e.touches[0].clientY;
+});
+wrap.addEventListener('touchmove', e => {
+ if (!dragging || e.touches.length !== 1) return;
+ const t = e.touches[0];
+ offX += t.clientX - sx; offY += t.clientY - sy;
+ sx = t.clientX; sy = t.clientY;
+ scheduleViewUpdate();
+});
+wrap.addEventListener('touchend', () => {
+ dragging = false;
+});
+
+/* ---------- coordinate display on hover ---------------------------- */
+wrap.addEventListener('mousemove', e => {
+ if (!imgW) return;
+
+ const r = wrap.getBoundingClientRect();
+ const x = e.clientX - r.left;
+ const y = e.clientY - r.top;
+
+ const { ew, ns } = pxToWorld(x, y);
+
+ // Display coordinates using the same format as the existing loc function
+ coordinates.textContent = loc(ns, ew);
+ coordinates.style.left = `${x + 10}px`;
+ coordinates.style.top = `${y + 10}px`;
+ coordinates.style.display = 'block';
+});
+
+wrap.addEventListener('mouseleave', () => {
+ coordinates.style.display = 'none';
+});
+
+/* ---------- vitals display functions ----------------------------- */
+// Store vitals data per character
+const characterVitals = {};
+const characterStats = {};
+const characterWindows = {};
+
+function updateVitalsDisplay(vitalsMsg) {
+ // Store the vitals data for this character
+ characterVitals[vitalsMsg.character_name] = {
+ health_percentage: vitalsMsg.health_percentage,
+ stamina_percentage: vitalsMsg.stamina_percentage,
+ mana_percentage: vitalsMsg.mana_percentage,
+ health_current: vitalsMsg.health_current,
+ health_max: vitalsMsg.health_max,
+ stamina_current: vitalsMsg.stamina_current,
+ stamina_max: vitalsMsg.stamina_max,
+ mana_current: vitalsMsg.mana_current,
+ mana_max: vitalsMsg.mana_max,
+ vitae: vitalsMsg.vitae
+ };
+
+ // Re-render the player list to update vitals in the UI
+ renderList();
+
+ // Also update character window if open
+ updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]);
+}
+
+function createVitalsHTML(characterName) {
+ const vitals = characterVitals[characterName];
+ if (!vitals) {
+ return ''; // No vitals data available
+ }
+
+ return `
+
+ `;
+}
+
+function createVitaeIndicator(characterName) {
+ const vitals = characterVitals[characterName];
+ if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
+ return ''; // No vitae penalty
+ }
+
+ return `⚰️ ${vitals.vitae}% `;
+}
+
+function getVitalClass(percentage) {
+ if (percentage <= 25) {
+ return 'critical-vital';
+ } else if (percentage <= 50) {
+ return 'low-vital';
+ }
+ return '';
+}
+
+/* ---------- epic rare notification system ------------------------ */
+// Track previous rare count to detect increases
+let lastRareCount = 0;
+let notificationQueue = [];
+let isShowingNotification = false;
+
+function triggerEpicRareNotification(characterName, rareName) {
+ // Add to queue
+ notificationQueue.push({ characterName, rareName });
+
+ // Process queue if not already showing a notification
+ if (!isShowingNotification) {
+ processNotificationQueue();
+ }
+
+ // Trigger fireworks immediately
+ createFireworks();
+
+ // Highlight the player in the list
+ highlightRareFinder(characterName);
+}
+
+function processNotificationQueue() {
+ if (notificationQueue.length === 0) {
+ isShowingNotification = false;
+ return;
+ }
+
+ isShowingNotification = true;
+ const notification = notificationQueue.shift();
+
+ // Create notification element
+ const container = document.getElementById('rareNotifications');
+ const notifEl = document.createElement('div');
+ notifEl.className = 'rare-notification';
+ notifEl.innerHTML = `
+ 🎆 LEGENDARY RARE! 🎆
+ ${notification.rareName}
+ found by
+ ⚔️ ${notification.characterName} ⚔️
+ `;
+
+ container.appendChild(notifEl);
+
+ // Remove notification after display duration and process next
+ setTimeout(() => {
+ notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
+ setTimeout(() => {
+ notifEl.remove();
+ processNotificationQueue();
+ }, 500);
+ }, NOTIFICATION_DURATION_MS);
+}
+
+// Add slide out animation
+const style = document.createElement('style');
+style.textContent = `
+ @keyframes notification-slide-out {
+ to {
+ transform: translateY(-100px);
+ opacity: 0;
+ }
+ }
+`;
+document.head.appendChild(style);
+
+function createFireworks() {
+ const container = document.getElementById('fireworksContainer');
+ const rareCounter = document.getElementById('totalRaresCounter');
+ const rect = rareCounter.getBoundingClientRect();
+
+ // Create 30 particles
+ const particleCount = 30;
+ const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue'];
+
+ for (let i = 0; i < particleCount; i++) {
+ const particle = document.createElement('div');
+ particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`;
+
+ // Start position at rare counter
+ particle.style.left = `${rect.left + rect.width / 2}px`;
+ particle.style.top = `${rect.top + rect.height / 2}px`;
+
+ // Random explosion direction
+ const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
+ const velocity = 100 + Math.random() * 200;
+ const dx = Math.cos(angle) * velocity;
+ const dy = Math.sin(angle) * velocity - 50; // Slight upward bias
+
+ // Create custom animation for this particle
+ const animName = `particle-${Date.now()}-${i}`;
+ const keyframes = `
+ @keyframes ${animName} {
+ 0% {
+ transform: translate(0, 0) scale(1);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(${dx}px, ${dy + 200}px) scale(0);
+ opacity: 0;
+ }
+ }
+ `;
+
+ const styleEl = document.createElement('style');
+ styleEl.textContent = keyframes;
+ document.head.appendChild(styleEl);
+
+ particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
+
+ container.appendChild(particle);
+
+ // Clean up particle and animation after completion
+ setTimeout(() => {
+ particle.remove();
+ styleEl.remove();
+ }, 2000);
+ }
+}
+
+function highlightRareFinder(characterName) {
+ // Use element pool for O(1) lookup instead of querySelectorAll
+ for (const item of elementPools.activeListItems) {
+ if (item.playerData && item.playerData.character_name === characterName) {
+ item.classList.add('rare-finder-glow');
+ setTimeout(() => {
+ item.classList.remove('rare-finder-glow');
+ }, GLOW_DURATION_MS);
+ break;
+ }
+ }
+}
+
+// Update total rares display to trigger fireworks on increase
+const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay;
+updateTotalRaresDisplay = function(data) {
+ originalUpdateTotalRaresDisplay(data);
+
+ // Check if total increased
+ const newTotal = data.all_time || 0;
+ if (newTotal > lastRareCount && lastRareCount > 0) {
+ // Don't trigger on initial load
+ createFireworks();
+
+ // Check for milestones when count increases
+ if (newTotal > 0 && newTotal % 100 === 0) {
+ triggerMilestoneCelebration(newTotal);
+ }
+ }
+ lastRareCount = newTotal;
+}
+
+function triggerMilestoneCelebration(rareNumber) {
+ debugLog(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`);
+
+ // Create full-screen milestone overlay
+ const overlay = document.createElement('div');
+ overlay.className = 'milestone-overlay';
+
+ overlay.innerHTML = `
+
+
#${rareNumber}
+
🏆 EPIC MILESTONE! 🏆
+
Server Achievement Unlocked
+
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Add screen shake effect
+ document.body.classList.add('screen-shake');
+
+ // Create massive firework explosion
+ createMilestoneFireworks();
+
+ // Remove milestone overlay after 5 seconds
+ setTimeout(() => {
+ overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse';
+ document.body.classList.remove('screen-shake');
+ setTimeout(() => {
+ overlay.remove();
+ }, 500);
+ }, 5000);
+}
+
+function createMilestoneFireworks() {
+ const container = document.getElementById('fireworksContainer');
+
+ // Create multiple bursts across the screen
+ const burstCount = 5;
+ const particlesPerBurst = 50;
+ const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00'];
+
+ for (let burst = 0; burst < burstCount; burst++) {
+ setTimeout(() => {
+ // Random position for each burst
+ const x = Math.random() * window.innerWidth;
+ const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15);
+
+ for (let i = 0; i < particlesPerBurst; i++) {
+ const particle = document.createElement('div');
+ particle.className = 'milestone-particle';
+ particle.style.background = colors[Math.floor(Math.random() * colors.length)];
+ particle.style.boxShadow = `0 0 12px ${particle.style.background}`;
+
+ // Start position
+ particle.style.left = `${x}px`;
+ particle.style.top = `${y}px`;
+
+ // Random explosion direction
+ const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8;
+ const velocity = 200 + Math.random() * 300;
+ const dx = Math.cos(angle) * velocity;
+ const dy = Math.sin(angle) * velocity - 100; // Upward bias
+
+ // Create custom animation
+ const animName = `milestone-particle-${Date.now()}-${burst}-${i}`;
+ const keyframes = `
+ @keyframes ${animName} {
+ 0% {
+ transform: translate(0, 0) scale(1);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(${dx}px, ${dy + 400}px) scale(0);
+ opacity: 0;
+ }
+ }
+ `;
+
+ const styleEl = document.createElement('style');
+ styleEl.textContent = keyframes;
+ document.head.appendChild(styleEl);
+
+ particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
+
+ container.appendChild(particle);
+
+ // Clean up
+ setTimeout(() => {
+ particle.remove();
+ styleEl.remove();
+ }, 3000);
+ }
+ }, burst * 200); // Stagger bursts
+ }
+}
+
+/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */
+
+/**
+ * Opens the dedicated inventory search page in a new browser tab.
+ */
+function openInventorySearch() {
+ // Open the dedicated inventory search page in a new tab
+ window.open('/inventory.html', '_blank');
+}
+
+/**
+ * Opens the Suitbuilder interface in a new browser tab.
+ */
+function openSuitbuilder() {
+ // Open the Suitbuilder page in a new tab
+ window.open('/suitbuilder.html', '_blank');
+}
+
+/**
+ * Opens the Player Debug interface in a new browser tab.
+ */
+function openPlayerDebug() {
+ // Open the Player Debug page in a new tab
+ window.open('/debug.html', '_blank');
+}
+
+/**
+ * Opens the Quest Status interface in a new browser tab.
+ */
+function openQuestStatus() {
+ // Open the Quest Status page in a new tab
+ window.open('/quest-status.html', '_blank');
+}
+
+/**
+ * Opens the Player Dashboard interface in a new browser tab.
+ */
+function openPlayerDashboard() {
+ // Open the Player Dashboard page in a new tab
+ window.open('/player-dashboard.html', '_blank');
+}
+
+// ─── Radar Window ──────────────────────────────────────────────────
+
+const radarWindows = {}; // character_name -> window element
+const dungeonMapCache = {}; // landblock hex string -> dungeon map data
+let dungeonTileCanvases = null; // env_id -> processed offscreen canvas
+
+// UB default source colors (ARGB as signed int32 → R,G,B)
+// These are the colors embedded in the tile images that get remapped
+const UB_TILE_COLORS = {
+ walls: { r: 0, g: 0, b: 255 }, // -16777089 → #0000FF
+ innerWalls: { r: 127, g: 127, b: 255 }, // -8404993 → #7F7FFF
+ rampedWalls: { r: 77, g: 255, b: 255 }, // -11622657 → #4DFFFF (approx)
+ floors: { r: 0, g: 127, b: 255 }, // -16744513 → #007FFF
+ stairs: { r: 0, g: 63, b: 255 }, // -16760961 → #003FFF
+};
+
+// Target display colors (UB-like appearance on dark background)
+const DUNGEON_DISPLAY_COLORS = {
+ walls: { r: 140, g: 140, b: 180 }, // light gray-blue walls
+ innerWalls: { r: 100, g: 100, b: 140 }, // darker inner walls
+ rampedWalls: { r: 120, g: 160, b: 120 }, // greenish ramps
+ floors: { r: 60, g: 80, b: 60 }, // dark green floors
+ stairs: { r: 180, g: 160, b: 80 }, // yellowish stairs
+};
+
+// Process a tile image: make white transparent, remap UB colors
+function processTileImage(img) {
+ const c = document.createElement('canvas');
+ c.width = 10;
+ c.height = 10;
+ const ctx = c.getContext('2d');
+ ctx.drawImage(img, 0, 0, 10, 10);
+ const imageData = ctx.getImageData(0, 0, 10, 10);
+ const d = imageData.data;
+
+ for (let i = 0; i < d.length; i += 4) {
+ const r = d[i], g = d[i+1], b = d[i+2];
+
+ // Make white (and near-white) transparent
+ if (r > 240 && g > 240 && b > 240) {
+ d[i+3] = 0; // alpha = 0
+ continue;
+ }
+
+ // Color remap: match source colors and replace with display colors
+ let matched = false;
+ for (const [key, src] of Object.entries(UB_TILE_COLORS)) {
+ if (Math.abs(r - src.r) < 15 && Math.abs(g - src.g) < 15 && Math.abs(b - src.b) < 15) {
+ const dst = DUNGEON_DISPLAY_COLORS[key];
+ d[i] = dst.r; d[i+1] = dst.g; d[i+2] = dst.b;
+ matched = true;
+ break;
+ }
+ }
+
+ // Make black semi-transparent (outlines)
+ if (!matched && r < 15 && g < 15 && b < 15) {
+ d[i+3] = 100;
+ }
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ return c;
+}
+
+// Load dungeon tile textures (614 tiles, ~287KB JSON)
+function loadDungeonTiles() {
+ if (dungeonTileCanvases) return; // already loading/loaded
+ dungeonTileCanvases = {};
+ fetch('dungeon_tiles.json')
+ .then(r => r.json())
+ .then(data => {
+ let loaded = 0;
+ const total = Object.keys(data).length;
+ Object.entries(data).forEach(([envId, dataUrl]) => {
+ const img = new Image();
+ img.onload = () => {
+ dungeonTileCanvases[envId] = processTileImage(img);
+ loaded++;
+ if (loaded === total) console.log(`Processed ${total} dungeon tiles`);
+ };
+ img.src = dataUrl;
+ });
+ })
+ .catch(err => console.warn('Failed to load dungeon tiles:', err));
+}
+
+const RADAR_COLORS = {
+ Monster: '#ff4444',
+ Player: '#4488ff',
+ NPC: '#44cc44',
+ Vendor: '#44cc44',
+ Portal: '#aa44ff',
+ Corpse: '#ff8800',
+ Container: '#cccc44',
+ Door: '#888888',
+};
+
+const RADAR_CANVAS_SIZE = 300;
+const RADAR_DEFAULT_RANGE = 0.5; // AC coordinate units (~120m)
+
+function showRadarWindow(name) {
+ const windowId = `radarWindow-${name}`;
+
+ const { win, content, isNew } = createWindow(
+ windowId, `Radar: ${name}`, 'radar-window', {
+ onClose: () => {
+ // Send stop_radar command
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ player_name: name, command: 'stop_radar' }));
+ }
+ delete radarWindows[name];
+ }
+ }
+ );
+
+ if (!isNew) {
+ // Window was hidden, not destroyed — re-register and restart streaming
+ radarWindows[name] = win;
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ player_name: name, command: 'start_radar' }));
+ }
+ return;
+ }
+
+ radarWindows[name] = win;
+ win.dataset.character = name;
+
+ // Radar controls
+ const controls = document.createElement('div');
+ controls.className = 'radar-controls';
+
+ const rangeDisplay = document.createElement('span');
+ rangeDisplay.className = 'radar-range-display';
+ rangeDisplay.textContent = 'Range: ~120m';
+ controls.appendChild(rangeDisplay);
+
+ const zoomHint = document.createElement('span');
+ zoomHint.className = 'radar-zoom-hint';
+ zoomHint.textContent = 'Scroll to zoom';
+ controls.appendChild(zoomHint);
+
+ content.appendChild(controls);
+
+ // Canvas
+ const canvas = document.createElement('canvas');
+ canvas.className = 'radar-canvas';
+ canvas.width = RADAR_CANVAS_SIZE;
+ canvas.height = RADAR_CANVAS_SIZE;
+ content.appendChild(canvas);
+
+ // Entity list
+ const listContainer = document.createElement('div');
+ listContainer.className = 'radar-entity-list';
+
+ const listHeader = document.createElement('div');
+ listHeader.className = 'radar-entity-header';
+ listHeader.innerHTML = 'Name Type Dist Dir ';
+ listContainer.appendChild(listHeader);
+
+ const listBody = document.createElement('div');
+ listBody.className = 'radar-entity-body';
+ listContainer.appendChild(listBody);
+ content.appendChild(listContainer);
+
+ // Load map image for radar background
+ const radarMapImg = new Image();
+ radarMapImg.src = 'dereth.png';
+
+ // Store refs on the window
+ win._radarCanvas = canvas;
+ win._radarListBody = listBody;
+ win._radarRange = RADAR_DEFAULT_RANGE;
+ win._radarRangeDisplay = rangeDisplay;
+ win._radarSelectedId = null;
+ win._radarLastObjects = []; // cache for click hit-testing
+ win._radarMapImg = radarMapImg;
+ win._radarIsDungeon = false;
+ win._radarDungeonLandblock = null;
+
+ // Scroll-wheel zoom on canvas
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const factor = e.deltaY > 0 ? 1.25 : 0.8;
+ win._radarRange = Math.max(0.02, Math.min(5.0, win._radarRange * factor));
+ const meters = Math.round(win._radarRange * 240);
+ win._radarRangeDisplay.textContent = `Range: ~${meters}m`;
+ });
+
+ // Click canvas to select nearest object
+ canvas.addEventListener('click', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
+ const my = (e.clientY - rect.top) * (canvas.height / rect.height);
+ // Find closest rendered dot
+ let closest = null;
+ let closestDist = 20; // max pixel distance to select
+ (win._radarLastObjects || []).forEach(obj => {
+ if (obj._px === undefined) return;
+ const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py) ** 2);
+ if (d < closestDist) { closestDist = d; closest = obj; }
+ });
+ win._radarSelectedId = closest ? closest.id : null;
+ });
+
+ // Load dungeon tiles on first radar open
+ loadDungeonTiles();
+
+ // Send start_radar command
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ player_name: name, command: 'start_radar' }));
+ }
+}
+
+function updateRadarWindow(msg) {
+ const name = msg.character_name;
+ const win = radarWindows[name];
+ if (!win || win.style.display === 'none') return;
+
+ const canvas = win._radarCanvas;
+ const listBody = win._radarListBody;
+ const range = win._radarRange || RADAR_DEFAULT_RANGE;
+ const selectedId = win._radarSelectedId;
+ const objects = msg.objects || [];
+
+ const playerEW = msg.player_ew;
+ const playerNS = msg.player_ns;
+ const playerHeading = msg.player_heading || 0;
+ const isDungeon = msg.is_dungeon || false;
+ const landblock = msg.landblock || null;
+ const playerX = msg.player_x || 0; // raw physics X (dungeon)
+ const playerY = msg.player_y || 0; // raw physics Y (dungeon)
+ const playerRawZ = msg.player_raw_z || 0;
+
+ // Update dungeon state
+ win._radarIsDungeon = isDungeon;
+ if (isDungeon && landblock) {
+ win._radarDungeonLandblock = landblock;
+ // Request dungeon map if not cached
+ if (!dungeonMapCache[landblock] && socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 'request_dungeon_map', landblock }));
+ }
+ }
+
+ // ─── Draw radar canvas ───
+ const ctx = canvas.getContext('2d');
+ const size = RADAR_CANVAS_SIZE;
+ const cx = size / 2;
+ const cy = size / 2;
+ // In dungeons, raw coords are game units; range is AC units (1 AC unit = 240 game units)
+ const scale = isDungeon ? (size / 2) / (range * 240) : (size / 2) / range;
+
+ ctx.clearRect(0, 0, size, size);
+
+ // Background
+ ctx.fillStyle = '#111';
+ ctx.beginPath();
+ ctx.arc(cx, cy, cx, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Background: overworld map or dungeon cells
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
+ ctx.clip();
+
+ if (isDungeon && landblock && dungeonMapCache[landblock]) {
+ // ─── Dungeon cell background with tile textures ───
+ const dmap = dungeonMapCache[landblock];
+ const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6;
+ ctx.translate(cx, cy);
+ // UB applies (heading - 180) rotation for dungeons (line 1013 DungeonMaps.cs)
+ // The layer has Y increasing downward; the 180° offset flips N/S
+ ctx.rotate(-(playerHeading - 180) * Math.PI / 180);
+ const cellSize = 10 * scale; // each cell is 10 game units
+ const hasTiles = dungeonTileCanvases && Object.keys(dungeonTileCanvases).length > 0;
+
+ // Normalize rotation value to radians (exact copy of UB's DungeonCell.cs)
+ function cellRotation(rot) {
+ if (rot === 1) return Math.PI;
+ if (rot < -0.70 && rot > -0.8) return Math.PI / 2;
+ if (rot > 0.70 && rot < 0.8) return -Math.PI / 2;
+ return 0;
+ }
+
+ // Draw non-current floors first (dimmed), then current floor on top
+ const sortedLevels = (dmap.z_levels || []).slice().sort((a, b) => {
+ const aCur = a.z === playerRoundedZ ? 1 : 0;
+ const bCur = b.z === playerRoundedZ ? 1 : 0;
+ return aCur - bCur; // current floor drawn last (on top)
+ });
+
+ sortedLevels.forEach(level => {
+ const isCurrentFloor = (level.z === playerRoundedZ);
+ ctx.globalAlpha = isCurrentFloor ? 0.85 : 0.12;
+
+ (level.cells || []).forEach(cell => {
+ // X mirrored (matches UB's layer rendering), Y direct
+ const dx = -(cell.x - playerX) * scale;
+ const dy = (cell.y - playerY) * scale;
+ const tileCanvas = hasTiles ? dungeonTileCanvases[String(cell.env_id)] : null;
+
+ if (tileCanvas) {
+ // Draw processed tile with per-cell rotation
+ ctx.save();
+ ctx.translate(dx, dy);
+ ctx.rotate(cellRotation(cell.rotation));
+ ctx.drawImage(tileCanvas, -cellSize / 2, -cellSize / 2, cellSize, cellSize);
+ ctx.restore();
+ } else {
+ // Fallback: colored rectangle
+ ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a';
+ ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
+ }
+ });
+ });
+ ctx.globalAlpha = 1.0;
+ ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
+ } else if (!isDungeon) {
+ // ─── Overworld map background ───
+ const mapImg = win._radarMapImg;
+ if (mapImg && mapImg.complete && mapImg.naturalWidth) {
+ const mapCoordRange = 204.2;
+ const pixPerCoord = mapImg.naturalWidth / mapCoordRange;
+ const mapPx = (playerEW + 102.1) * pixPerCoord;
+ const mapPy = (102.1 - playerNS) * pixPerCoord;
+ ctx.translate(cx, cy);
+ ctx.rotate(-playerHeading * Math.PI / 180);
+ ctx.globalAlpha = 0.4;
+ const srcSize = range * pixPerCoord * 2;
+ ctx.drawImage(mapImg,
+ mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize,
+ -cx, -cy, size, size
+ );
+ ctx.globalAlpha = 1.0;
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ }
+ }
+ ctx.restore();
+
+ // Range rings
+ ctx.strokeStyle = '#333';
+ ctx.lineWidth = 1;
+ for (let i = 1; i <= 4; i++) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // Crosshairs
+ ctx.strokeStyle = '#333';
+ ctx.beginPath();
+ ctx.moveTo(cx, 0); ctx.lineTo(cx, size);
+ ctx.moveTo(0, cy); ctx.lineTo(size, cy);
+ ctx.stroke();
+
+ // Always heading-up: player facing direction = up on canvas.
+ // Compass labels rotate around the edge based on player heading.
+ // AC heading: 0=N, 90=E, 180=S, 270=W (standard clockwise)
+ const headingRad = playerHeading * Math.PI / 180;
+ const compassLabels = [
+ { label: 'N', angle: 0 },
+ { label: 'E', angle: Math.PI / 2 },
+ { label: 'S', angle: Math.PI },
+ { label: 'W', angle: -Math.PI / 2 },
+ ];
+ ctx.font = 'bold 12px monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ compassLabels.forEach(({ label, angle }) => {
+ // Rotate compass direction by negative heading so labels move as player turns
+ const a = angle - headingRad;
+ const lx = cx + Math.sin(a) * (cx - 12);
+ const ly = cy - Math.cos(a) * (cx - 12);
+ ctx.fillStyle = label === 'N' ? '#cc4444' : '#888';
+ ctx.fillText(label, lx, ly);
+ });
+
+ // Facing direction indicator (always points up from center)
+ ctx.strokeStyle = '#666';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy);
+ ctx.lineTo(cx, cy - cx * 0.85);
+ ctx.stroke();
+
+ // Rotate world by heading so player facing = up on canvas
+ // In dungeons, use (PI - heading) to match UB's canvas rotation
+ const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad;
+
+ // Draw objects and cache positions for click hit-testing
+ const cosA = Math.cos(rotAngle);
+ const sinA = Math.sin(rotAngle);
+ objects.forEach(obj => {
+ // Use raw physics coords in dungeons (X mirrored), EW/NS on surface
+ let dX, dY;
+ if (isDungeon && obj.raw_x !== undefined) {
+ dX = -(obj.raw_x - playerX); // X mirrored to match tile space
+ dY = (obj.raw_y - playerY); // Y direct (180° heading offset handles N/S)
+ } else {
+ dX = obj.ew - playerEW;
+ dY = obj.ns - playerNS;
+ }
+
+ const dx = dX * cosA - dY * sinA;
+ // In dungeons, don't negate dy (180° heading offset handles N/S)
+ // In overworld, negate for north-up
+ const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA);
+
+ const px = cx + dx * scale;
+ const py = cy + dy * scale;
+
+ // Store pixel position for click hit-testing
+ obj._px = px;
+ obj._py = py;
+
+ // Clip to circle
+ const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
+ if (distFromCenter > cx - 4) return;
+
+ const color = RADAR_COLORS[obj.object_class] || '#999';
+ const isSelected = obj.id === selectedId;
+ const dotSize = isSelected ? 6 : ((obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3);
+
+ // Selection ring
+ if (isSelected) {
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(px, py, dotSize, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Label for players, portals, and selected object
+ if (obj.object_class === 'Player' || obj.object_class === 'Portal' || isSelected) {
+ ctx.fillStyle = isSelected ? '#fff' : color;
+ ctx.font = '9px monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText(obj.name, px + 6, py + 3);
+ }
+ });
+ win._radarLastObjects = objects;
+
+ // Player dot (center)
+ ctx.fillStyle = '#ffcc00';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ // ─── Update entity list ───
+ // Sort by distance
+ const withDist = objects.map(obj => {
+ let distMeters, dX, dY;
+ if (isDungeon && obj.raw_x !== undefined) {
+ dX = -(obj.raw_x - playerX); // X mirrored
+ dY = (obj.raw_y - playerY); // Y direct
+ distMeters = Math.sqrt(dX * dX + dY * dY); // raw coords are ~meters
+ } else {
+ dX = obj.ew - playerEW;
+ dY = obj.ns - playerNS;
+ distMeters = Math.sqrt(dX * dX + dY * dY) * 240;
+ }
+
+ // Compass direction
+ const angle = Math.atan2(dX, dY) * 180 / Math.PI;
+ const dir = compassDir(angle);
+
+ return { ...obj, distMeters, dir };
+ });
+ withDist.sort((a, b) => a.distMeters - b.distMeters);
+
+ // Rebuild list with selection support
+ listBody.innerHTML = '';
+ let selectedRow = null;
+ withDist.forEach(obj => {
+ const row = document.createElement('div');
+ row.className = 'radar-entity-row';
+ if (obj.id === selectedId) {
+ row.classList.add('radar-entity-selected');
+ selectedRow = row;
+ }
+
+ // Click row to select on canvas
+ row.addEventListener('click', () => {
+ win._radarSelectedId = (win._radarSelectedId === obj.id) ? null : obj.id;
+ });
+
+ const colorDot = document.createElement('span');
+ colorDot.className = 're-color';
+ colorDot.style.background = RADAR_COLORS[obj.object_class] || '#999';
+ row.appendChild(colorDot);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 're-name';
+ nameSpan.textContent = obj.name;
+ row.appendChild(nameSpan);
+
+ const typeSpan = document.createElement('span');
+ typeSpan.className = 're-type';
+ typeSpan.textContent = obj.object_class;
+ row.appendChild(typeSpan);
+
+ const distSpan = document.createElement('span');
+ distSpan.className = 're-dist';
+ distSpan.textContent = obj.distMeters < 1000
+ ? `${Math.round(obj.distMeters)}m`
+ : `${(obj.distMeters / 1000).toFixed(1)}km`;
+ row.appendChild(distSpan);
+
+ const dirSpan = document.createElement('span');
+ dirSpan.className = 're-dir';
+ dirSpan.textContent = obj.dir;
+ row.appendChild(dirSpan);
+
+ listBody.appendChild(row);
+ });
+
+ // Scroll selected row into view
+ if (selectedRow) selectedRow.scrollIntoView({ block: 'nearest' });
+}
+
+function compassDir(angleDeg) {
+ // angleDeg: 0 = N, 90 = E, -90 = W, 180 = S
+ const a = ((angleDeg % 360) + 360) % 360;
+ const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
+ return dirs[Math.round(a / 45) % 8];
+}
+
+// ─── Version Display ────────────────────────────────────────────
+
+fetch('/api-version').then(r => r.json()).then(d => {
+ const el = document.getElementById('versionDisplay');
+ if (el) el.textContent = 'v' + d.version;
+}).catch(() => {});
+
+// ─── Current User Info ──────────────────────────────────────────
+let _currentUser = null;
+
+fetch('/me').then(r => {
+ if (!r.ok) throw new Error('not authenticated');
+ return r.json();
+}).then(data => {
+ _currentUser = data;
+ const userInfo = document.getElementById('userInfo');
+ const nameEl = document.getElementById('currentUsername');
+ const adminLink = document.getElementById('adminLink');
+ if (userInfo && nameEl) {
+ nameEl.textContent = data.username;
+ userInfo.style.display = 'flex';
+ }
+ if (adminLink && data.is_admin) {
+ adminLink.style.display = 'inline';
+ }
+}).catch(() => {});
+
+// ─── Issues Board ───────────────────────────────────────────────
+
+const ISSUE_CATEGORIES = {
+ plugin: { label: 'Plugin', color: '#8844cc' },
+ overlord: { label: 'Overlord', color: '#4488cc' },
+ nav: { label: 'Nav', color: '#44aa44' },
+ macro: { label: 'Macro', color: '#cc8844' },
+ other: { label: 'Other', color: '#888888' },
+};
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+function showIssuesWindow() {
+ const { win, content, isNew } = createWindow(
+ 'issuesWindow', 'Issues Board', 'issues-window'
+ );
+
+ if (!isNew) {
+ refreshIssuesList(win);
+ return;
+ }
+
+ win.style.width = '540px';
+ win.style.height = '520px';
+
+ // Issues list container
+ const listDiv = document.createElement('div');
+ listDiv.className = 'issues-list';
+ content.appendChild(listDiv);
+ win._issuesList = listDiv;
+
+ // Add issue form
+ const form = document.createElement('div');
+ form.className = 'issues-form';
+ form.innerHTML = `
+
+
+
+ Plugin
+ Overlord
+ Nav
+ Macro
+ Other
+
+
+
+
+ Add
+
+ `;
+ content.appendChild(form);
+
+ // Add button handler
+ form.querySelector('#issueAddBtn').addEventListener('click', async () => {
+ const title = document.getElementById('issueTitle').value.trim();
+ const desc = document.getElementById('issueDescription').value.trim();
+ const cat = document.getElementById('issueCategory').value;
+ if (!title) return;
+
+ await fetch('/issues', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, description: desc, category: cat })
+ });
+ document.getElementById('issueTitle').value = '';
+ document.getElementById('issueDescription').value = '';
+ refreshIssuesList(win);
+ });
+
+ refreshIssuesList(win);
+}
+
+async function refreshIssuesList(win) {
+ const listDiv = win._issuesList;
+ if (!listDiv) return;
+
+ try {
+ const resp = await fetch('/issues');
+ const data = await resp.json();
+ const issues = data.issues || [];
+
+ if (issues.length === 0) {
+ listDiv.innerHTML = 'No open issues
';
+ return;
+ }
+
+ // Sort: unresolved first, then resolved
+ issues.sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0));
+
+ listDiv.innerHTML = '';
+ issues.forEach(issue => {
+ const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
+ const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
+ const isResolved = !!issue.resolved;
+ const author = issue.author || 'User';
+ const comments = issue.comments || [];
+
+ const row = document.createElement('div');
+ row.className = 'issue-row' + (isResolved ? ' issue-resolved' : '');
+
+ // Header line
+ const headerDiv = document.createElement('div');
+ headerDiv.className = 'issue-header';
+ headerDiv.innerHTML = `
+ ${cat.label}
+ ${escapeHtml(issue.title)}
+ by ${escapeHtml(author)}
+ ${date}
+ `;
+ row.appendChild(headerDiv);
+
+ // Description
+ if (issue.description) {
+ const descDiv = document.createElement('div');
+ descDiv.className = 'issue-description';
+ descDiv.textContent = issue.description;
+ row.appendChild(descDiv);
+ }
+
+ // Action buttons
+ const actionsDiv = document.createElement('div');
+ actionsDiv.className = 'issue-actions';
+
+ if (isResolved) {
+ const reopenBtn = document.createElement('button');
+ reopenBtn.textContent = '\u21BA Reopen';
+ reopenBtn.className = 'issue-reopen-btn';
+ reopenBtn.addEventListener('click', async () => {
+ await fetch(`/issues/${issue.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resolved: false })
+ });
+ refreshIssuesList(win);
+ });
+ actionsDiv.appendChild(reopenBtn);
+
+ const deleteBtn = document.createElement('button');
+ deleteBtn.textContent = '\uD83D\uDDD1 Delete';
+ deleteBtn.className = 'issue-delete-btn';
+ deleteBtn.addEventListener('click', async () => {
+ if (!confirm(`Delete issue "${issue.title}"?`)) return;
+ await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
+ refreshIssuesList(win);
+ });
+ actionsDiv.appendChild(deleteBtn);
+ } else {
+ const resolveBtn = document.createElement('button');
+ resolveBtn.textContent = '\u2713 Resolve';
+ resolveBtn.className = 'issue-resolve-btn';
+ resolveBtn.addEventListener('click', async () => {
+ await fetch(`/issues/${issue.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resolved: true })
+ });
+ refreshIssuesList(win);
+ });
+ actionsDiv.appendChild(resolveBtn);
+ }
+
+ // Edit button
+ const editBtn = document.createElement('button');
+ editBtn.textContent = '\u270E Edit';
+ editBtn.className = 'issue-edit-btn';
+ editBtn.addEventListener('click', () => {
+ showEditIssueForm(row, issue, win);
+ });
+ actionsDiv.appendChild(editBtn);
+
+ row.appendChild(actionsDiv);
+
+ // Comments section — always visible inline
+ showCommentsSection(row, issue, win);
+
+ listDiv.appendChild(row);
+ });
+ } catch (err) {
+ listDiv.innerHTML = 'Failed to load issues
';
+ }
+}
+
+function showEditIssueForm(row, issue, win) {
+ // Remove any existing edit form
+ const existing = row.querySelector('.issue-edit-form');
+ if (existing) { existing.remove(); return; }
+
+ const cat = issue.category || 'other';
+ const form = document.createElement('div');
+ form.className = 'issue-edit-form';
+ form.innerHTML = `
+
+
+
+ Plugin
+ Overlord
+ Nav
+ Macro
+ Other
+
+
+
+ `;
+
+ form.querySelector('.edit-save-btn').addEventListener('click', async () => {
+ const title = form.querySelector('.edit-title').value.trim();
+ const desc = form.querySelector('.edit-description').value.trim();
+ const category = form.querySelector('.edit-category').value;
+ if (!title) return;
+ await fetch(`/issues/${issue.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, description: desc, category })
+ });
+ refreshIssuesList(win);
+ });
+
+ form.querySelector('.edit-cancel-btn').addEventListener('click', () => {
+ form.remove();
+ });
+
+ row.appendChild(form);
+}
+
+function showCommentsSection(row, issue, win) {
+ const section = document.createElement('div');
+ section.className = 'issue-comments-section';
+
+ const comments = issue.comments || [];
+
+ // Render existing comments
+ const commentsListDiv = document.createElement('div');
+ commentsListDiv.className = 'issue-comments-list';
+ if (comments.length === 0) {
+ commentsListDiv.innerHTML = 'No comments yet
';
+ } else {
+ comments.forEach(c => {
+ const cDiv = document.createElement('div');
+ cDiv.className = 'issue-comment';
+ const cDate = c.created ? new Date(c.created).toLocaleDateString('sv-SE') : '';
+ cDiv.innerHTML = ` `;
+ const textDiv = document.createElement('div');
+ textDiv.className = 'comment-text';
+ textDiv.textContent = c.text;
+ cDiv.appendChild(textDiv);
+ commentsListDiv.appendChild(cDiv);
+ });
+ }
+ section.appendChild(commentsListDiv);
+
+ // Add comment form
+ const addDiv = document.createElement('div');
+ addDiv.className = 'issue-comment-form';
+ addDiv.innerHTML = `
+
+
+
+
+ `;
+
+ addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => {
+ const text = addDiv.querySelector('.comment-text-input').value.trim();
+ if (!text) return;
+ await fetch(`/issues/${issue.id}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text })
+ });
+ refreshIssuesList(win);
+ });
+
+ section.appendChild(addDiv);
+ row.appendChild(section);
+}
+
+// ─── Vital Sharing NetworkUI ───────────────────────────────────────────────
+// Live state fed by share_* WebSocket messages AND by /vital-sharing/peers
+// polling. Keyed by character_name.
+const vitalSharingPeers = {};
+let _vitalSharingPollTimer = null;
+
+function removeVitalSharingPeer(name) {
+ if (!name) return;
+ if (vitalSharingPeers[name]) {
+ delete vitalSharingPeers[name];
+ renderVitalSharingWindow();
+ }
+}
+
+function updateVitalSharingPeer(msg) {
+ const name = msg.character_name;
+ if (!name) return;
+ const entry = vitalSharingPeers[name] || (vitalSharingPeers[name] = {
+ character_name: name,
+ tags: [],
+ vitals: null,
+ position: null,
+ last_update: null,
+ connected: true,
+ });
+ entry.last_update = msg.timestamp || new Date().toISOString();
+ if (Array.isArray(msg.tags)) entry.tags = msg.tags;
+ if (msg.type === 'share_vital_update') {
+ entry.vitals = {
+ current_health: msg.current_health,
+ max_health: msg.max_health,
+ current_stamina: msg.current_stamina,
+ max_stamina: msg.max_stamina,
+ current_mana: msg.current_mana,
+ max_mana: msg.max_mana,
+ };
+ } else if (msg.type === 'share_position_update') {
+ entry.position = {
+ ew: msg.ew, ns: msg.ns, z: msg.z, heading: msg.heading,
+ };
+ }
+ renderVitalSharingWindow();
+}
+
+function showVitalSharingWindow() {
+ const { win, content, isNew } = createWindow(
+ 'vitalSharingWindow', 'Vital Sharing Network', 'issues-window',
+ { onClose: () => {
+ if (_vitalSharingPollTimer) {
+ clearInterval(_vitalSharingPollTimer);
+ _vitalSharingPollTimer = null;
+ }
+ }
+ }
+ );
+
+ if (!isNew) {
+ refreshVitalSharingPeers();
+ return;
+ }
+
+ win.style.width = '520px';
+ win.style.height = '500px';
+
+ const listDiv = document.createElement('div');
+ listDiv.className = 'vital-sharing-list';
+ listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;';
+ content.appendChild(listDiv);
+ win._vitalSharingList = listDiv;
+
+ refreshVitalSharingPeers();
+ if (_vitalSharingPollTimer) clearInterval(_vitalSharingPollTimer);
+ _vitalSharingPollTimer = setInterval(refreshVitalSharingPeers, 5000);
+}
+
+async function refreshVitalSharingPeers() {
+ try {
+ const resp = await fetch('/vital-sharing/peers');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ const serverNames = new Set();
+ (data.peers || []).forEach(p => {
+ serverNames.add(p.character_name);
+ const existing = vitalSharingPeers[p.character_name] || {};
+ vitalSharingPeers[p.character_name] = { ...existing, ...p };
+ });
+ // Prune any local entries the server no longer knows about (unsubscribed
+ // or disconnected). This catches any race where the share_peer_removed
+ // broadcast didn't arrive.
+ Object.keys(vitalSharingPeers).forEach(name => {
+ if (!serverNames.has(name)) delete vitalSharingPeers[name];
+ });
+ } catch (e) {
+ // ignore transient errors
+ }
+ renderVitalSharingWindow();
+}
+
+function renderVitalSharingWindow() {
+ const win = document.getElementById('vitalSharingWindow');
+ if (!win || win.style.display === 'none') return;
+ const listDiv = win._vitalSharingList;
+ if (!listDiv) return;
+
+ const peers = Object.values(vitalSharingPeers).sort((a, b) =>
+ (a.character_name || '').localeCompare(b.character_name || '')
+ );
+
+ if (peers.length === 0) {
+ listDiv.innerHTML = 'No vital-sharing peers connected
';
+ return;
+ }
+
+ listDiv.innerHTML = '';
+ peers.forEach(p => {
+ const row = document.createElement('div');
+ row.className = 'vital-sharing-peer';
+ row.style.cssText = 'border:1px solid #444;background:#1f1f1f;padding:6px 8px;margin-bottom:5px;border-radius:3px;font-size:0.78rem;color:#ddd;';
+
+ const dot = p.plugin_connected
+ ? '● '
+ : '● ';
+ const subscribed = p.subscribed ? ' [subscribed] ' : '';
+ const tags = (p.tags || []).join(', ') || 'no tags ';
+
+ let vitalsHtml = '(no vitals yet) ';
+ if (p.vitals && p.vitals.max_health) {
+ const v = p.vitals;
+ const pct = (cur, max) => max > 0 ? Math.max(0, Math.min(100, (cur / max) * 100)) : 0;
+ const bar = (label, cur, max, color) => `
+
+
${label}
+
+
${cur ?? 0}/${max ?? 0}
+
`;
+ vitalsHtml =
+ bar('HP', v.current_health, v.max_health, 'linear-gradient(90deg, #ff4444, #ff6666)') +
+ bar('STA', v.current_stamina, v.max_stamina, 'linear-gradient(90deg, #ffaa00, #ffcc44)') +
+ bar('MANA', v.current_mana, v.max_mana, 'linear-gradient(90deg, #4488ff, #66aaff)');
+ }
+
+ let posHtml = '';
+ if (p.position && p.position.ew !== undefined) {
+ const fmt = (n) => (typeof n === 'number' ? n.toFixed(1) : n);
+ posHtml = `
+ ${fmt(p.position.ns)}N, ${fmt(p.position.ew)}E z=${fmt(p.position.z)}
+
`;
+ }
+
+ row.innerHTML = `
+
+ ${dot}
+ ${p.character_name}
+ ${subscribed}
+
+ tags: ${tags}
+ ${vitalsHtml}
+ ${posHtml}
+ `;
+ listDiv.appendChild(row);
+ });
+}
+
+// ─── Combat Stats (Mag-Tools style) ───────────────────────────────────────
+const liveCombatStats = {};
+const combatStatsWindows = {};
+const DAMAGE_ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
+
+function showCombatStatsWindow(charName) {
+ // If called without a name, show a picker of all characters with data
+ if (!charName) {
+ const chars = Object.keys(liveCombatStats).sort();
+ if (chars.length === 0) {
+ // Try fetching from API
+ fetch('/combat-stats').then(r => r.json()).then(data => {
+ if (data.stats && data.stats.length > 0) {
+ data.stats.forEach(s => { liveCombatStats[s.character_name] = s; });
+ showCombatStatsPickerWindow();
+ } else {
+ alert('No combat stats available yet. Kill some monsters first!');
+ }
+ });
+ return;
+ }
+ showCombatStatsPickerWindow();
+ return;
+ }
+
+ const windowId = `combatStatsWindow-${charName}`;
+ const { win, content, isNew } = createWindow(windowId, `Combat: ${charName}`, 'issues-window');
+
+ if (!isNew) {
+ renderCombatStatsContent(win, charName);
+ return;
+ }
+
+ win.style.width = '620px';
+ win.style.height = '560px';
+ combatStatsWindows[charName] = win;
+
+ // Session/Lifetime toggle
+ const controls = document.createElement('div');
+ controls.style.cssText = 'padding:4px 8px;display:flex;gap:6px;align-items:center;border-bottom:1px solid #444;';
+ const sessionBtn = document.createElement('button');
+ sessionBtn.textContent = 'Session';
+ sessionBtn.className = 'combat-stats-toggle active';
+ sessionBtn.onclick = () => { win._combatMode = 'session'; sessionBtn.classList.add('active'); lifetimeBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
+ const lifetimeBtn = document.createElement('button');
+ lifetimeBtn.textContent = 'Lifetime';
+ lifetimeBtn.className = 'combat-stats-toggle';
+ lifetimeBtn.onclick = () => { win._combatMode = 'lifetime'; lifetimeBtn.classList.add('active'); sessionBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
+ controls.appendChild(sessionBtn);
+ controls.appendChild(lifetimeBtn);
+ content.appendChild(controls);
+
+ // Monster list (top)
+ const monsterDiv = document.createElement('div');
+ monsterDiv.className = 'combat-monster-list';
+ monsterDiv.style.cssText = 'height:180px;overflow-y:auto;border-bottom:1px solid #444;';
+ content.appendChild(monsterDiv);
+ win._monsterList = monsterDiv;
+
+ // Damage breakdown grid (bottom)
+ const breakdownDiv = document.createElement('div');
+ breakdownDiv.className = 'combat-breakdown';
+ breakdownDiv.style.cssText = 'flex:1;overflow-y:auto;padding:4px 6px;';
+ content.appendChild(breakdownDiv);
+ win._breakdownGrid = breakdownDiv;
+
+ win._combatMode = 'session';
+ win._selectedMonster = null; // null = "All"
+
+ renderCombatStatsContent(win, charName);
+}
+
+function showCombatStatsPickerWindow() {
+ const { win, content, isNew } = createWindow('combatStatsPicker', 'Combat Stats — Select Character', 'issues-window');
+ if (!isNew) { renderCombatStatsPicker(win); return; }
+ win.style.width = '320px';
+ win.style.height = '400px';
+ const listDiv = document.createElement('div');
+ listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;';
+ content.appendChild(listDiv);
+ win._pickerList = listDiv;
+ renderCombatStatsPicker(win);
+}
+
+function renderCombatStatsPicker(win) {
+ const listDiv = win._pickerList;
+ if (!listDiv) return;
+ const chars = Object.keys(liveCombatStats).sort();
+ listDiv.innerHTML = '';
+ if (chars.length === 0) {
+ listDiv.innerHTML = 'No combat data yet
';
+ return;
+ }
+ chars.forEach(name => {
+ const row = document.createElement('div');
+ row.style.cssText = 'padding:4px 8px;cursor:pointer;border-bottom:1px solid #333;color:#ddd;font-size:0.85rem;';
+ row.textContent = name;
+ row.onmouseenter = () => row.style.background = '#333';
+ row.onmouseleave = () => row.style.background = '';
+ row.onclick = () => { showCombatStatsWindow(name); };
+ listDiv.appendChild(row);
+ });
+}
+
+function updateCombatStatsWindows(charName) {
+ const win = combatStatsWindows[charName];
+ if (win && win.style.display !== 'none') {
+ renderCombatStatsContent(win, charName);
+ }
+}
+
+function renderCombatStatsContent(win, charName) {
+ const data = liveCombatStats[charName];
+ if (!data) return;
+
+ const mode = win._combatMode || 'session';
+ const stateData = data[mode];
+ if (!stateData) return;
+
+ const monsters = stateData.monsters || {};
+ const monsterNames = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
+
+ // ── Monster list ──
+ const monsterDiv = win._monsterList;
+ if (monsterDiv) {
+ monsterDiv.innerHTML = '';
+
+ // Header row
+ const hdr = document.createElement('div');
+ hdr.style.cssText = 'display:flex;gap:4px;padding:2px 6px;font-size:0.72rem;color:#888;border-bottom:1px solid #333;font-weight:bold;';
+ hdr.innerHTML = `Monster Kills Dmg Recv Dmg Given `;
+ monsterDiv.appendChild(hdr);
+
+ // "All" row
+ const allRow = createMonsterRow('*', 'All', stateData.total_kills, stateData.total_damage_received, stateData.total_damage_given, win._selectedMonster === null);
+ allRow.onclick = () => { win._selectedMonster = null; renderCombatStatsContent(win, charName); };
+ monsterDiv.appendChild(allRow);
+
+ // Per-monster rows
+ monsterNames.forEach(name => {
+ const m = monsters[name];
+ const selected = win._selectedMonster === name;
+ const row = createMonsterRow('', name, m.kill_count, m.damage_received, m.damage_given, selected);
+ row.onclick = () => { win._selectedMonster = name; renderCombatStatsContent(win, charName); };
+ monsterDiv.appendChild(row);
+ });
+ }
+
+ // ── Breakdown grid ──
+ const grid = win._breakdownGrid;
+ if (!grid) return;
+
+ // Gather stats for the selected monster or all
+ let offense = {}, defense = {};
+ let totalAethSurges = 0, totalCloakSurges = 0;
+
+ if (win._selectedMonster === null) {
+ // Aggregate all monsters
+ monsterNames.forEach(name => {
+ const m = monsters[name];
+ mergeAttackStats(offense, m.offense || {});
+ mergeAttackStats(defense, m.defense || {});
+ totalAethSurges += m.aetheria_surges || 0;
+ totalCloakSurges += m.cloak_surges || 0;
+ });
+ // Add cloak surges from the synthetic key
+ if (monsters['__cloak_surges__']) {
+ totalCloakSurges += monsters['__cloak_surges__'].cloak_surges || 0;
+ }
+ } else {
+ const m = monsters[win._selectedMonster];
+ if (m) {
+ offense = m.offense || {};
+ defense = m.defense || {};
+ totalAethSurges = m.aetheria_surges || 0;
+ totalCloakSurges = m.cloak_surges || 0;
+ }
+ }
+
+ // Compute aggregates for the right-side stats
+ const offAll = flattenStats(offense);
+ const defAll = flattenStats(defense);
+ const defMM = flattenStatsForType(defense, 'MeleeMissile');
+ const defMag = flattenStatsForType(defense, 'Magic');
+
+ const totalAttacks = offAll.totalAttacks;
+ const failedAttacks = offAll.failedAttacks;
+ const hitRate = totalAttacks > 0 ? ((totalAttacks - failedAttacks) / totalAttacks * 100).toFixed(0) : '0';
+
+ const totalMeleeDefends = defMM.totalAttacks;
+ const totalEvades = defMM.failedAttacks;
+ const evadeRate = totalMeleeDefends > 0 ? (totalEvades / totalMeleeDefends * 100).toFixed(0) : '0';
+
+ const totalMagicDefends = defMag.totalAttacks;
+ const totalResists = defMag.failedAttacks;
+ const resistRate = totalMagicDefends > 0 ? (totalResists / totalMagicDefends * 100).toFixed(0) : '0';
+
+ const aethRate = totalAttacks > 0 ? (totalAethSurges / totalAttacks * 100).toFixed(1) : '0.0';
+ const totalDefHits = (totalMeleeDefends - totalEvades) + (totalMagicDefends - totalResists);
+ const cloakRate = totalDefHits > 0 ? (totalCloakSurges / totalDefHits * 100).toFixed(1) : '0.0';
+
+ const crits = offAll.crits;
+ const hitsNonKill = totalAttacks - failedAttacks;
+ const critRate = hitsNonKill > 0 ? (crits / hitsNonKill * 100).toFixed(1) : '0.0';
+ const normalHits = hitsNonKill - crits;
+ const avgNormal = normalHits > 0 ? Math.round(offAll.totalNormalDamage / normalHits) : 0;
+ const avgCrit = crits > 0 ? Math.round(offAll.totalCritDamage / crits) : 0;
+
+ // Damage by element — offense (given) and defense (received)
+ const offDmgMM = {}, offDmgMag = {}, defDmgMM = {}, defDmgMag = {};
+ let totalOffMM = 0, totalOffMag = 0, totalDefMM = 0, totalDefMag = 0;
+ DAMAGE_ELEMENTS.forEach(el => {
+ offDmgMM[el] = getDefDmg(offense, 'MeleeMissile', el);
+ offDmgMag[el] = getDefDmg(offense, 'Magic', el);
+ defDmgMM[el] = getDefDmg(defense, 'MeleeMissile', el);
+ defDmgMag[el] = getDefDmg(defense, 'Magic', el);
+ totalOffMM += offDmgMM[el];
+ totalOffMag += offDmgMag[el];
+ totalDefMM += defDmgMM[el];
+ totalDefMag += defDmgMag[el];
+ });
+
+ const totalDmgGiven = offAll.totalNormalDamage + offAll.totalCritDamage;
+
+ // Build the grid HTML
+ const fmtN = n => n === 0 ? '' : n.toLocaleString();
+ const rightCell = (text) => `${text} `;
+ const labelCell = (text) => `${text} `;
+ const headerCell = (text) => `${text} `;
+ const statLabel = (text) => `${text} `;
+ const sepCell = ` `;
+
+ let html = '';
+
+ // Column headers: Element | Dmg Given (M/M, Mag) | Dmg Recv (M/M, Mag) | Stats
+ html += ` ${headerCell('Given M/M')}${headerCell('Given Mag')}${sepCell}${headerCell('Recv M/M')}${headerCell('Recv Mag')}${sepCell}${statLabel('Attacks')}${rightCell(totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : '')} `;
+
+ // Stats to show on the right side of each element row
+ const rightStats = [
+ ['Evades', () => totalMeleeDefends > 0 ? `${fmtN(totalMeleeDefends)} (${evadeRate}%)` : ''],
+ ['Resists', () => totalMagicDefends > 0 ? `${fmtN(totalMagicDefends)} (${resistRate}%)` : ''],
+ ['A.Surges', () => totalAethSurges > 0 ? `${fmtN(totalAethSurges)} (${aethRate}%)` : ''],
+ ['C.Surges', () => totalCloakSurges > 0 ? `${fmtN(totalCloakSurges)} (${cloakRate}%)` : ''],
+ ['', () => ''],
+ ['', () => ''],
+ ['Av/Mx', () => avgNormal > 0 ? `${fmtN(avgNormal)} / ${fmtN(offAll.maxNormalDamage)}` : ''],
+ ['Crits', () => crits > 0 ? `${fmtN(crits)} (${critRate}%)` : ''],
+ ];
+
+ for (let i = 0; i < DAMAGE_ELEMENTS.length; i++) {
+ const el = DAMAGE_ELEMENTS[i];
+ const rs = rightStats[i] || ['', () => ''];
+ html += `${labelCell(el)}${rightCell(fmtN(offDmgMM[el]))}${rightCell(fmtN(offDmgMag[el]))}${sepCell}${rightCell(fmtN(defDmgMM[el]))}${rightCell(fmtN(defDmgMag[el]))}${sepCell}${rs[0] ? statLabel(rs[0]) : ' '}${rightCell(rs[1]())} `;
+ }
+
+ // Crit Avg/Max row
+ html += ` ${sepCell} ${sepCell}${statLabel('Av/Mx')}${rightCell(avgCrit > 0 ? `${fmtN(avgCrit)} / ${fmtN(offAll.maxCritDamage)}` : '')} `;
+
+ // Blank row
+ html += ` `;
+
+ // Total row
+ html += `${labelCell('Total')}${rightCell(fmtN(totalOffMM))}${rightCell(fmtN(totalOffMag))}${sepCell}${rightCell(fmtN(totalDefMM))}${rightCell(fmtN(totalDefMag))}${sepCell}${statLabel('Total')}${rightCell(fmtN(totalDmgGiven))} `;
+
+ html += '
';
+ grid.innerHTML = html;
+}
+
+function createMonsterRow(marker, name, kills, dmgRecv, dmgGiven, selected) {
+ const row = document.createElement('div');
+ const bg = selected ? 'background:#2a3a4a;' : '';
+ row.style.cssText = `display:flex;gap:4px;padding:2px 6px;font-size:0.78rem;cursor:pointer;border-bottom:1px solid #222;${bg}color:#ddd;`;
+ row.onmouseenter = () => { if (!selected) row.style.background = '#252525'; };
+ row.onmouseleave = () => { if (!selected) row.style.background = ''; };
+ const fmtN = n => (!n || n === 0) ? '' : Number(n).toLocaleString();
+ row.innerHTML = `${marker} ${name} ${fmtN(kills)} ${fmtN(dmgRecv)} ${fmtN(dmgGiven)} `;
+ return row;
+}
+
+// ── Helpers for aggregating nested combat data ──
+function mergeAttackStats(target, source) {
+ for (const [atk, byEl] of Object.entries(source)) {
+ if (!target[atk]) target[atk] = {};
+ for (const [el, stats] of Object.entries(byEl)) {
+ if (!target[atk][el]) target[atk][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
+ const t = target[atk][el];
+ t.total_attacks += stats.total_attacks || 0;
+ t.failed_attacks += stats.failed_attacks || 0;
+ t.crits += stats.crits || 0;
+ t.total_normal_damage += stats.total_normal_damage || 0;
+ t.max_normal_damage = Math.max(t.max_normal_damage, stats.max_normal_damage || 0);
+ t.total_crit_damage += stats.total_crit_damage || 0;
+ t.max_crit_damage = Math.max(t.max_crit_damage, stats.max_crit_damage || 0);
+ }
+ }
+}
+
+function flattenStats(attackTypes) {
+ let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
+ for (const byEl of Object.values(attackTypes)) {
+ for (const s of Object.values(byEl)) {
+ r.totalAttacks += s.total_attacks || 0;
+ r.failedAttacks += s.failed_attacks || 0;
+ r.crits += s.crits || 0;
+ r.totalNormalDamage += s.total_normal_damage || 0;
+ r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
+ r.totalCritDamage += s.total_crit_damage || 0;
+ r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
+ }
+ }
+ return r;
+}
+
+function flattenStatsForType(attackTypes, type) {
+ let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
+ const byEl = attackTypes[type];
+ if (!byEl) return r;
+ for (const s of Object.values(byEl)) {
+ r.totalAttacks += s.total_attacks || 0;
+ r.failedAttacks += s.failed_attacks || 0;
+ r.crits += s.crits || 0;
+ r.totalNormalDamage += s.total_normal_damage || 0;
+ r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
+ r.totalCritDamage += s.total_crit_damage || 0;
+ r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
+ }
+ return r;
+}
+
+function getDefDmg(defense, atkType, element) {
+ const byEl = defense[atkType];
+ if (!byEl) return 0;
+ const s = byEl[element];
+ if (!s) return 0;
+ return (s.total_normal_damage || 0) + (s.total_crit_damage || 0);
+}