diff --git a/dDashboardServer/static/js/world3d-core.js b/dDashboardServer/static/js/world3d-core.js
index a3f899610..301d19cd7 100644
--- a/dDashboardServer/static/js/world3d-core.js
+++ b/dDashboardServer/static/js/world3d-core.js
@@ -1,6 +1,7 @@
/**
* The 3D world view's bookkeeping, without three.js so it can be tested with node: smoothing live positions (worlds
- * report about once a second), finding where a player was at a moment of a replay, and the heat map timelapse frames.
+ * report about once a second), finding where a player was at a moment of a replay, the heat map timelapse frames, and
+ * following a player from one world to the next (live and in capture playback) with the timeline's world markers.
*/
/**
@@ -183,3 +184,76 @@ export function isPlaceholderTerrain(minY, maxY, objectMiddleY, flat = 1, away =
if (!(maxY - minY < flat) || objectMiddleY === null || objectMiddleY === undefined) return false;
return Math.abs(objectMiddleY - (minY + maxY) / 2) > away;
}
+
+// ---- following a player across worlds ----
+
+/**
+ * Live view: where the followed player went, from one player_positions report (it lists every world's players).
+ * Returns {zone, instance, clone} when they are reported only in a world the view isn't showing (another zone, or
+ * another instance when one instance is picked; `instance` 0 is all of them), else null: while the old world still
+ * reports them too, or before the new one does, the view stays. Character select (zone 0) has nothing to draw.
+ */
+export function followedMove(followId, players, zone, instance) {
+ if (!followId) return null;
+ let here = false, elsewhere = null;
+ for (const p of players || []) {
+ if (p.id !== followId) continue;
+ if (p.zone === zone && (!instance || p.instance === instance)) here = true;
+ else if (p.zone) elsewhere = p;
+ }
+ return !here && elsewhere ? { zone: elsewhere.zone, instance: elsewhere.instance, clone: elsewhere.clone } : null;
+}
+
+/**
+ * Capture playback: the world a character was on at time t, from a capture's world moves ([{character, t, zone,
+ * instance, clone, ...}] in time order, the positions route's `worlds`). Character select (zone 0) in between keeps the
+ * world before it; before their first world, null.
+ */
+export function worldAt(worlds, character, t) {
+ let found = null;
+ for (const w of worlds || []) {
+ if (w.t > t) break;
+ if (w.character === character && w.zone) found = w;
+ }
+ return found;
+}
+
+/** Capture playback: the world to switch the 3D view to so it shows the followed character at time t, or null. */
+export function captureSwitch(worlds, character, t, zone) {
+ if (!character) return null;
+ const w = worldAt(worlds, character, t);
+ return w && w.zone !== zone ? w : null;
+}
+
+/**
+ * Timeline markers for a capture's world changes: each time a character (every one, or only `character`) reaches a
+ * world other than the one they were last in. Character select in between isn't a change; their first world is one
+ * when the capture saw them before it (logging in from character select). Returns [{t, at (0 to 1 along
+ * `duration` seconds), character, name, zone, zoneName, instance}].
+ */
+export function worldMarkers(worlds, duration, character = null) {
+ const last = new Map(), out = [];
+ for (const w of worlds || []) {
+ if (character && w.character !== character) continue;
+ const seen = last.has(w.character), before = last.get(w.character);
+ if (!w.zone) { if (!seen) last.set(w.character, null); continue; }
+ last.set(w.character, w);
+ if (!seen || (before && before.zone === w.zone && before.instance === w.instance)) continue;
+ out.push({ t: w.t, at: duration > 0 ? Math.max(0, Math.min(1, w.t / duration)) : 0, character: w.character, name: w.name,
+ zone: w.zone, zoneName: w.zoneName, instance: w.instance });
+ }
+ return out;
+}
+
+/**
+ * The markers over a timeline's slider: one tick per world change, labelled with the zone's name (and the character's,
+ * when `names`); clicking one seeks to it (data-t). `esc` escapes text for HTML.
+ */
+export function markersHtml(markers, esc, names = false) {
+ return markers.map((m) => {
+ const zone = m.zoneName || ('Zone ' + m.zone);
+ const title = (m.name ? m.name + ' → ' : '') + zone + ' #' + m.instance;
+ return '';
+ }).join('');
+}
diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt
index 2679e8123..009f9d126 100644
--- a/tests/dWebTests/CMakeLists.txt
+++ b/tests/dWebTests/CMakeLists.txt
@@ -116,4 +116,7 @@ if(NODE_EXECUTABLE)
# The Performance page's flame graph, timeline and phase layouts (static/js/perf-charts.js)
add_test(NAME PerfChartsJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/perf-charts.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/perf-charts.js")
+ # 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")
endif()
diff --git a/tests/dWebTests/world3d-follow.test.mjs b/tests/dWebTests/world3d-follow.test.mjs
new file mode 100644
index 000000000..25fa7f418
--- /dev/null
+++ b/tests/dWebTests/world3d-follow.test.mjs
@@ -0,0 +1,75 @@
+// Following a player across worlds (static/js/world3d-core.js): when the live view and capture playback switch the 3D
+// scene, and the timeline's world change markers.
+// Run by ctest: node world3d-follow.test.mjs
+import { pathToFileURL } from 'node:url';
+
+const [modulePath] = process.argv.slice(2);
+const W = await import(pathToFileURL(modulePath).href);
+let failures = 0;
+const same = (actual, expected, what) => {
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ failures++;
+ console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
+ }
+};
+
+// ---- live: one player_positions report lists every world's players ----
+const at = (id, zone, instance, clone = 0) => ({ id, name: 'N' + id, zone, instance, clone, x: 0, y: 0, z: 0 });
+same(W.followedMove('7', [at('7', 1100, 3)], 1100, 0), null, 'still in the zone shown');
+same(W.followedMove('7', [at('7', 1100, 3)], 1100, 3), null, 'still in the instance shown');
+same(W.followedMove('7', [at('7', 1100, 4)], 1100, 0), null, 'another instance while all instances are shown');
+same(W.followedMove('7', [at('7', 1100, 4, 2)], 1100, 3), { zone: 1100, instance: 4, clone: 2 }, 'another instance of the picked one');
+same(W.followedMove('7', [at('8', 1100, 3), at('7', 1200, 5)], 1100, 0), { zone: 1200, instance: 5, clone: 0 }, 'moved to another zone');
+same(W.followedMove('7', [at('7', 1100, 3), at('7', 1200, 5)], 1100, 3), null, 'the old world still reports them: wait');
+same(W.followedMove('7', [], 1100, 3), null, 'between worlds (nobody reports them): wait');
+same(W.followedMove('7', [at('7', 0, 1)], 1100, 3), null, 'character select has nothing to draw');
+same(W.followedMove(null, [at('7', 1200, 5)], 1100, 3), null, 'nobody followed');
+same(W.followedMove('7', undefined, 1100, 3), null, 'no report');
+
+// ---- capture playback: the positions route's worlds ----
+const worlds = [
+ { character: 'a', name: 'Alpha', t: 0, zone: 0, zoneName: '', instance: 1, clone: 0 },
+ { character: 'a', name: 'Alpha', t: 5, zone: 1000, zoneName: 'First', instance: 2, clone: 0 },
+ { character: 'b', name: 'Beta', t: 6, zone: 1100, zoneName: 'Second', instance: 3, clone: 0 },
+ { character: 'a', name: 'Alpha', t: 20, zone: 1100, zoneName: 'Second', instance: 3, clone: 0 },
+ { character: 'a', name: 'Alpha', t: 30, zone: 0, zoneName: '', instance: 1, clone: 0 },
+ { character: 'a', name: 'Alpha', t: 40, zone: 1100, zoneName: 'Second', instance: 3, clone: 0 },
+ { character: 'a', name: 'Alpha', t: 50, zone: 1100, zoneName: 'Second', instance: 9, clone: 0 }
+];
+same(W.worldAt(worlds, 'a', 1), null, 'in character select before any world');
+same(W.worldAt(worlds, 'a', 5).zone, 1000, 'reached the first world');
+same(W.worldAt(worlds, 'a', 19.9).zone, 1000, 'still there just before the move');
+same(W.worldAt(worlds, 'a', 20).zone, 1100, 'moved at the transfer');
+same(W.worldAt(worlds, 'a', 35).t, 20, 'character select in between keeps the world before it');
+same(W.worldAt(worlds, 'a', 55).instance, 9, 'another instance');
+same(W.worldAt(worlds, 'b', 5), null, 'another character not there yet');
+
+same(W.captureSwitch(worlds, 'a', 10, 1000), null, 'already showing their world');
+same(W.captureSwitch(worlds, 'a', 25, 1000).zone, 1100, 'switch forward at the transfer');
+same(W.captureSwitch(worlds, 'a', 10, 1100).zone, 1000, 'switch back when scrubbing back');
+same(W.captureSwitch(worlds, 'a', 55, 1100), null, 'another instance of the zone shown: players of every instance are drawn');
+same(W.captureSwitch(worlds, 'a', 1, 1100), null, 'before their first world the view stays');
+same(W.captureSwitch(worlds, null, 25, 1000), null, 'nobody followed');
+
+const markers = W.worldMarkers(worlds, 100);
+same(markers.map((m) => [m.character, m.t, m.zone, m.instance]), [['a', 5, 1000, 2], ['a', 20, 1100, 3], ['a', 50, 1100, 9]],
+ 'changes: logging in from character select counts, the first world seen does not, back to the same world after character select does not');
+same(markers[1].at, 0.2, 'placed along the timeline');
+same(markers[1].zoneName, 'Second', 'named from the route (the locale)');
+same(W.worldMarkers(worlds, 100, 'b'), [], 'only the followed character');
+same(W.worldMarkers(worlds, 10).map((m) => m.at), [0.5, 1, 1], 'clamped to the timeline');
+same(W.worldMarkers([], 10), [], 'nothing captured');
+
+const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+const html = W.markersHtml([{ t: 20, at: 0.2, name: 'A', zone: 1100, zoneName: 'Second', instance: 3 }], esc, true);
+same(html.includes('left:20.000%'), true, 'marker position');
+same(html.includes('data-t="20"'), true, 'seeks to its time');
+same(html.includes('A<b>: Second'), true, 'labelled with the character and zone, escaped');
+same(html.includes('title="A<b> → Second #3"'), true, 'title with the instance');
+same(W.markersHtml([{ t: 1, at: 0, zone: 1200, zoneName: '', instance: 1 }], esc).includes('Zone 1200'), true, 'a zone without a name');
+
+if (failures) {
+ console.error(failures + ' failed');
+ process.exit(1);
+}
+console.log('world3d follow: ok');