Merge exp/property-replay: property models, info and behaviours in capture replays

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:54:20 -05:00
20 changed files with 1211 additions and 12 deletions

View File

@@ -14,7 +14,9 @@
#include "BinaryPathFinder.h"
#include "CaptureBundle.h"
#include "CaptureProperty.h"
#include "CaptureTools.h"
#include "ClientAssets.h"
#include "Database.h"
#include "DashboardRoutes.h"
#include "Game.h"
@@ -112,6 +114,20 @@ namespace {
}
const nlohmann::json* ReplicaFields(size_t i) const { return i < replica.size() && replica[i] ? &*replica[i] : nullptr; }
// The properties the capture saw (CaptureProperty), read once after the replica pass, on a worker thread
std::once_flag propertyOnce;
std::atomic<bool> propertyBuilt{};
nlohmann::json property;
const nlohmann::json& Property() {
Decode();
std::call_once(propertyOnce, [this] {
property = CaptureProperty::Build(bundle.records, startUs, [this](size_t i) { return ReplicaFields(i); });
propertyBuilt = true;
});
return property;
}
};
std::shared_ptr<Cached> g_Cache;
@@ -686,6 +702,46 @@ namespace CaptureReplay {
{"worlds", WorldsJson(*cached)}, {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} });
});
Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/property", Perm(PERMISSION),
"The properties the capture saw, per world (zone, instance, clone): {worlds: [{zone, zoneName, instance, clone, saved, info, counts, "
"models: [{object, lot, spawner, ugcId, behaviors, spans: [{from, to, i, position, rotation}]}], events}]}. From the captured replica "
"packets and property game messages; `saved` is the property saved for that zone and clone now (its id for links and UGC meshes)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto session = FindSession(context, reply);
if (!session) return;
const auto cached = Load(*session, reply);
if (!cached) return;
// On the main thread: zone names and the saved property of each world the capture was on
std::map<std::pair<uint32_t, uint32_t>, json> saved;
std::map<uint32_t, std::string> zoneNames;
for (const auto& record : cached->bundle.records) {
const auto& h = record.header;
if (h.source != static_cast<uint8_t>(eCaptureSource::WORLD) || !h.zoneId || saved.contains({ h.zoneId, h.cloneId })) continue;
saved[{ h.zoneId, h.cloneId }] = nullptr;
if (!zoneNames.contains(h.zoneId)) zoneNames[h.zoneId] = GameText::ZoneName(h.zoneId);
const auto info = h.cloneId ? Database::Get()->GetPropertyInfo(h.zoneId, h.cloneId) : std::nullopt;
if (!info) continue;
saved[{ h.zoneId, h.cloneId }] = { {"id", std::to_string(info->id)}, {"name", info->name}, {"ownerId", std::to_string(info->ownerId)},
{"ownerName", CharacterName(info->ownerId)} };
}
Workers::Reply(reply, context, cached->propertyBuilt, [cached, saved = std::move(saved), zoneNames = std::move(zoneNames)](HTTPReply& out) {
json worlds = cached->Property()["worlds"];
for (auto& world : worlds) {
const auto zone = world["zone"].get<uint32_t>(), clone = world["clone"].get<uint32_t>();
const auto name = zoneNames.find(zone);
world["zoneName"] = name == zoneNames.end() ? std::string{} : name->second;
const auto found = saved.find({ zone, clone });
world["saved"] = found == saved.end() ? json(nullptr) : found->second;
// Names from the locale, in the viewer's language (read once at startup, so workers may read them)
for (auto& model : world["models"]) model["name"] = ClientAssets::ItemName(model["lot"].get<LOT>());
for (auto& event : world["events"]) {
if (event.contains("lot")) event["name"] = ClientAssets::ItemName(event["lot"].get<LOT>());
}
}
JsonSuccess(out, { {"worlds", worlds} });
});
});
Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/worlds", Perm(PERMISSION),
"Which world server each captured character was on, in time order: one entry per move to another zone or instance (zone 0 is character select)",
[](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -383,6 +383,26 @@ namespace WorldView {
JsonReply(reply, eHTTPStatusCode::OK, { {"instances", instances} });
});
Route(eHTTPMethod::GET, "/api/world3d/property_models", Perm("players_history"),
"The models placed on a property now, for replays of recorded positions there (which don't record models): "
"{property: {id, name, ownerId, ownerName} or null, models: [{id, lot, ugcId, position, rotation}]}. Query: ?zone=&clone=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto zone = static_cast<uint32_t>(std::max<int64_t>(0, QueryInt(context, "zone", 0)));
const auto clone = static_cast<uint32_t>(std::max<int64_t>(0, QueryInt(context, "clone", 0)));
const auto info = zone && clone ? Database::Get()->GetPropertyInfo(zone, clone) : std::nullopt;
if (!info) return JsonReply(reply, eHTTPStatusCode::OK, { {"property", nullptr}, {"models", nlohmann::json::array()} });
nlohmann::json models = nlohmann::json::array();
for (const auto& model : Database::Get()->GetPropertyModels(info->id)) {
models.push_back({ {"id", std::to_string(model.id)}, {"lot", model.lot}, {"name", ClientAssets::ItemName(model.lot)}, {"ugcId", std::to_string(model.ugcId)},
{"position", {model.position.x, model.position.y, model.position.z}},
{"rotation", {model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w}} });
}
JsonReply(reply, eHTTPStatusCode::OK, {
{"property", { {"id", std::to_string(info->id)}, {"name", info->name}, {"ownerId", std::to_string(info->ownerId)}, {"ownerName", CharacterName(info->ownerId)} }},
{"models", models}
});
});
Route(eHTTPMethod::GET, "/api/world3d/history", Perm("players_history"),
"Where players went in a zone between two times, for replays: per character its samples as [seconds after from, x, y, z, ...]. "
"Query: ?zone=&instance= (0: all)&from=&to= (Unix seconds, at most 7 days apart). Long ranges are thinned to about 3000 samples per player. Audited",

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

@@ -143,6 +143,7 @@
state.records = [];
state.worlds = [];
byId('worldMarkers').innerHTML = '';
byId('propertySummary').classList.add('d-none');
state.selected = -1;
history.replaceState(null, '', '#capture=' + id);
byId('viewer').classList.remove('d-none');
@@ -182,9 +183,71 @@
else render();
renderHeader();
loadWorlds(c.id);
loadProperty(c.id);
}, function () { state.loading = false; });
}
// ---- the properties the capture was on ----
function point(p) { return p && p.length === 3 ? p.map(function (v) { return v.toFixed(1); }).join(', ') : ''; }
// What happened to the models: placed, moved (a run of moves of one model is one entry) and removed
function modelEvents(world) {
var out = [];
(world.events || []).forEach(function (e) {
if (e.kind !== 'placed' && e.kind !== 'moved' && e.kind !== 'removed') return;
var last = out[out.length - 1];
if (e.kind === 'moved' && last && last.kind === 'moved' && last.object === e.object) { last.moves++; last.position = e.position; return; }
out.push(Object.assign({ moves: 1 }, e));
});
return out;
}
function eventHtml(e) {
var what = e.object ? esc(e.name || ('LOT ' + e.lot)) + ' <span class="text-body-secondary">LOT ' + esc(e.lot) + '</span>' : 'a model';
var text = e.kind === 'placed' ? 'Placed ' + what + (e.position ? ' at ' + esc(point(e.position)) : '') + (e.object ? '' : ' <span class="text-body-secondary">(not seen made)</span>')
: e.kind === 'moved' ? 'Moved ' + what + (e.moves > 1 ? ' ' + e.moves + ' times' : '') + ' to ' + esc(point(e.position))
: 'Removed ' + what + (e.reason ? ' (' + esc(e.reason) + ')' : '');
// Jump to the packet that said it: the client's request for a removal it asked for
var index = e.kind === 'removed' && e.asked !== undefined ? e.asked : e.i;
return '<button type="button" class="list-group-item list-group-item-action px-2 py-1" data-packet="' + esc(index) + '" data-at="' + esc(e.t) + '">' +
'<span class="font-monospace text-body-secondary me-2">' + timeText(e.t * 1000) + '</span>' + text + ' <span class="text-body-secondary">#' + esc(index) + '</span></button>';
}
function worldHtml(w) {
var info = (w.info || [])[0], last = (w.info || [])[(w.info || []).length - 1];
var owner = info ? info.ownerName : (w.saved ? w.saved.ownerName : '');
var ownerId = info ? info.ownerId : (w.saved ? w.saved.ownerId : '');
var events = modelEvents(w);
var name = last && last.name ? last.name : (w.saved ? w.saved.name : '');
return '<div class="card mb-2"><div class="card-body p-2">' +
'<div><strong>' + esc(name || 'Property') + '</strong>' + (owner ? ' · owner ' + (ownerId && ownerId !== '0' ? fmt.character(ownerId, owner) : esc(owner)) : '') +
' · ' + esc(w.zoneName || ('Zone ' + w.zone)) + ' #' + esc(w.instance) + (w.clone ? ' (clone ' + esc(w.clone) + ')' : '') +
' · ' + nf.format(w.models.length) + ' model' + (w.models.length === 1 ? '' : 's') + ' seen' +
(w.saved ? ' · <a href="/properties/' + esc(w.saved.id) + '">the property now</a>' : '') + '</div>' +
(info && (w.info || []).length > 1 ? '<div class="text-body-secondary">Its property data changed ' + ((w.info || []).length - 1) + ' time(s) during the capture.</div>' : '') +
(events.length ? '<div class="list-group list-group-flush mt-1" style="max-height: 12rem; overflow-y: auto">' + events.map(eventHtml).join('') + '</div>'
: '<div class="text-body-secondary">No model was placed, moved or removed during the capture.</div>') +
'</div></div>';
}
function loadProperty(id) {
api.get(url(id) + '/property').then(function (d) {
if (!state.capture || state.capture.id !== id || !d.worlds) return;
var box = byId('propertySummary');
box.innerHTML = d.worlds.map(worldHtml).join('');
box.classList.toggle('d-none', !d.worlds.length);
}).catch(function () {});
}
byId('propertySummary').addEventListener('click', function (e) {
var entry = e.target.closest('[data-packet]');
if (!entry) return;
stop();
setPlayhead(Number(entry.dataset.at) * 1000 + 1);
showPacket(parseInt(entry.dataset.packet, 10));
});
// The timeline's world change markers (world3d-core.js draws them, as World 3D's replay does)
function loadWorlds(id) {
Promise.all([api.get(url(id) + '/worlds'), import('/js/world3d-core.js')]).then(function (results) {
@@ -290,9 +353,15 @@
byId('rows').addEventListener('click', function (e) {
var row = e.target.closest('[data-i]');
if (!row || !state.capture) return;
state.selected = parseInt(row.dataset.i, 10);
if (row) showPacket(parseInt(row.dataset.i, 10));
});
function showPacket(index) {
if (!state.capture) return;
state.selected = index;
render();
var shown = byId('rows').querySelector('tr[data-i="' + index + '"]');
if (shown) shown.scrollIntoView({ block: 'nearest' });
api.get(url(state.capture.id) + '/packets/' + state.selected).then(function (d) {
if (!d.record) { toast(d.error || 'Could not load the packet', 'danger'); return; }
var r = d.record;
@@ -305,7 +374,7 @@
byId('detailFields').textContent = r.fields ? JSON.stringify(r.fields, null, 2) : (r.gap ? r.gap + ' packets were lost here (a server buffer was full)' : 'Not decoded: no struct reads this packet (the server never sends or handles it). Its bytes are below.');
byId('detailHex').textContent = hexdump(r.hex || '');
}).catch(function () {});
});
}
byId('detailClose').addEventListener('click', function () {
byId('detail').classList.add('d-none');
byId('grid').classList.add('no-detail');

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

@@ -103,6 +103,7 @@
<span class="font-monospace text-nowrap" id="timeText"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="endBtn" title="Show everything">End</button>
</div>
<div class="d-none mb-2 small" id="propertySummary" aria-label="Properties in this capture"></div>
<div class="row g-2 align-items-end mb-2">
<div class="col-md-5"><input class="form-control form-control-sm" id="nameFilter" placeholder="Packet name part, e.g. POSITION, GAME_MSG REQUEST_USE"></div>
<div class="col-md-3"><select class="form-select form-select-sm" id="sourceFilter" aria-label="Server">

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">