mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
feat(world3d): a property's models in capture and recorded replays
In a capture replay, each model stands where the capture saw it at the playhead (the UGC server's mesh for a brick built model when it has one, a box otherwise), appearing, moving and vanishing with the timeline both ways; the Property tab shows the property data of that moment and the models standing, and behavior messages are ticks on the timeline. In a replay of recorded positions on one property instance, the models placed now are drawn and labelled as now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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