feat(world3d): a property's models in capture and recorded replays

In a capture replay, each model stands where the capture saw it at the
playhead (the UGC server's mesh for a brick built model when it has one, a box
otherwise), appearing, moving and vanishing with the timeline both ways; the
Property tab shows the property data of that moment and the models standing,
and behavior messages are ticks on the timeline. In a replay of recorded
positions on one property instance, the models placed now are drawn and
labelled as now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:52:35 -05:00
parent 056aad5568
commit 15a743809a
7 changed files with 312 additions and 4 deletions

View File

@@ -262,6 +262,8 @@ html.nav-loading main { opacity: .65; transition: opacity .2s; }
border-radius: .2rem; background: var(--bs-warning-bg-subtle); color: var(--bs-warning-text-emphasis); max-width: 9rem; overflow: hidden;
text-overflow: ellipsis; }
.timeline-marker:hover > span, .timeline-marker:focus-visible > span { max-width: none; z-index: 1; }
/* A property's behavior messages in a capture replay: short unlabelled ticks under the world change markers */
.timeline-marker.timeline-behavior { top: .55rem; border-left: 1px solid var(--bs-info); }
/* Client system info: values whose caveat is a tooltip; unreliable ones stand out more */
.sysinfo-tip { text-decoration: underline dotted; text-underline-offset: .2em; cursor: help; }

View File

