mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-27 15:07:14 +00:00
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:
@@ -16,6 +16,7 @@
|
||||
#include "CaptureBundle.h"
|
||||
#include "CaptureTools.h"
|
||||
#include "LiveImport.h"
|
||||
#include "CaptureProperty.h"
|
||||
#include "GameMessageDecoder.h"
|
||||
#include "PacketDecoder.h"
|
||||
#include "CDClientDatabase.h"
|
||||
@@ -59,6 +60,8 @@ namespace {
|
||||
" info <bundle> what is in a bundle\n"
|
||||
" decode <bundle> [--fields] [--limit N] [--cdserver <CDServer.sqlite>]\n"
|
||||
" its packets on one timeline (replica packets too with --cdserver)\n"
|
||||
" property <bundle> --cdserver <CDServer.sqlite>\n"
|
||||
" the properties it saw: models over time, property data, events (JSON)\n"
|
||||
" anonymise <in> <out> a test fixture: names and chat replaced, IDs placeholders\n"
|
||||
" import-live <folder> <out-dir> convert live captures (every folder of *_traffic.zip under <folder>)\n"
|
||||
" replay <bundle>... [options] replay against a fresh sandbox stack per bundle and compare\n"
|
||||
@@ -163,6 +166,31 @@ namespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The properties a capture saw, as the dashboard's capture viewer shows them (CaptureProperty)
|
||||
int Property(const std::string& path, const std::string& cdServer) {
|
||||
CaptureBundle::Bundle bundle;
|
||||
if (!LoadBundle(path, bundle)) return 1;
|
||||
CaptureTools::SortTimeline(bundle.records);
|
||||
ReplicaDecoder::ComponentTable components;
|
||||
try {
|
||||
CDClientDatabase::Connect(cdServer);
|
||||
ReplicaDecoder::LoadComponentTable(components);
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Can't read " << cdServer << ": " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
ReplicaDecoder::Session replicas(components);
|
||||
std::vector<std::optional<json>> replica(bundle.records.size());
|
||||
for (size_t i = 0; i < bundle.records.size(); i++) {
|
||||
const auto& record = bundle.records[i];
|
||||
if (record.header.flags & PacketRecordFlags::GAP || CaptureTools::FromClient(record.header)) continue;
|
||||
replica[i] = replicas.Decode(record.bytes, CaptureTools::ReplicaConnection(record.header));
|
||||
}
|
||||
const auto start = bundle.records.empty() ? 0 : bundle.records.front().header.timeUs;
|
||||
std::cout << CaptureProperty::Build(bundle.records, start, [&replica](size_t i) { return replica[i] ? &*replica[i] : nullptr; }).dump(2) << "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Anonymise(const std::string& in, const std::string& out) {
|
||||
CaptureBundle::Bundle bundle;
|
||||
if (!LoadBundle(in, bundle)) return 1;
|
||||
@@ -345,6 +373,7 @@ int main(int argc, char** argv) {
|
||||
const auto& command = args[1];
|
||||
if (command == "info" && args.size() >= 3) return Info(args[2]);
|
||||
if (command == "decode" && args.size() >= 3) return Decode(args[2], Flag(args, "--fields"), std::stoul(Arg(args, "--limit", "1000000")), Arg(args, "--cdserver", ""));
|
||||
if (command == "property" && args.size() >= 3) return Property(args[2], Arg(args, "--cdserver", ""));
|
||||
if (command == "anonymise" && args.size() >= 4) return Anonymise(args[2], args[3]);
|
||||
if (command == "import-live" && args.size() >= 4) return ImportLive(args[2], args[3]);
|
||||
if (command == "replay") return Replay(args);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
|
||||
"BrickByBrick.cpp"
|
||||
"CaptureProperty.cpp"
|
||||
"ChatServerLink.cpp"
|
||||
"CheatDetection.cpp"
|
||||
"DashboardActions.cpp"
|
||||
|
||||
364
dGame/dUtilities/CaptureProperty.cpp
Normal file
364
dGame/dUtilities/CaptureProperty.cpp
Normal file
@@ -0,0 +1,364 @@
|
||||
#include "CaptureProperty.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
|
||||
#include "BrickByBrick.h"
|
||||
#include "CaptureTools.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "MessageIdentifiers.h"
|
||||
#include "PacketDecoder.h"
|
||||
#include "PropertyMessages.h"
|
||||
|
||||
namespace {
|
||||
using json = nlohmann::json;
|
||||
using Record = CaptureBundle::Record;
|
||||
|
||||
constexpr const char* GAME_MSG = "GAME_MSG ";
|
||||
|
||||
struct Span {
|
||||
double from{};
|
||||
std::optional<double> to;
|
||||
size_t index{};
|
||||
json position;
|
||||
json rotation;
|
||||
};
|
||||
|
||||
struct Model {
|
||||
LWOOBJID object{};
|
||||
LOT lot{};
|
||||
std::string spawner;
|
||||
std::string ugcId;
|
||||
json behaviors;
|
||||
std::vector<Span> spans;
|
||||
bool present{};
|
||||
bool placed{}; // a placement in the capture made it
|
||||
};
|
||||
|
||||
struct Placement {
|
||||
double t{};
|
||||
size_t index{};
|
||||
json position;
|
||||
json rotation;
|
||||
};
|
||||
|
||||
struct World {
|
||||
uint32_t zone{};
|
||||
uint32_t instance{};
|
||||
uint32_t clone{};
|
||||
double t{};
|
||||
bool property{};
|
||||
json info = json::array();
|
||||
json counts = json::array();
|
||||
json events = json::array();
|
||||
std::vector<Model> models;
|
||||
std::map<LWOOBJID, size_t> byObject; // object -> its entry in models (the latest, if it was made again)
|
||||
std::vector<Placement> placements; // placed, their model not made yet
|
||||
std::map<LWOOBJID, std::pair<size_t, int32_t>> deletes; // model -> the DeleteModelFromClient (record, reason) asking for it
|
||||
};
|
||||
|
||||
const json* ComponentFields(const json& fields, const char* name) {
|
||||
const auto it = fields.find("components");
|
||||
if (it == fields.end() || !it->is_array()) return nullptr;
|
||||
for (const auto& component : *it) {
|
||||
if (component.value("component", std::string{}) == name) {
|
||||
const auto found = component.find("fields");
|
||||
return found == component.end() ? nullptr : &*found;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Where the object stands: the first component that says (its physics component)
|
||||
std::optional<std::pair<json, json>> Placed(const json& fields) {
|
||||
const auto it = fields.find("components");
|
||||
if (it == fields.end() || !it->is_array()) return std::nullopt;
|
||||
for (const auto& component : *it) {
|
||||
const auto found = component.find("fields");
|
||||
if (found == component.end() || !found->is_object()) continue;
|
||||
const auto position = found->find("position"), rotation = found->find("rotation");
|
||||
if (position != found->end() && position->is_array() && position->size() == 3 && rotation != found->end() && rotation->is_array() && rotation->size() == 4) {
|
||||
return std::pair{ *position, *rotation };
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// A model's component: the plain one, or the mutable one (with behaviors) property and inventory models get
|
||||
const json* ModelFields(const json& fields) {
|
||||
const auto* model = ComponentFields(fields, "MUTABLE_MODEL_BEHAVIORS");
|
||||
return model ? model : ComponentFields(fields, "MODEL");
|
||||
}
|
||||
|
||||
bool IsModel(const json& fields) {
|
||||
if (fields.value("lot", 0) == BrickByBrick::MODEL_OBJECT_LOT) return true;
|
||||
return ModelFields(fields) && !ComponentFields(fields, "PET");
|
||||
}
|
||||
|
||||
// A config entry's value ("key=type:value")
|
||||
std::string ConfigValue(const json& fields, const std::string& key) {
|
||||
const auto found = fields.find("config");
|
||||
if (found == fields.end()) return {};
|
||||
// Compressed config shows its inflated entries under "entries"
|
||||
const json* config = &*found;
|
||||
if (config->is_object()) {
|
||||
const auto entries = config->find("entries");
|
||||
if (entries == config->end()) return {};
|
||||
config = &*entries;
|
||||
}
|
||||
if (!config->is_array()) return {};
|
||||
for (const auto& entry : *config) {
|
||||
if (!entry.is_string()) continue;
|
||||
const auto& text = entry.get_ref<const std::string&>();
|
||||
if (!text.starts_with(key + "=")) continue;
|
||||
const auto colon = text.find(':', key.size() + 1);
|
||||
return colon == std::string::npos ? std::string{} : text.substr(colon + 1);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
LWOOBJID ObjectOf(const json& fields, const char* key) {
|
||||
const auto it = fields.find(key);
|
||||
if (it == fields.end()) return LWOOBJID_EMPTY;
|
||||
if (it->is_string()) return GeneralUtils::TryParse<LWOOBJID>(it->get<std::string>()).value_or(LWOOBJID_EMPTY);
|
||||
if (it->is_number_integer()) return it->get<LWOOBJID>();
|
||||
return LWOOBJID_EMPTY;
|
||||
}
|
||||
|
||||
const char* DeleteReason(int32_t reason) {
|
||||
switch (static_cast<BrickByBrick::eDeleteReason>(reason)) {
|
||||
case BrickByBrick::eDeleteReason::PICKING_MODEL_UP: return "picked up";
|
||||
case BrickByBrick::eDeleteReason::RETURNING_MODEL_TO_INVENTORY: return "put away";
|
||||
case BrickByBrick::eDeleteReason::BREAKING_MODEL_APART: return "taken apart";
|
||||
default: return "removed";
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadPropertyData's moderation status, as the client reads it
|
||||
const char* Moderation(uint32_t status) {
|
||||
using Data = GameMessages::DownloadPropertyData;
|
||||
switch (status) {
|
||||
case Data::REJECTION_STATUS_APPROVED: return "approved";
|
||||
case Data::REJECTION_STATUS_PENDING: return "pending";
|
||||
case Data::REJECTION_STATUS_REJECTED: return "rejected";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
json SpanJson(const Span& span) {
|
||||
return { {"from", span.from}, {"to", span.to ? json(*span.to) : json(nullptr)}, {"i", span.index}, {"position", span.position}, {"rotation", span.rotation} };
|
||||
}
|
||||
|
||||
json EventJson(const char* kind, double t, size_t index, const Model* model) {
|
||||
json out{ {"kind", kind}, {"t", t}, {"i", index} };
|
||||
if (model) {
|
||||
out["object"] = std::to_string(model->object);
|
||||
out["lot"] = model->lot;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A placement the server confirmed, with the model it made
|
||||
json PlacedJson(const Placement& placement, Model& model) {
|
||||
model.placed = true;
|
||||
auto event = EventJson("placed", placement.t, placement.index, &model);
|
||||
event["position"] = placement.position;
|
||||
event["rotation"] = placement.rotation;
|
||||
event["made"] = model.spans.front().index;
|
||||
return event;
|
||||
}
|
||||
|
||||
void Close(Model& model, double t) {
|
||||
if (!model.spans.empty() && !model.spans.back().to) model.spans.back().to = t;
|
||||
model.present = false;
|
||||
}
|
||||
}
|
||||
|
||||
namespace CaptureProperty {
|
||||
json Build(const std::vector<Record>& records, int64_t startUs, const ReplicaFields& replica) {
|
||||
std::map<std::tuple<uint32_t, uint32_t, uint32_t>, World> worlds;
|
||||
const auto worldOf = [&](const PacketRecordHeader& h, double t) -> World& {
|
||||
auto [it, added] = worlds.try_emplace({ h.zoneId, h.instanceId, h.cloneId });
|
||||
if (added) {
|
||||
it->second.zone = h.zoneId;
|
||||
it->second.instance = h.instanceId;
|
||||
it->second.clone = h.cloneId;
|
||||
it->second.t = t;
|
||||
}
|
||||
return it->second;
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < records.size(); i++) {
|
||||
const auto& record = records[i];
|
||||
const auto& h = record.header;
|
||||
if (h.flags & PacketRecordFlags::GAP || h.source != static_cast<uint8_t>(eCaptureSource::WORLD) || record.bytes.empty()) continue;
|
||||
const double t = static_cast<double>(h.timeUs - startUs) / 1e6;
|
||||
const bool fromClient = CaptureTools::FromClient(h);
|
||||
|
||||
// Replica packets: the models themselves
|
||||
const auto id = static_cast<uint8_t>(record.bytes[0]);
|
||||
if (id == ID_REPLICA_MANAGER_CONSTRUCTION || id == ID_REPLICA_MANAGER_SERIALIZE || id == ID_REPLICA_MANAGER_DESTRUCTION) {
|
||||
const auto* fields = replica ? replica(i) : nullptr;
|
||||
if (!fields || fromClient) continue;
|
||||
const auto object = ObjectOf(*fields, "objectID");
|
||||
if (!object) continue;
|
||||
if (id == ID_REPLICA_MANAGER_CONSTRUCTION) {
|
||||
if (!IsModel(*fields)) continue;
|
||||
auto& world = worldOf(h, t);
|
||||
const auto placed = Placed(*fields);
|
||||
const auto known = world.byObject.find(object);
|
||||
// Made again for another captured player on the same world: the same model
|
||||
if (known != world.byObject.end() && world.models[known->second].present) {
|
||||
auto& model = world.models[known->second];
|
||||
if (placed && !model.spans.empty() && model.spans.back().position == placed->first) continue;
|
||||
Close(model, t);
|
||||
}
|
||||
Model model;
|
||||
model.object = object;
|
||||
model.lot = fields->value("lot", 0);
|
||||
if (const auto spawner = fields->find("spawner"); spawner != fields->end() && spawner->is_string()) model.spawner = spawner->get<std::string>();
|
||||
model.ugcId = ConfigValue(*fields, "blueprintid");
|
||||
if (const auto* component = ModelFields(*fields); component && component->contains("behaviors")) model.behaviors = (*component)["behaviors"];
|
||||
model.present = true;
|
||||
if (placed) model.spans.push_back({ t, std::nullopt, i, placed->first, placed->second });
|
||||
world.byObject[object] = world.models.size();
|
||||
world.models.push_back(std::move(model));
|
||||
// The model a placement made (a live server answered first): the first one made where it said
|
||||
if (placed) {
|
||||
for (auto it = world.placements.begin(); it != world.placements.end(); ++it) {
|
||||
if (it->position != placed->first) continue;
|
||||
world.events.push_back(PlacedJson(*it, world.models.back()));
|
||||
world.placements.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const auto found = worlds.find({ h.zoneId, h.instanceId, h.cloneId });
|
||||
if (found == worlds.end()) continue;
|
||||
auto& world = found->second;
|
||||
const auto known = world.byObject.find(object);
|
||||
if (known == world.byObject.end() || !world.models[known->second].present) continue;
|
||||
auto& model = world.models[known->second];
|
||||
if (id == ID_REPLICA_MANAGER_DESTRUCTION) {
|
||||
Close(model, t);
|
||||
auto event = EventJson("removed", t, i, &model);
|
||||
const auto asked = world.deletes.find(object);
|
||||
if (asked != world.deletes.end()) {
|
||||
event["reason"] = DeleteReason(asked->second.second);
|
||||
event["asked"] = asked->second.first;
|
||||
world.deletes.erase(asked);
|
||||
}
|
||||
world.events.push_back(std::move(event));
|
||||
continue;
|
||||
}
|
||||
// Serialization: behaviors, and a new place
|
||||
if (const auto* component = ModelFields(*fields); component && component->contains("behaviors")) model.behaviors = (*component)["behaviors"];
|
||||
const auto placed = Placed(*fields);
|
||||
if (!placed || (!model.spans.empty() && model.spans.back().position == placed->first && model.spans.back().rotation == placed->second)) continue;
|
||||
if (!model.spans.empty()) model.spans.back().to = t;
|
||||
model.spans.push_back({ t, std::nullopt, i, placed->first, placed->second });
|
||||
auto event = EventJson("moved", t, i, &model);
|
||||
event["position"] = placed->first;
|
||||
world.events.push_back(std::move(event));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Game messages about the property
|
||||
const auto decoded = PacketDecoder::Decode(record.bytes, fromClient);
|
||||
if (decoded.gameMessageId < 0 || !decoded.name.starts_with(GAME_MSG)) continue;
|
||||
const auto name = decoded.name.substr(std::char_traits<char>::length(GAME_MSG));
|
||||
const json empty = json::object();
|
||||
const auto& fields = decoded.fields ? *decoded.fields : empty;
|
||||
const bool behavior = name.find("BEHAVIOR") != std::string::npos;
|
||||
// A live server also sent the data of the player's properties elsewhere (another map): only this world's
|
||||
// is about it (any, when the capture doesn't know the world's zone)
|
||||
const bool otherProperty = name == "DOWNLOAD_PROPERTY_DATA" && h.zoneId && fields.value("mapId", 0) != h.zoneId;
|
||||
const bool propertyMessage = !otherProperty && (name == "DOWNLOAD_PROPERTY_DATA" || name == "GET_MODELS_ON_PROPERTY" ||
|
||||
name == "PLACE_MODEL_RESPONSE" || name == "DELETE_MODEL_FROM_CLIENT" || name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END");
|
||||
const auto existing = worlds.find({ h.zoneId, h.instanceId, h.cloneId });
|
||||
if (!propertyMessage && !behavior && existing == worlds.end()) continue;
|
||||
auto& world = existing != worlds.end() ? existing->second : worldOf(h, t);
|
||||
if (propertyMessage) world.property = true;
|
||||
|
||||
if (name == "DOWNLOAD_PROPERTY_DATA" && !fromClient && decoded.fields && !otherProperty) {
|
||||
json info{ {"t", t}, {"i", i} };
|
||||
for (const char* key : { "propertyId", "name", "description", "ownerName", "ownerId", "accessType", "moderationStatus",
|
||||
"rejectionReason", "reputation", "templateId", "mapId", "cloneId", "maxBuildHeight" }) {
|
||||
if (fields.contains(key)) info[key] = fields[key];
|
||||
}
|
||||
if (const auto* moderation = Moderation(fields.value("moderationStatus", UINT32_MAX))) info["moderation"] = moderation;
|
||||
// Sent again (to each player, on each edit): kept once while it says the same
|
||||
auto said = info;
|
||||
said.erase("t");
|
||||
said.erase("i");
|
||||
if (!world.info.empty()) {
|
||||
auto before = world.info.back();
|
||||
before.erase("t");
|
||||
before.erase("i");
|
||||
if (before == said) continue;
|
||||
}
|
||||
world.info.push_back(std::move(info));
|
||||
} else if (name == "GET_MODELS_ON_PROPERTY" && !fromClient && fields.contains("models") && fields["models"].is_array()) {
|
||||
// Each captured player on the world gets one: count it once while it says the same
|
||||
const auto count = fields["models"].size();
|
||||
if (world.counts.empty() || world.counts.back()["count"] != count) world.counts.push_back({ {"t", t}, {"i", i}, {"count", count} });
|
||||
} else if (name == "PLACE_MODEL_RESPONSE" && !fromClient && fields.value("response", 0) == BrickByBrick::PLACE_MODEL_PLACED) {
|
||||
Placement placement{ t, i, fields.value("position", json::array()), fields.value("rotation", json::array()) };
|
||||
// The model it made: this server made it before answering (the latest one made there not placed yet), or
|
||||
// it comes after
|
||||
auto made = std::find_if(world.models.rbegin(), world.models.rend(), [&placement](const Model& model) {
|
||||
return model.present && !model.placed && !model.spans.empty() && model.spans.front().position == placement.position;
|
||||
});
|
||||
if (made != world.models.rend()) world.events.push_back(PlacedJson(placement, *made));
|
||||
else world.placements.push_back(std::move(placement));
|
||||
} else if (name == "DELETE_MODEL_FROM_CLIENT" && fromClient && decoded.fields) {
|
||||
const auto model = ObjectOf(fields, "modelID");
|
||||
if (model) world.deletes[model] = { i, fields.value("reason", 0) };
|
||||
} else if (name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END") {
|
||||
world.events.push_back(EventJson(name == "PROPERTY_EDITOR_BEGIN" ? "editing" : "stopped editing", t, i, nullptr));
|
||||
}
|
||||
|
||||
// Behaviors: the behavior messages, and what was sent to or from a model while it stood there
|
||||
const Model* model = nullptr;
|
||||
auto target = decoded.objectId;
|
||||
if (name == "CONTROL_BEHAVIORS" && fields.contains("args") && fields["args"].is_object()) {
|
||||
const auto fromArgs = ObjectOf(fields["args"], "objectID");
|
||||
if (fromArgs) target = fromArgs;
|
||||
}
|
||||
if (const auto it = world.byObject.find(target); it != world.byObject.end() && world.models[it->second].present) model = &world.models[it->second];
|
||||
if (!behavior && !model) continue;
|
||||
auto event = EventJson("behavior", t, i, model);
|
||||
event["message"] = name;
|
||||
event["toServer"] = fromClient;
|
||||
if (name == "CONTROL_BEHAVIORS" && fields.contains("command")) event["command"] = fields["command"];
|
||||
world.events.push_back(std::move(event));
|
||||
}
|
||||
|
||||
json out = json::array();
|
||||
for (auto& [key, world] : worlds) {
|
||||
if (!world.property && world.models.empty()) continue;
|
||||
json models = json::array();
|
||||
for (const auto& model : world.models) {
|
||||
json spans = json::array();
|
||||
for (const auto& span : model.spans) spans.push_back(SpanJson(span));
|
||||
models.push_back({ {"object", std::to_string(model.object)}, {"lot", model.lot}, {"spawner", model.spawner},
|
||||
{"ugcId", model.ugcId}, {"behaviors", model.behaviors}, {"spans", spans} });
|
||||
}
|
||||
// Placements whose model was never seen made (made before the capture could read it, or refused later)
|
||||
for (const auto& placement : world.placements) {
|
||||
auto event = EventJson("placed", placement.t, placement.index, nullptr);
|
||||
event["position"] = placement.position;
|
||||
event["rotation"] = placement.rotation;
|
||||
world.events.push_back(std::move(event));
|
||||
}
|
||||
std::stable_sort(world.events.begin(), world.events.end(), [](const json& a, const json& b) { return a["i"].get<size_t>() < b["i"].get<size_t>(); });
|
||||
out.push_back({ {"zone", world.zone}, {"instance", world.instance}, {"clone", world.clone}, {"t", world.t}, {"property", world.property},
|
||||
{"info", world.info}, {"counts", world.counts}, {"models", models}, {"events", world.events} });
|
||||
}
|
||||
return { {"worlds", out} };
|
||||
}
|
||||
}
|
||||
39
dGame/dUtilities/CaptureProperty.h
Normal file
39
dGame/dUtilities/CaptureProperty.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#ifndef __CAPTUREPROPERTY__H__
|
||||
#define __CAPTUREPROPERTY__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "CaptureBundle.h"
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* A property as a packet capture saw it, for the capture viewer and the World 3D replay: per world (zone, instance
|
||||
* and clone) the models placed on it over time and what the property said about itself.
|
||||
*
|
||||
* Everything comes from the captured packets:
|
||||
* - models: the replica constructions, serializations and destructions of objects with a model component (and brick
|
||||
* built models, LOT 14), each with its position and rotation per time span (from its physics component);
|
||||
* - placed: the server's PlaceModelResponse saying a model was placed, with the model that was then made at that
|
||||
* position; removed: a model's destruction, with the reason from the DeleteModelFromClient that asked for it;
|
||||
* moved: a model's serialization with a new position;
|
||||
* - info: each DownloadPropertyData (owner, name, description, access, moderation, reputation) and each
|
||||
* GetModelsOnProperty (how many models the property had);
|
||||
* - behaviors: the behavior messages (ControlBehaviors and the others named for behaviors) and the game messages
|
||||
* sent to or from a model while it stood there.
|
||||
* A world is listed when a property message was captured on it or a model was constructed there.
|
||||
*/
|
||||
namespace CaptureProperty {
|
||||
// The fields ReplicaDecoder read from record `index` (nullptr when it isn't a replica packet)
|
||||
using ReplicaFields = std::function<const nlohmann::json*(size_t index)>;
|
||||
|
||||
/**
|
||||
* {worlds: [{zone, instance, clone, t, info: [...], counts: [...], models: [...], events: [...]}]}; times are seconds
|
||||
* from `startUs`, `i` the record's index. A model: {object, lot, spawner, ugcId, behaviors, spans: [{from, to, i,
|
||||
* position, rotation}]} (`to` null: still there when the capture ended).
|
||||
*/
|
||||
nlohmann::json Build(const std::vector<CaptureBundle::Record>& records, int64_t startUs, const ReplicaFields& replica);
|
||||
}
|
||||
|
||||
#endif //!__CAPTUREPROPERTY__H__
|
||||
@@ -91,6 +91,9 @@ namespace {
|
||||
return name.empty() ? json(value) : json(std::string(name) + " (" + std::to_string(value) + ")");
|
||||
}
|
||||
|
||||
// Larger compressed LDF is not inflated (a construction's config is a few hundred bytes)
|
||||
constexpr uint32_t MAX_LDF_BYTES = 1024 * 1024;
|
||||
|
||||
// LDF entries as the client reads them: "key=type:value"
|
||||
json ReadLdfEntries(Reader& r, int32_t count) {
|
||||
json out = json::array();
|
||||
|
||||
@@ -16,6 +16,30 @@ The dashboard serves them with the zone's name from the client's locale (`/api/i
|
||||
its slider; World 3D switches its scene to the followed character's world at that time (`world3d-core.js`
|
||||
`captureSwitch`, `worldMarkers`).
|
||||
|
||||
## Properties in a capture
|
||||
|
||||
`CaptureProperty::Build` (`/api/inspector/sessions/:id/property`, `CaptureTool property`) reads the property each
|
||||
world of a capture was, from its packets alone:
|
||||
|
||||
- **Models:** objects constructed with a model component (and brick built models, LOT 14), each with its LOT, object
|
||||
ID, spawner, blueprint (the UGC ID, from its config) and behavior count, and where it stood per time span (its
|
||||
physics component's position and rotation, from the construction and each serialization that moves it) until its
|
||||
destruction.
|
||||
- **Events:** placed (a `PlaceModelResponse` saying a model was placed, with the model made at that exact position:
|
||||
this server makes it before answering, a live server after), moved (a serialization with a new position), removed
|
||||
(a destruction, with the reason from the `DeleteModelFromClient` that asked for it), editing, and behavior messages
|
||||
(those named for behaviors, and any game message sent to or from a model while it stood there).
|
||||
- **Property data:** each `DownloadPropertyData` for this world's map (a live server also sent the player's other
|
||||
properties), kept once while it says the same, and each `GetModelsOnProperty`'s model count.
|
||||
|
||||
The dashboard adds the zone's and the models' names from the locale and the property saved now for that zone and clone
|
||||
(its link, and the `?property=` that lets the UGC mesh route serve its models). World 3D draws each model where it stood
|
||||
at the playhead (the UGC server's mesh for a brick built model when it has one, a box otherwise), shows the property
|
||||
data of that moment in its Property tab and ticks the behavior messages on the timeline; seeking either way reads the
|
||||
spans again, nothing is replayed. The capture page lists each property with its owner and every model placed, moved or
|
||||
removed, each jumping to its packet. A replay of recorded positions on one property instance shows the models placed
|
||||
now (`/api/world3d/property_models`), labelled as such, since position history doesn't record models.
|
||||
|
||||
Captures, bundles and fixtures are player data. None of them is ever committed: `captures/`, `*.bundle` and
|
||||
`tests/fixtures-local/` are in `.gitignore`.
|
||||
|
||||
@@ -122,7 +146,9 @@ thread, and the result is kept with the loaded capture:
|
||||
age, config, trigger, spawner, spawner node, scale, world state, GM level, parent and children), then each
|
||||
component's data in the order the client reads them (`Entity::WriteComponents`). Which components an object has
|
||||
comes from the ComponentsRegistry rows of its LOT, read from the CDClient at startup, with the ones the server adds
|
||||
itself (a destroyable for collectibles, quick builds and models, the character's parts, a buff with a destroyable).
|
||||
itself (a destroyable for collectibles, quick builds and models, the character's parts, a buff with a destroyable)
|
||||
and without the item component of a model (its model component writes the item info). A compressed config (live
|
||||
servers compressed model configs) is inflated and read.
|
||||
- **Serialization:** the object's LOT and components from its construction earlier on the same world instance, then
|
||||
each component's update. An object constructed before the capture started shows "object not constructed in this
|
||||
capture" and the bytes.
|
||||
@@ -211,6 +237,7 @@ CaptureTool replay <bundle>... --client <game client folder> [--cdserver <CDServ
|
||||
CaptureTool import-live <folder> <out-dir> convert live captures
|
||||
CaptureTool anonymise <in> <out> make a fixture
|
||||
CaptureTool info|decode <bundle> [--cdserver <CDServer.sqlite>] look inside (replica packets too with --cdserver)
|
||||
CaptureTool property <bundle> --cdserver <CDServer.sqlite> the properties it saw (JSON)
|
||||
```
|
||||
|
||||
### The sandbox
|
||||
@@ -290,6 +317,12 @@ decoder registry knows and checks it writes back to the same bytes; without fixt
|
||||
- In a capture of a zone load: every packet shows fields; constructions list their components (no
|
||||
`(layout did not match)` on players, enemies, smashables, NPCs); a later `ID_REPLICA_MANAGER_SERIALIZE` of an enemy
|
||||
hit in the capture shows its new health; game messages both ways (a skill, an emote, a vendor purchase) show fields.
|
||||
- Capture a property visit where you place, pick up and put away a model, change the name or privacy, and run a
|
||||
model's behavior: the capture page lists the property and its owner with each placement and removal (clicking one
|
||||
selects its packet); in World 3D the models stand where they were at the playhead (brick built ones as the UGC
|
||||
server made them), appear and vanish at those moments forward and when seeking back, the Property tab's name,
|
||||
privacy and model count change when the update arrives, and the behavior messages are ticks on the timeline.
|
||||
- Replay recorded positions on one property instance: its placed models show, marked as how they are now.
|
||||
- Export a bundle, replay it with `CaptureTool replay`, and open a kept sandbox's logs.
|
||||
- Moving platforms (Starbase 3001's snail, Nexus Tower's lifts) move as before, and objects built with them look right.
|
||||
- A tamed pet that follows you through a fight and an untamed one being tamed: status and name updates show as before.
|
||||
|
||||
@@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS
|
||||
"RocketLaunchTests.cpp"
|
||||
"ReplicaConstructionTests.cpp"
|
||||
"ReplicaDecoderTests.cpp"
|
||||
"CapturePropertyTests.cpp"
|
||||
"ItemRemovalTests.cpp"
|
||||
"LootMetricsTests.cpp"
|
||||
)
|
||||
|
||||
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal file
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal file
@@ -0,0 +1,231 @@
|
||||
// A property as a packet capture saw it (CaptureProperty): a synthetic capture made from the server's own writers (a
|
||||
// brick built model and its replica packets, and the property's game messages as the server and client send them),
|
||||
// read back into the models' timeline, the property's info and the events the capture page lists.
|
||||
#include "GameDependencies.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "BitStream.h"
|
||||
#include "BrickByBrick.h"
|
||||
#include "CaptureBundle.h"
|
||||
#include "CaptureProperty.h"
|
||||
#include "CaptureTools.h"
|
||||
#include "DestroyableComponent.h"
|
||||
#include "Entity.h"
|
||||
#include "GameMessageDecoder.h"
|
||||
#include "MessageIdentifiers.h"
|
||||
#include "MessageType/Client.h"
|
||||
#include "MessageType/World.h"
|
||||
#include "ModelComponent.h"
|
||||
#include "PacketDecoder.h"
|
||||
#include "PropertyMessages.h"
|
||||
#include "ReplicaDecoder.h"
|
||||
#include "ServiceType.h"
|
||||
#include "SimplePhysicsComponent.h"
|
||||
#include "eReplicaComponentType.h"
|
||||
#include "eReplicaPacketType.h"
|
||||
|
||||
namespace {
|
||||
using json = nlohmann::json;
|
||||
using enum eReplicaComponentType;
|
||||
|
||||
constexpr LWOOBJID PLAYER = 1152921506064087003;
|
||||
constexpr LWOOBJID FIRST = 288300744895900001, SECOND = 288300744895900002, THIRD = 288300744895900003;
|
||||
|
||||
std::string Bytes(const RakNet::BitStream& stream) { return std::string(reinterpret_cast<const char*>(stream.GetData()), stream.GetNumberOfBytesUsed()); }
|
||||
|
||||
std::string Construction(Entity& entity, uint16_t network) {
|
||||
RakNet::BitStream stream;
|
||||
stream.Write<uint8_t>(ID_REPLICA_MANAGER_CONSTRUCTION);
|
||||
stream.Write1();
|
||||
stream.Write(network);
|
||||
entity.WriteBaseReplicaData(stream, eReplicaPacketType::CONSTRUCTION);
|
||||
entity.WriteComponents(stream, eReplicaPacketType::CONSTRUCTION);
|
||||
return Bytes(stream);
|
||||
}
|
||||
|
||||
std::string Serialization(Entity& entity, uint16_t network) {
|
||||
RakNet::BitStream stream;
|
||||
stream.Write<uint8_t>(ID_REPLICA_MANAGER_SERIALIZE);
|
||||
stream.Write(network);
|
||||
entity.WriteBaseReplicaData(stream, eReplicaPacketType::SERIALIZATION);
|
||||
entity.WriteComponents(stream, eReplicaPacketType::SERIALIZATION);
|
||||
return Bytes(stream);
|
||||
}
|
||||
|
||||
std::string Destruction(uint16_t network) {
|
||||
RakNet::BitStream stream;
|
||||
stream.Write<uint8_t>(ID_REPLICA_MANAGER_DESTRUCTION);
|
||||
stream.Write(network);
|
||||
return Bytes(stream);
|
||||
}
|
||||
|
||||
// A game message as the server sends it (CLIENT GAME_MSG) or the client does (WORLD GAME_MSG)
|
||||
std::string GameMessage(bool toServer, LWOOBJID object, const GameMessages::NetGameMsg& message) {
|
||||
RakNet::BitStream stream;
|
||||
if (toServer) LUBitStream(ServiceType::WORLD, MessageType::World::GAME_MSG).WriteHeader(stream);
|
||||
else LUBitStream(ServiceType::CLIENT, MessageType::Client::GAME_MSG).WriteHeader(stream);
|
||||
stream.Write(object);
|
||||
stream.Write(message.msgId);
|
||||
message.Serialize(stream);
|
||||
return Bytes(stream);
|
||||
}
|
||||
|
||||
struct Capture {
|
||||
std::vector<CaptureBundle::Record> records;
|
||||
|
||||
// One world packet, a second after the one before
|
||||
void Add(bool fromClient, std::string bytes) {
|
||||
CaptureBundle::Record record;
|
||||
record.header.seq = static_cast<uint32_t>(records.size());
|
||||
record.header.timeUs = 1000000 * static_cast<int64_t>(records.size() + 1);
|
||||
record.header.source = static_cast<uint8_t>(eCaptureSource::WORLD);
|
||||
record.header.direction = static_cast<uint8_t>(fromClient ? ePacketDirection::RECEIVED : ePacketDirection::SENT);
|
||||
record.header.zoneId = 1150;
|
||||
record.header.instanceId = 7;
|
||||
record.header.cloneId = 42;
|
||||
record.header.characterId = PLAYER;
|
||||
record.header.bits = static_cast<uint32_t>(bytes.size() * 8);
|
||||
record.bytes = std::move(bytes);
|
||||
records.push_back(std::move(record));
|
||||
}
|
||||
};
|
||||
|
||||
GameMessages::DownloadPropertyData PropertyData(const std::u16string& name, uint64_t reputation) {
|
||||
GameMessages::DownloadPropertyData data;
|
||||
data.propertyId = 1234;
|
||||
data.mapId = 1150;
|
||||
data.name = name;
|
||||
data.description = u"A garden";
|
||||
data.ownerName = u"Builder";
|
||||
data.ownerId = PLAYER;
|
||||
data.reputation = reputation;
|
||||
data.accessType = 2;
|
||||
data.moderationStatus = 1;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class CapturePropertyTest : public GameDependenciesTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
SetUpDependencies();
|
||||
PacketDecoder::SetGameMessageDecoder([](MessageType::Game id, bool toServer, RakNet::BitStream& payload) {
|
||||
return GameMessageDecoder::Decode(id, toServer, payload);
|
||||
});
|
||||
}
|
||||
void TearDown() override { TearDownDependencies(); }
|
||||
|
||||
std::unique_ptr<Entity> MakeModel(LWOOBJID id, const NiPoint3& position, LWOOBJID blueprint) {
|
||||
info.lot = BrickByBrick::MODEL_OBJECT_LOT;
|
||||
info.settings = {};
|
||||
info.settings.Insert<LWOOBJID>(u"blueprintid", blueprint);
|
||||
auto model = std::make_unique<Entity>(id, info);
|
||||
model->AddComponent<SimplePhysicsComponent>(-1)->SetPosition(position);
|
||||
model->AddComponent<ModelComponent>(-1)->LoadBehaviors();
|
||||
model->AddComponent<DestroyableComponent>(-1)->SetIsSmashable(true);
|
||||
return model;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(CapturePropertyTest, ModelsInfoAndEventsOverTime) {
|
||||
Capture capture;
|
||||
const NiPoint3 a(1.0f, 2.0f, 3.0f), b(10.0f, 2.0f, -4.0f), c(5.0f, 2.0f, 5.0f);
|
||||
auto first = MakeModel(FIRST, a, 5551);
|
||||
auto second = MakeModel(SECOND, b, 5552);
|
||||
|
||||
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"Old name", 10))); // 0: the property as it loads
|
||||
capture.Add(false, Construction(*first, 20)); // 1: a model already there
|
||||
GameMessages::PlaceModelResponse placed;
|
||||
placed.position = b;
|
||||
placed.response = BrickByBrick::PLACE_MODEL_PLACED;
|
||||
capture.Add(false, GameMessage(false, PLAYER, placed)); // 2: the server placed one...
|
||||
capture.Add(false, Construction(*second, 21)); // 3: ...and made it
|
||||
first->GetComponent<SimplePhysicsComponent>()->SetPosition(c);
|
||||
capture.Add(false, Serialization(*first, 20)); // 4: the first one moves
|
||||
GameMessages::PlayBehaviorSound sound;
|
||||
sound.soundID = 3;
|
||||
capture.Add(false, GameMessage(false, FIRST, sound)); // 5: its behavior plays a sound
|
||||
GameMessages::DeleteModelFromClient pickUp;
|
||||
pickUp.modelID = SECOND;
|
||||
pickUp.reason = static_cast<int32_t>(BrickByBrick::eDeleteReason::PICKING_MODEL_UP);
|
||||
capture.Add(true, GameMessage(true, PLAYER, pickUp)); // 6: the player picks the second up
|
||||
capture.Add(false, Destruction(21)); // 7: and it goes
|
||||
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 8: renamed
|
||||
auto elsewhere = PropertyData(u"Another property", 5);
|
||||
elsewhere.mapId = 1250;
|
||||
capture.Add(false, GameMessage(false, PLAYER, elsewhere)); // 9: the player's property on another map
|
||||
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 10: the same data again
|
||||
// This server makes the model before it answers the placement
|
||||
const NiPoint3 d(-8.8f, 455.04f, 126.4f);
|
||||
auto third = MakeModel(THIRD, d, 5553);
|
||||
capture.Add(false, Construction(*third, 22)); // 11: made...
|
||||
placed.position = d;
|
||||
capture.Add(false, GameMessage(false, PLAYER, placed)); // 12: ...then placed
|
||||
|
||||
const ReplicaDecoder::ComponentTable table{ { BrickByBrick::MODEL_OBJECT_LOT, {} } };
|
||||
ReplicaDecoder::Session session(table);
|
||||
std::vector<std::optional<json>> replica(capture.records.size());
|
||||
for (size_t i = 0; i < capture.records.size(); i++) replica[i] = session.Decode(capture.records[i].bytes, CaptureTools::ReplicaConnection(capture.records[i].header));
|
||||
const auto out = CaptureProperty::Build(capture.records, 0, [&](size_t i) { return replica[i] ? &*replica[i] : nullptr; });
|
||||
|
||||
ASSERT_EQ(out["worlds"].size(), 1u) << out.dump();
|
||||
const auto& world = out["worlds"][0];
|
||||
EXPECT_EQ(world["zone"], 1150);
|
||||
EXPECT_EQ(world["instance"], 7);
|
||||
EXPECT_EQ(world["clone"], 42);
|
||||
|
||||
// The property as each DownloadPropertyData said it, leaving out the one about another map
|
||||
ASSERT_EQ(world["info"].size(), 2u);
|
||||
EXPECT_EQ(world["info"][0]["name"], "Old name");
|
||||
EXPECT_EQ(world["info"][0]["ownerName"], "Builder");
|
||||
EXPECT_EQ(world["info"][0]["reputation"], "10");
|
||||
EXPECT_EQ(world["info"][0]["i"], 0);
|
||||
EXPECT_EQ(world["info"][1]["name"], "New name");
|
||||
EXPECT_EQ(world["info"][1]["reputation"], "25");
|
||||
|
||||
// The models: where each stood and when
|
||||
ASSERT_EQ(world["models"].size(), 3u);
|
||||
const auto& m1 = world["models"][0];
|
||||
EXPECT_EQ(m1["object"], std::to_string(FIRST));
|
||||
EXPECT_EQ(m1["lot"], BrickByBrick::MODEL_OBJECT_LOT);
|
||||
EXPECT_EQ(m1["ugcId"], "5551");
|
||||
ASSERT_EQ(m1["spans"].size(), 2u) << m1.dump();
|
||||
EXPECT_EQ(m1["spans"][0]["from"], 2.0);
|
||||
EXPECT_EQ(m1["spans"][0]["to"], 5.0);
|
||||
EXPECT_EQ(m1["spans"][0]["position"], json::array({ 1.0f, 2.0f, 3.0f }));
|
||||
EXPECT_EQ(m1["spans"][1]["from"], 5.0);
|
||||
EXPECT_TRUE(m1["spans"][1]["to"].is_null());
|
||||
EXPECT_EQ(m1["spans"][1]["position"], json::array({ 5.0f, 2.0f, 5.0f }));
|
||||
const auto& m2 = world["models"][1];
|
||||
EXPECT_EQ(m2["ugcId"], "5552");
|
||||
ASSERT_EQ(m2["spans"].size(), 1u);
|
||||
EXPECT_EQ(m2["spans"][0]["from"], 4.0);
|
||||
EXPECT_EQ(m2["spans"][0]["to"], 8.0);
|
||||
|
||||
// What the capture page lists: placed (the server's answer, with the model it made), moved, the behavior's
|
||||
// message and removed (with the reason the client gave)
|
||||
std::vector<std::string> kinds;
|
||||
for (const auto& event : world["events"]) kinds.push_back(event["kind"]);
|
||||
EXPECT_EQ(kinds, (std::vector<std::string>{ "placed", "moved", "behavior", "removed", "placed" })) << world["events"].dump();
|
||||
EXPECT_EQ(world["events"][0]["i"], 2);
|
||||
EXPECT_EQ(world["events"][0]["object"], std::to_string(SECOND));
|
||||
EXPECT_EQ(world["events"][0]["made"], 3);
|
||||
EXPECT_EQ(world["events"][1]["object"], std::to_string(FIRST));
|
||||
EXPECT_EQ(world["events"][2]["message"], "PLAY_BEHAVIOR_SOUND");
|
||||
EXPECT_EQ(world["events"][2]["object"], std::to_string(FIRST));
|
||||
EXPECT_EQ(world["events"][3]["object"], std::to_string(SECOND));
|
||||
EXPECT_EQ(world["events"][3]["reason"], "picked up");
|
||||
EXPECT_EQ(world["events"][3]["asked"], 6);
|
||||
EXPECT_EQ(world["events"][4]["i"], 12);
|
||||
EXPECT_EQ(world["events"][4]["object"], std::to_string(THIRD));
|
||||
EXPECT_EQ(world["events"][4]["made"], 11);
|
||||
}
|
||||
|
||||
// A world with no property message and no model isn't a property
|
||||
TEST_F(CapturePropertyTest, OtherWorldsAreLeftOut) {
|
||||
Capture capture;
|
||||
GameMessages::PlayBehaviorSound sound;
|
||||
capture.Add(false, GameMessage(false, PLAYER, sound));
|
||||
const auto out = CaptureProperty::Build(capture.records, 0, [](size_t) { return nullptr; });
|
||||
EXPECT_TRUE(out["worlds"].empty()) << out.dump();
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
#include "DestroyableComponent.h"
|
||||
#include "Entity.h"
|
||||
#include "InventoryComponent.h"
|
||||
#include "ItemComponent.h"
|
||||
#include "LevelProgressionComponent.h"
|
||||
#include "MessageIdentifiers.h"
|
||||
#include "ModelComponent.h"
|
||||
@@ -142,11 +141,13 @@ TEST_F(ReplicaDecoderTest, PlayerConstructionReadsTheCharacterParts) {
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "CHARACTER"))["statistics"].size(), 27u);
|
||||
}
|
||||
|
||||
// A model's destroyable is not in its registry rows: it is written after the other components
|
||||
// A model's destroyable is not in its registry rows: it is written after the other components. Its item component is
|
||||
// in the rows but not made (Entity::Initialize): the model component writes the item info
|
||||
TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) {
|
||||
info.lot = 6000;
|
||||
Entity model(288300744895900200, info);
|
||||
model.AddComponent<SimplePhysicsComponent>(-1);
|
||||
auto* const physics = model.AddComponent<SimplePhysicsComponent>(-1);
|
||||
physics->SetPosition(NiPoint3(-12.0f, 432.96f, -49.6f));
|
||||
// The model component writes the item's user-generated-content block itself, where the client reads the item
|
||||
model.AddComponent<ModelComponent>(-1)->LoadBehaviors();
|
||||
auto* const destroyable = model.AddComponent<DestroyableComponent>(-1);
|
||||
@@ -157,8 +158,10 @@ TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) {
|
||||
const auto constructed = session.Decode(Construction(model, 3), 1);
|
||||
ASSERT_TRUE(constructed);
|
||||
EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump();
|
||||
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS ITEM MODEL DESTROYABLE ");
|
||||
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL DESTROYABLE ");
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "DESTROYABLE"))["smashable"], true);
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"], json::array({ -12.0f, 432.96f, -49.6f }));
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "288300744895900200");
|
||||
}
|
||||
|
||||
// When no layout reads the packet exactly, what read is shown with the rest as bits, never a guess
|
||||
@@ -366,4 +369,45 @@ TEST_F(ReplicaDecoderTest, ModelBehaviorsOnlyOnPropertyModels) {
|
||||
EXPECT_EQ(fields->contains("behaviors"), onProperty);
|
||||
}
|
||||
info.settings.values.clear();
|
||||
// A live premade model placed on a property (LOT 12504: simple physics, item, model and render in the registry): its
|
||||
// config is compressed, and it reads as the server writes a model, with the destroyable after the render
|
||||
TEST_F(ReplicaDecoderTest, LivePlacedModel) {
|
||||
const unsigned char live[] = {
|
||||
0x24, 0xa1, 0x9b, 0xb6, 0xa7, 0x00, 0x00, 0x00, 0x20, 0x00, 0x02, 0x6c, 0x18, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00,
|
||||
0x5d, 0x40, 0x00, 0x00, 0x00, 0x62, 0x40, 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x1e, 0x27, 0x18, 0xd8, 0x58, 0x18, 0x14,
|
||||
0x12, 0x59, 0xb2, 0x19, 0xf2, 0x19, 0x4a, 0x00, 0x24, 0x9e, 0x43, 0x2a, 0x50, 0x17, 0x30, 0x84, 0x33, 0xa4, 0x30, 0x64,
|
||||
0x02, 0x69, 0x14, 0xc6, 0x5c, 0xa0, 0x1d, 0x0c, 0x64, 0x31, 0x72, 0x02, 0x15, 0x0a, 0x65, 0x02, 0xd5, 0x24, 0xc0, 0x85,
|
||||
0x83, 0x58, 0x6a, 0x01, 0x3a, 0x92, 0x19, 0x58, 0x19, 0x00, 0xa2, 0x8a, 0xc5, 0x0c, 0x05, 0x00, 0x59, 0x50, 0x3f, 0x08,
|
||||
0xa8, 0x36, 0x92, 0x81, 0x9f, 0xe1, 0x89, 0x21, 0x8b, 0x08, 0x0b, 0xc6, 0x72, 0x3c, 0xd9, 0x1c, 0x18, 0x58, 0x19, 0xc5,
|
||||
0x0a, 0xc1, 0x46, 0x80, 0xe4, 0xbd, 0xa1, 0x46, 0xc0, 0x64, 0xb8, 0x2f, 0x18, 0x70, 0x00, 0x01, 0x80, 0x1b, 0xc4, 0xc5,
|
||||
0xde, 0x10, 0x63, 0x1c, 0x85, 0xc0, 0x10, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x08, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x81, 0x83, 0xc2, 0xf5, 0xb0, 0x86, 0xce, 0xcc, 0x8d, 0x84, 0x00,
|
||||
0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7e, 0x00, 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7f, 0xd8, 0x30, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x08, 0x18, 0x3c, 0x2f, 0x5b, 0x08, 0x6c,
|
||||
0xec, 0xc8, 0xd8, 0x5e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x1e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x10,
|
||||
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x07, 0xe0, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xf0, 0x80,
|
||||
};
|
||||
const ReplicaDecoder::ComponentTable table{ { 12504, { RENDER, SIMPLE_PHYSICS, ITEM, MODEL } } };
|
||||
ReplicaDecoder::Session session(table);
|
||||
const auto constructed = session.Decode(std::string(reinterpret_cast<const char*>(live), sizeof(live)), 1);
|
||||
ASSERT_TRUE(constructed);
|
||||
EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump();
|
||||
EXPECT_EQ((*constructed)["lot"], 12504);
|
||||
EXPECT_EQ((*constructed)["objectID"], "288300744895909485");
|
||||
EXPECT_EQ((*constructed)["spawner"], "1152921510458437894");
|
||||
const auto config = (*constructed)["config"].dump();
|
||||
EXPECT_NE(config.find("userModelID=9:12504"), std::string::npos) << config;
|
||||
EXPECT_NE(config.find("propertyObjectID=7:1"), std::string::npos) << config;
|
||||
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL RENDER DESTROYABLE ");
|
||||
const auto& position = (*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"];
|
||||
EXPECT_FLOAT_EQ(position[0].get<float>(), -12.0f);
|
||||
EXPECT_NEAR(position[1].get<float>(), 432.96f, 0.001f);
|
||||
EXPECT_NEAR(position[2].get<float>(), -49.6f, 0.001f);
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "12504");
|
||||
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["behaviors"], 0);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
63
tests/dWebTests/world3d-property.test.mjs
Normal file
63
tests/dWebTests/world3d-property.test.mjs
Normal 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, '<'));
|
||||
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');
|
||||
Reference in New Issue
Block a user