Fix inventory window never refreshing live (per-character version)

The inventoryVersion counter in useLiveData was a single global value that
bumped on every inventory_delta for any character. With 60+ active chars
all generating deltas, the global counter advanced multiple times per
second.

InventoryWindow's debounce effect watched this global counter, so every
bump reset its 2-second fetch timer. Since bumps arrived faster than 2s,
the fetch never fired — the window appeared frozen until the user closed
and reopened it (which triggered the initial-fetch effect).

Fix: make inventoryVersions a Map<string, number> keyed by character name.
Each inventory_delta now only bumps its own character's counter, so an
open window's debounce correctly fires 2s after its character's last
delta, ignoring unrelated traffic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-15 18:57:55 +02:00
parent 7dc5996820
commit d26f1f725c
18 changed files with 176 additions and 9 deletions

View file

@ -66,7 +66,7 @@ export const MapLayout: React.FC<Props> = ({ data }) => {
selectedPlayer={selectedPlayer}
/>
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
nearbyObjects={data.nearbyObjects} inventoryVersion={data.inventoryVersion}
nearbyObjects={data.nearbyObjects} inventoryVersions={data.inventoryVersions}
equipmentCantrips={data.equipmentCantrips} characterStats={data.characterStats}
socket={data.socketRef.current} />
<RareNotification recentRares={data.recentRares} />

View file

@ -17,13 +17,15 @@ interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
inventoryVersion: number;
/** Per-character inventory counters. InventoryWindow watches only its
* own character's value so unrelated deltas don't reset its debounce. */
inventoryVersions: Map<string, number>;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, characterStats, socket }) => {
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersions, equipmentCantrips, characterStats, socket }) => {
const { windows } = useWindowManager();
return (
@ -44,7 +46,7 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
liveStats={characterStats.get(charName)} />;
case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
inventoryVersion={inventoryVersion} equipmentCantrips={equipmentCantrips.get(charName)} />;
inventoryVersion={inventoryVersions.get(charName) ?? 0} equipmentCantrips={equipmentCantrips.get(charName)} />;
case 'radar':
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;

View file

@ -14,7 +14,11 @@ export interface DashboardState {
recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
inventoryVersion: number;
/** Per-character inventory version counter bumps when that character
* receives an inventory_delta. Open windows watch only their own
* character's counter so deltas for unrelated chars don't reset their
* debounce timer. */
inventoryVersions: Map<string, number>;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
deathAlerts: Array<{ character_name: string; vitae: number; timestamp: string }>;
@ -29,7 +33,7 @@ export function useLiveData(): DashboardState {
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>());
const [chatVersion, setChatVersion] = useState(0);
const [inventoryVersion, setInventoryVersion] = useState(0);
const [inventoryVersions, setInventoryVersions] = useState<Map<string, number>>(new Map());
const equipmentCantripRef = useRef(new Map<string, any>());
const [equipCantripVersion, setEquipCantripVersion] = useState(0);
const characterStatsRef = useRef(new Map<string, any>());
@ -72,8 +76,17 @@ export function useLiveData(): DashboardState {
setRecentRares(prev => [r, ...prev].slice(0, 50));
} else if (msg.type === 'inventory_delta') {
const d = msg as unknown as { character_name: string };
// Bump inventory version so open inventory windows can re-fetch
setInventoryVersion(v => v + 1);
// Bump ONLY this character's inventory version so an open window for
// that character re-fetches. Deltas for other characters don't touch
// it, which keeps the 2s debounce in InventoryWindow from being reset
// forever by unrelated chatter.
if (d.character_name) {
setInventoryVersions(prev => {
const next = new Map(prev);
next.set(d.character_name, (next.get(d.character_name) ?? 0) + 1);
return next;
});
}
} else if (msg.type === 'character_stats') {
// Store full character stats for CharacterWindow live updates
const cs = msg as unknown as { character_name: string };
@ -200,5 +213,5 @@ export function useLiveData(): DashboardState {
// eslint-disable-next-line react-hooks/exhaustive-deps
const characterStats = useMemo(() => characterStatsRef.current, [charStatsVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, characterStats, deathAlerts, socketRef };
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersions, equipmentCantrips, characterStats, deathAlerts, socketRef };
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{u as c,j as r,D as d}from"./index-BipVD-xb.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{r as c,j as t,D as u,a as f}from"./index-BipVD-xb.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{r as o,j as t,D as d}from"./index-BipVD-xb.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -0,0 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-BipVD-xb.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

17
static/_build/assets/react-yfL0ty4i.js vendored Normal file

File diff suppressed because one or more lines are too long

18
static/_build/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" />
<link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-BipVD-xb.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
<link rel="stylesheet" crossorigin href="/assets/index-BsAcOCNp.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

72
static/_build/sw.js Normal file
View file

@ -0,0 +1,72 @@
// Service worker for MosswartOverlord v2 — caches static assets for instant repeat loads
const CACHE_NAME = 'mo-v2-cache-v1';
const STATIC_ASSETS = [
'/dereth.png',
'/dereth_highres.png',
'/prismatic-taper-icon.png',
'/icons/0600127E.png',
'/icons/06000133.png',
'/icons/06001080.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache icon images on first fetch
if (url.pathname.startsWith('/icons/') && event.request.method === 'GET') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache dungeon_tiles.json (large, rarely changes)
if (url.pathname === '/dungeon_tiles.json') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache static assets (map images etc)
if (STATIC_ASSETS.some(a => url.pathname === a)) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
return;
}
});