@@ -148,7 +148,7 @@ export function parseLxfml(text) {
* The parts of a model the UGC server made (a .nif, converted by the dashboard like the scenery's models: see
* scenery-core.js), as [{geometry, material}] in the model's own coordinates. Each part owns its geometry and material.
*/
async function loadGeneratedModel(url) {
export async function loadGeneratedModel(url) {
const response = await limitedFetch(url);
if (!response.ok) throw new Error('HTTP ' + response.status);
const model = parseModel(await response.arrayBuffer());

View File

@@ -257,3 +257,55 @@ export function markersHtml(markers, esc, names = false) {
'<span>' + esc(names && m.name ? m.name + ': ' + zone : zone) + '</span></button>';
}).join('');
}
// ---- a property as a capture saw it ----
/**
* Capture playback: a property world at time t (one entry of /api/inspector/sessions/:id/property's worlds): each model
* standing then with the span it stood in ({model, span}), the property data then (the latest DownloadPropertyData)
* and the model count the property gave then. Every answer comes from t alone, so seeking back and forth is the same.
*/
export function propertyAt(world, t) {
const models = [];
for (const model of (world && world.models) || []) {
const span = model.spans.find((s) => s.from <= t && (s.to === null || s.to === undefined || t < s.to));
if (span) models.push({ model, span });
}
const latest = (list) => {
let found = null;
for (const entry of list || []) {
if (entry.t > t) break;
found = entry;
}
return found;
};
return { models, info: latest(world && world.info), count: latest(world && world.counts) };
}
/**
* The property world to show in the zone on screen at time t: the one the followed character was on (worldAt), else
* the first captured on this zone. `worlds`: the property route's worlds; `moves`: the positions route's worlds.
*/
export function propertyWorldFor(worlds, zone, moves, character, t) {
const here = (worlds || []).filter((w) => w.zone === zone);
if (!here.length) return null;
const on = character ? worldAt(moves, character, t) : null;
return (on && here.find((w) => w.instance === on.instance && w.clone === on.clone)) || here[0];
}
/**
* Timeline ticks for a property's behavior messages (and what was sent to or from a model): [{t, at, title}] along
* `duration` seconds, one per message, `title` naming the message.
*/
export function behaviorMarkers(world, duration) {
return ((world && world.events) || []).filter((e) => e.kind === 'behavior').map((e) => ({
t: e.t, at: duration > 0 ? Math.max(0, Math.min(1, e.t / duration)) : 0,
title: e.message + (e.command ? ' ' + e.command : '') + (e.lot !== undefined ? ' (LOT ' + e.lot + ')' : '')
}));
}
/** The behavior ticks over the timeline's slider (as markersHtml); clicking one seeks to it (data-t). */
export function behaviorMarkersHtml(markers, esc) {
return markers.map((m) => '<button type="button" class="timeline-marker timeline-behavior" style="left:' + (m.at * 100).toFixed(3) + '%" data-t="' + m.t +
'" title="' + esc(m.title) + '"></button>').join('');
}

View File

@@ -12,6 +12,9 @@
* scrubbing never rebuilds geometry. A packet capture's movement plays the same way, driven by its timeline.
* - Following a player keeps the camera on them across worlds: live, when the position feed reports them in another
* world; in a capture, at the time their packets move to another world server (a marker on the timeline).
* - A property's placed models: in a capture, where the capture saw each one at the playhead, with the property's
* data then and ticks on the timeline where behavior messages ran; in a replay of recorded positions, the models
* placed now (position history doesn't record models), labelled as such.
* - Heat map (reports_view): the economy map events of one kind per day as coloured 4x4 squares on the ground,
* played back day by day.
* Names come from the server (kinds, path types, map event kinds); nothing about the game is kept here.
@@ -19,11 +22,11 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.js';
import { buildTerrainChunks, TERRAIN_LOOKS, loadGeneratedModel } from '/js/lddviewer.js';
import { createScenery } from '/js/scenery.js';
import { nearPlaneFor } from '/js/scenery-core.js';
import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain,
followedMove, captureSwitch, worldMarkers, markersHtml } from '/js/world3d-core.js';
followedMove, captureSwitch, worldMarkers, markersHtml, propertyAt, propertyWorldFor, behaviorMarkers, behaviorMarkersHtml } from '/js/world3d-core.js';
const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report
const LIVE_FORGET = 8; // seconds without a report before a live player is dropped
@@ -498,6 +501,7 @@ async function loadZone(zone, keep = false) {
const resume = state.capture && state.replay ? { t: state.replay.t, playing: state.replay.playing } : null;
stopReplay();
clearHeat();
clearPropertyModels();
// The old zone goes at once: its models, flairs, terrain and markers, and whatever of them is still loading
scenery.clear();
flairs.clear();
@@ -927,6 +931,7 @@ async function loadReplay() {
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; }
startReplay(data);
loadCurrentProperty();
}
/**
@@ -940,6 +945,7 @@ async function loadCaptureReplay(capture) {
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; }
state.capture = { id: String(capture), data, worlds: data.worlds || [] };
await loadCaptureProperty(capture);
// A capture plays in real time (the saved speed is for scrubbing through hours of recorded positions)
$('replaySpeed').value = '1';
// One character captured: follow them from the start
@@ -982,7 +988,8 @@ function renderMarkers() {
const c = state.capture, r = state.replay;
if (!c || !r) { el.innerHTML = ''; return; }
const names = !state.followCharacter && new Set(c.worlds.map((w) => w.character)).size > 1;
el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names);
el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names) +
behaviorMarkersHtml(behaviorMarkers(property.world, r.to - r.from), esc);
}
function startReplay(data, capture = false) {
@@ -1063,6 +1070,178 @@ if ('BroadcastChannel' in window) {
});
}
// ---- a property's placed models ----
/**
* A property's models drawn where they stood: in a capture replay, as the capture saw them at the playhead
* (/api/inspector/sessions/:id/property, world3d-core.js propertyAt); in a replay of recorded positions, the models
* placed now (/api/world3d/property_models), since position history doesn't record models. A player-built model is
* drawn from the mesh the UGC server made of it when there is one, anything else as a box (as the property view does).
*/
const propertyLayer = new THREE.Group();
scene.add(propertyLayer);
const PROPERTY_BOX = new THREE.BoxGeometry(1.6, 1.6, 1.6).translate(0, 0.8, 0);
const PROPERTY_BOX_MATERIAL = new THREE.MeshStandardMaterial({ color: 0xd98e3f, roughness: 0.6, transparent: true, opacity: 0.85 });
const ugcMeshes = new Map(); // "ugcId?via" -> promise of the UGC server's parts, or null when it has none
const property = {
worlds: null, // capture: the property route's worlds
current: null, // recorded positions: {zone, clone, property, models}
drawn: new Map(), // model key -> {group, span}
world: null, // the capture world shown
panelKey: '' // what the panel shows, so it is only written again when that changes
};
function ugcParts(ugcId, via) {
const key = ugcId + '?' + via;
if (!ugcMeshes.has(key)) {
ugcMeshes.set(key, loadGeneratedModel('/api/ugc_links/mesh/' + encodeURIComponent(ugcId) + (via ? '?property=' + encodeURIComponent(via) : '')).catch(() => null));
}
return ugcMeshes.get(key);
}
// One model's object: a box at once, the UGC server's mesh in its place when that loads
function modelObject(model, via) {
const group = new THREE.Group();
const box = new THREE.Mesh(PROPERTY_BOX, PROPERTY_BOX_MATERIAL);
group.add(box);
if (model.ugcId && model.ugcId !== '0') {
ugcParts(model.ugcId, via).then((parts) => {
if (!parts || !group.parent) return;
group.remove(box);
for (const part of parts) group.add(new THREE.Mesh(part.geometry, part.material));
});
}
return group;
}
function clearPropertyModels() {
for (const { group } of property.drawn.values()) propertyLayer.remove(group);
property.drawn.clear();
property.world = null;
property.panelKey = '';
}
// Draws [{key, model, span}] and hides what isn't in it (its object is kept for when it comes back)
function placePropertyModels(list, via) {
const shown = new Set();
for (const { key, model, span } of list) {
let drawn = property.drawn.get(key);
if (!drawn) {
drawn = { group: modelObject(model, via), span: null };
property.drawn.set(key, drawn);
propertyLayer.add(drawn.group);
}
if (drawn.span !== span) {
drawn.span = span;
drawn.group.position.fromArray(span.position);
drawn.group.quaternion.fromArray(span.rotation);
}
drawn.group.visible = true;
shown.add(key);
}
for (const [key, drawn] of property.drawn) if (!shown.has(key)) drawn.group.visible = false;
propertyLayer.visible = $('propertyModelsToggle').checked;
}
function showPropertyTab(shown) {
$('propertyTabItem').classList.toggle('d-none', !shown);
if (!shown) $('propertyInfo').innerHTML = '';
}
function focusPoint(position) {
const offset = camera.position.clone().sub(controls.target);
if (offset.length() > 60) offset.setLength(30);
controls.target.fromArray(position);
camera.position.copy(controls.target).add(offset);
}
function modelRows(list) {
return '<div class="list-group list-group-flush">' + list.map(({ model, span }) => {
const name = model.name || ('LOT ' + model.lot);
const behaviors = model.behaviors ? ' · ' + model.behaviors + ' behavior' + (model.behaviors === 1 ? '' : 's') : '';
return '<button type="button" class="list-group-item list-group-item-action px-1 py-1" data-focus="' + esc(span.position.join(',')) + '">' +
'<div class="text-truncate">' + esc(name) + ' <span class="text-body-secondary">LOT ' + esc(model.lot) + '</span></div>' +
'<div class="text-body-secondary">' + (model.ugcId && model.ugcId !== '0' ? 'UGC ' + esc(model.ugcId) + ' · ' : '') +
span.position.map((v) => v.toFixed(1)).join(', ') + esc(behaviors) + '</div></button>';
}).join('') + '</div>';
}
// The capture's property at time t in the zone shown
function updateCaptureProperty(t) {
const c = state.capture;
const world = propertyWorldFor(property.worlds, state.zone, c && c.worlds, state.followCharacter, t);
if (world !== property.world) {
clearPropertyModels();
property.world = world;
renderMarkers();
}
showPropertyTab(!!world);
if (!world) return;
const now = propertyAt(world, t);
const via = world.saved ? world.saved.id : '';
placePropertyModels(now.models.map(({ model, span }) => ({ key: model.object + '/' + model.spans.indexOf(span), model, span })), via);
const key = world.zone + ':' + world.instance + ':' + world.clone + '|' + now.models.map((m) => m.model.object + '/' + m.span.i).join(',') + '|' +
(now.info ? now.info.i : '') + '|' + (now.count ? now.count.i : '');
if (key === property.panelKey) return;
property.panelKey = key;
const info = now.info;
const field = (label, value) => value === '' || value === null || value === undefined ? '' : '<dt class="col-5">' + esc(label) + '</dt><dd class="col-7 text-break">' + value + '</dd>';
$('propertyInfo').innerHTML = '<div class="text-body-secondary mb-2">As the capture saw it at this moment: ' + esc(world.zoneName || zoneName(world.zone)) +
' #' + esc(world.instance) + (world.clone ? ' (clone ' + esc(world.clone) + ')' : '') + '</div>' +
(info ? '<dl class="row mb-2">' +
field('Name', esc(info.name)) +
field('Owner', info.ownerId && info.ownerId !== '0' ? fmt.character(info.ownerId, info.ownerName) : esc(info.ownerName)) +
field('Description', esc(info.description)) +
field('Privacy', info.accessType !== undefined ? esc(Labels.name('privacy', info.accessType) || info.accessType) : '') +
field('Approval', info.moderation ? esc(info.moderation) + (info.rejectionReason ? ' <span class="text-body-secondary">(' + esc(info.rejectionReason) + ')</span>' : '') : '') +
field(GameText.term('reputation'), info.reputation !== undefined ? esc(info.reputation) : '') +
field('Models', now.count ? esc(now.count.count) : '') +
'</dl><div class="text-body-secondary mb-2">Property data from packet ' + esc(info.i) + '.</div>'
: '<div class="text-body-secondary mb-2">The capture has no property data for this world yet.</div>') +
(world.saved ? '<div class="mb-2"><a href="/properties/' + esc(world.saved.id) + '">The property now</a></div>' : '') +
'<h6 class="mb-1">Models standing here (' + now.models.length + ')</h6>' +
(now.models.length ? modelRows(now.models) : '<div class="text-body-secondary">None at this moment.</div>');
}
async function loadCaptureProperty(capture) {
const data = await api.get('/api/inspector/sessions/' + encodeURIComponent(capture) + '/property').catch(() => null);
property.worlds = data && data.worlds ? data.worlds : [];
property.current = null;
clearPropertyModels();
}
// Recorded positions on a property: the models placed there now, labelled as now
async function loadCurrentProperty() {
property.worlds = null;
property.current = null;
clearPropertyModels();
showPropertyTab(false);
const propertyZone = ((state.meta && state.meta.propertyZones) || []).includes(Number(state.zone));
const instance = (state.replayInstances || []).find((i) => i.instance === state.instance);
if (!propertyZone) return;
if (!instance || !instance.clone) {
showPropertyTab(true);
$('propertyInfo').innerHTML = '<div class="text-body-secondary">Each property is its own instance of this zone: pick one to see its placed models.</div>';
return;
}
const data = await api.get('/api/world3d/property_models?zone=' + state.zone + '&clone=' + instance.clone).catch(() => null);
if (!data || !data.property) return;
property.current = { zone: state.zone, clone: instance.clone, ...data };
placePropertyModels(data.models.map((m) => ({ key: m.id, model: m, span: m })), data.property.id);
showPropertyTab(true);
$('propertyInfo').innerHTML = '<div class="alert alert-warning py-1 px-2 mb-2">Placed models as they are <strong>now</strong>: position history doesn\'t record models, so they may differ from the time replayed.</div>' +
'<dl class="row mb-2"><dt class="col-5">Name</dt><dd class="col-7 text-break">' + esc(data.property.name) + '</dd>' +
'<dt class="col-5">Owner</dt><dd class="col-7">' + fmt.character(data.property.ownerId, data.property.ownerName) + '</dd></dl>' +
'<div class="mb-2"><a href="/properties/' + esc(data.property.id) + '">Open the property</a></div>' +
'<h6 class="mb-1">Placed models (' + data.models.length + ')</h6>' + modelRows(data.models.map((m) => ({ model: m, span: m })));
}
$('propertyInfo').addEventListener('click', (e) => {
const row = e.target.closest('[data-focus]');
if (row) focusPoint(row.dataset.focus.split(',').map(Number));
});
$('propertyModelsToggle').addEventListener('change', (e) => { propertyLayer.visible = e.target.checked; });
// ---- 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
@@ -1182,6 +1361,9 @@ async function setMode(mode) {
}
if (mode !== 'replay') {
stopReplay();
clearPropertyModels();
property.worlds = property.current = null;
showPropertyTab(false);
if (state.capture) setFollow(null);
state.capture = null;
renderMarkers();
@@ -1249,6 +1431,7 @@ function animate() {
}
drawPlayers(currentPlayers(now / 1000));
if (state.capture && r && property.worlds) updateCaptureProperty(r.t);
if (state.capture && r && !state.switching) {
const move = captureSwitch(state.capture.worlds, state.followCharacter, r.t, state.zone);
if (move) switchWorld(move, move.name);

View File

@@ -124,6 +124,7 @@
<li class="nav-item" role="presentation"><button class="nav-link active py-1" data-bs-toggle="pill" data-bs-target="#tabPlayers" type="button" role="tab">Players <span class="badge text-bg-secondary" id="playerCount">0</span></button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabSelected" type="button" role="tab" id="selectedTabBtn">Selected</button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLayers" type="button" role="tab">Layers</button></li>
<li class="nav-item d-none" role="presentation" id="propertyTabItem"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabProperty" type="button" role="tab">Property</button></li>
</ul>
</div>
<div class="tab-content flex-grow-1 overflow-hidden">
@@ -134,6 +135,10 @@
<div class="tab-pane h-100 p-2 small" id="tabSelected" role="tabpanel">
<div id="selectionInfo"><span class="text-body-secondary">Click a player or an object.</span></div>
</div>
<div class="tab-pane h-100 p-2 small overflow-auto" id="tabProperty" role="tabpanel">
<div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" id="propertyModelsToggle" data-pref="world3d.propertyModels" checked><label class="form-check-label" for="propertyModelsToggle">Placed models</label></div>
<div id="propertyInfo"></div>
</div>
<div class="tab-pane h-100 p-2 small" id="tabLayers" role="tabpanel">
<div class="fw-semibold mb-1">View</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">

View File

@@ -119,4 +119,7 @@ if(NODE_EXECUTABLE)
# Following a player across worlds in World 3D (live and capture playback) and the timeline's world markers (static/js/world3d-core.js)
add_test(NAME World3dFollowJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-follow.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js")
# A property in a capture replay: its models at the playhead, its data then, and its behavior ticks (static/js/world3d-core.js)
add_test(NAME World3dPropertyJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-property.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js")
endif()

View File

@@ -0,0 +1,63 @@
// A property in a capture replay (static/js/world3d-core.js): which models stand where at the playhead, the property
// data then, which property world the zone on screen shows, and the behavior ticks.
// Run by ctest: node world3d-property.test.mjs <world3d-core.js>
import { pathToFileURL } from 'node:url';
const [modulePath] = process.argv.slice(2);
const W = await import(pathToFileURL(modulePath).href);
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
// The property route's shape: one model there from the start that moves at 5s, one placed at 4s and picked up at 8s
const world = {
zone: 1150, instance: 7, clone: 42,
info: [{ t: 1, name: 'Old name', reputation: 10 }, { t: 9, name: 'New name', reputation: 25 }],
counts: [{ t: 1, count: 1 }, { t: 4, count: 2 }, { t: 8, count: 1 }],
models: [
{ object: '1', lot: 14, spans: [{ from: 2, to: 5, position: [1, 2, 3] }, { from: 5, to: null, position: [5, 2, 5] }] },
{ object: '2', lot: 14, spans: [{ from: 4, to: 8, position: [10, 2, -4] }] }
],
events: [
{ kind: 'placed', t: 3, object: '2' },
{ kind: 'behavior', t: 6, message: 'PLAY_BEHAVIOR_SOUND', object: '1', lot: 14 },
{ kind: 'removed', t: 8, object: '2', reason: 'picked up' }
]
};
const at = (t) => {
const p = W.propertyAt(world, t);
return { models: p.models.map((m) => m.model.object + '@' + m.span.position.join(',')), name: p.info && p.info.name, count: p.count && p.count.count };
};
same(at(0), { models: [], name: null, count: null }, 'before anything was captured');
same(at(2), { models: ['1@1,2,3'], name: 'Old name', count: 1 }, 'the model already there');
same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the placed model appears');
same(at(5), { models: ['1@5,2,5', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the first one moved (a span ends where the next begins)');
same(at(8), { models: ['1@5,2,5'], name: 'Old name', count: 1 }, 'the second one was picked up');
same(at(20), { models: ['1@5,2,5'], name: 'New name', count: 1 }, 'still there at the end, renamed');
// Seeking back gives the same as playing forward
same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'seeking back');
same(W.propertyAt(null, 3), { models: [], info: null, count: null }, 'no property');
// Which world: the followed character's, else the first of the zone
const other = { ...world, instance: 9, clone: 43 };
const moves = [{ character: 'c', t: 0, zone: 1150, instance: 9, clone: 43 }, { character: 'c', t: 10, zone: 1150, instance: 7, clone: 42 }];
same(W.propertyWorldFor([world, other], 1150, moves, 'c', 5).clone, 43, 'the followed character\'s property');
same(W.propertyWorldFor([world, other], 1150, moves, 'c', 11).clone, 42, 'they moved to another property');
same(W.propertyWorldFor([world, other], 1150, moves, null, 5).clone, 42, 'nobody followed: the first');
same(W.propertyWorldFor([world, other], 1100, moves, 'c', 5), null, 'not a property zone');
// Behavior ticks
same(W.behaviorMarkers(world, 12), [{ t: 6, at: 0.5, title: 'PLAY_BEHAVIOR_SOUND (LOT 14)' }], 'one behavior tick');
const html = W.behaviorMarkersHtml(W.behaviorMarkers(world, 12), (s) => String(s).replace(/</g, '&lt;'));
same(html.includes('data-t="6"') && html.includes('left:50.000%'), true, 'the tick seeks to its message');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('world3d property: ok');