feat(dashboard): play packet captures back and replay their movement in World 3D

The Packet Captures page (/inspector/packets, dev_message_inspector) arms captures with a
search picker (part of an account or character name, or a pasted account or object ID; online
ones marked; everything needs no pick), lists running and saved ones live (packet_capture
topic), and plays one back on its timeline: play, pause, seek and speed, packets appearing in
order with their decoded fields and bytes. World 3D takes ?capture=<id>&zone=<zone> and replays
the captured position updates in its replay mode; the capture page's playhead drives it over a
BroadcastChannel. Saved packet captures show in the Message Inspector's list and open there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:48:25 -05:00
parent 332bc04ce8
commit 88a97b893e
6 changed files with 524 additions and 4 deletions

View File

@@ -874,6 +874,25 @@ async function loadReplay() {
const data = await api.get('/api/world3d/history?zone=' + state.zone + '&instance=' + state.instance + '&from=' + range.from + '&to=' + range.to).catch(() => null);
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; }
startReplay(data);
}
/**
* A packet capture's movement (the Packet Captures page's "World 3D" button: /world3d?capture=<id>&zone=<zone>): the
* captured characters' position updates, replayed like recorded positions. The capture page's timeline drives the
* time here while it plays (BroadcastChannel "dlu-capture-replay").
*/
async function loadCaptureReplay(capture) {
setStatus('Loading the capture…');
const data = await api.get('/api/inspector/sessions/' + encodeURIComponent(capture) + '/positions?zone=' + state.zone).catch(() => null);
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; }
startReplay(data);
state.replay.capture = String(capture);
if (!data.players.length) toast('No movement was captured in this zone' + (data.zones && data.zones.length ? ' (captured in: ' + data.zones.map(zoneName).join(', ') + ')' : ''), 'info');
}
function startReplay(data) {
stopReplay();
const gap = Math.max(data.idleSeconds * 1.6, data.bucket * 2.5, data.interval * 3);
const replay = { from: data.from, to: data.to, players: data.players, gap, hold: data.idleSeconds, t: 0, playing: false, trails: null };
@@ -935,6 +954,16 @@ if (canHistory) {
$('replayScrub').addEventListener('input', () => setReplayTime(Number($('replayScrub').value)));
}
if ('BroadcastChannel' in window) {
new BroadcastChannel('dlu-capture-replay').addEventListener('message', (e) => {
const r = state.replay, m = e.data || {};
if (!r || !r.capture || String(m.capture) !== r.capture) return;
r.playing = false;
$('replayPlay').textContent = 'Play';
setReplayTime(Number(m.t) || 0);
});
}
// ---- heat map timelapse ----
// On a property zone every property is its own instance: show them all together, or one (its clone), as the reports do
@@ -1149,5 +1178,14 @@ function animate() {
// Running worlds and their player counts change; refresh them now and then
setInterval(() => api.get('/api/world3d/meta').then((m) => { state.meta.worlds = m.worlds; renderInstances(); }).catch(() => {}), 30000);
animate();
const params = new URLSearchParams(location.search);
if (params.get('zone') && $('zoneSelect').querySelector('option[value="' + CSS.escape(params.get('zone')) + '"]')) $('zoneSelect').value = params.get('zone');
if ($('zoneSelect').value) await loadZone(Number($('zoneSelect').value));
if (params.get('capture') && canHistory) {
$('modeReplay').checked = true;
await setMode('replay');
await loadCaptureReplay(params.get('capture'));
} else if (params.get('capture')) {
toast('Replaying a capture here needs the players_history permission', 'warning');
}
})();