diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index 6a8a404ca..47b376242 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -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; } diff --git a/dDashboardServer/static/js/lddviewer.js b/dDashboardServer/static/js/lddviewer.js index c3e5433aa..cd0f85bec 100644 --- a/dDashboardServer/static/js/lddviewer.js +++ b/dDashboardServer/static/js/lddviewer.js @@ -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()); diff --git a/dDashboardServer/static/js/world3d-core.js b/dDashboardServer/static/js/world3d-core.js index 301d19cd7..f6579bd55 100644 --- a/dDashboardServer/static/js/world3d-core.js +++ b/dDashboardServer/static/js/world3d-core.js @@ -257,3 +257,55 @@ export function markersHtml(markers, esc, names = false) { '' + esc(names && m.name ? m.name + ': ' + zone : zone) + ''; }).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) => '').join(''); +} diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index a50f6a434..d0208238a 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -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 '
' + list.map(({ model, span }) => { + const name = model.name || ('LOT ' + model.lot); + const behaviors = model.behaviors ? ' · ' + model.behaviors + ' behavior' + (model.behaviors === 1 ? '' : 's') : ''; + return ''; + }).join('') + '
'; +} + +// 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 ? '' : '
' + esc(label) + '
' + value + '
'; + $('propertyInfo').innerHTML = '
As the capture saw it at this moment: ' + esc(world.zoneName || zoneName(world.zone)) + + ' #' + esc(world.instance) + (world.clone ? ' (clone ' + esc(world.clone) + ')' : '') + '
' + + (info ? '
' + + 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 ? ' (' + esc(info.rejectionReason) + ')' : '') : '') + + field(GameText.term('reputation'), info.reputation !== undefined ? esc(info.reputation) : '') + + field('Models', now.count ? esc(now.count.count) : '') + + '
Property data from packet ' + esc(info.i) + '.
' + : '
The capture has no property data for this world yet.
') + + (world.saved ? '
The property now
' : '') + + '
Models standing here (' + now.models.length + ')
' + + (now.models.length ? modelRows(now.models) : '
None at this moment.
'); +} + +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 = '
Each property is its own instance of this zone: pick one to see its placed models.
'; + 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 = '
Placed models as they are now: position history doesn\'t record models, so they may differ from the time replayed.
' + + '
Name
' + esc(data.property.name) + '
' + + '
Owner
' + fmt.character(data.property.ownerId, data.property.ownerName) + '
' + + '
Open the property
' + + '
Placed models (' + data.models.length + ')
' + 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); diff --git a/dDashboardServer/templates/world3d.jinja2 b/dDashboardServer/templates/world3d.jinja2 index 178c7b67d..19d081e2d 100644 --- a/dDashboardServer/templates/world3d.jinja2 +++ b/dDashboardServer/templates/world3d.jinja2 @@ -124,6 +124,7 @@ +
@@ -134,6 +135,10 @@
Click a player or an object.
+
+
+
+
View
diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 009f9d126..b174734cc 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -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() diff --git a/tests/dWebTests/world3d-property.test.mjs b/tests/dWebTests/world3d-property.test.mjs new file mode 100644 index 000000000..1c8453824 --- /dev/null +++ b/tests/dWebTests/world3d-property.test.mjs @@ -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 +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(/