From 601a27343f63b3712c22794aa41c0f7b8127d444 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 03:06:15 -0500 Subject: [PATCH] feat(dashboard): capture worlds route, every zone's movement in positions /api/inspector/sessions/:id/worlds lists the captured characters' moves between world servers with the zone's name from the locale; /positions also returns them, takes ?zone=all and names each track's character and zone. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/CaptureReplay.cpp | 43 +++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/dDashboardServer/routes/CaptureReplay.cpp b/dDashboardServer/routes/CaptureReplay.cpp index 0ee9101d8..e0b3a7acb 100644 --- a/dDashboardServer/routes/CaptureReplay.cpp +++ b/dDashboardServer/routes/CaptureReplay.cpp @@ -15,6 +15,7 @@ #include "Database.h" #include "DashboardRoutes.h" #include "Game.h" +#include "GameText.h" #include "LiveWorld.h" #include "Logger.h" #include "master/MessageCapture.h" @@ -257,6 +258,24 @@ namespace { return &g_Cache; } + std::string CharacterName(LWOOBJID id) { + const auto info = id ? Database::Get()->GetCharacterInfo(id) : std::nullopt; + return info ? info->name : "Character " + std::to_string(id); + } + + // Each captured character's moves between world servers (CaptureTools::Worlds), with the zone's name from the locale + json WorldsJson(const Cached& cached) { + json out = json::array(); + std::map names; + for (const auto& visit : CaptureTools::Worlds(cached.bundle.records, cached.startUs)) { + if (!names.contains(visit.characterId)) names[visit.characterId] = CharacterName(visit.characterId); + out.push_back({ {"character", std::to_string(visit.characterId)}, {"name", names[visit.characterId]}, {"t", visit.t}, + {"zone", visit.zoneId}, {"zoneName", visit.zoneId ? GameText::ZoneName(visit.zoneId) : std::string{}}, + {"instance", visit.instanceId}, {"clone", visit.cloneId} }); + } + return out; + } + json StoredSessionJson(const Session& s) { if (const auto* live = Live(s.id)) return SessionJson(*live); Capture view; @@ -599,7 +618,7 @@ namespace CaptureReplay { }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/positions", Perm(PERMISSION), - "Where the captured characters moved, in the World 3D replay's shape. Query: ?zone= (default: the first zone with movement)", + "Where the captured characters moved, in the World 3D replay's shape, and which world each was on when (worlds). Query: ?zone=|all (default: the first zone with movement)", [](HTTPReply& reply, const HTTPContext& context) { const auto session = FindSession(context, reply); if (!session) return; @@ -608,18 +627,28 @@ namespace CaptureReplay { const auto tracks = CaptureTools::Tracks(cached->bundle.records, cached->startUs); std::set zones; for (const auto& t : tracks) zones.insert(t.zoneId); + const bool all = QueryValue(context.queryString, "zone") == "all"; auto zone = GeneralUtils::TryParse(QueryValue(context.queryString, "zone")).value_or(zones.empty() ? 0 : *zones.begin()); json players = json::array(); for (const auto& t : tracks) { - if (t.zoneId != zone) continue; - const auto info = t.characterId ? Database::Get()->GetCharacterInfo(t.characterId) : std::nullopt; - players.push_back({ {"id", std::to_string(t.characterId) + ":" + std::to_string(t.instanceId)}, {"name", info ? info->name : "Character " + std::to_string(t.characterId)}, - {"instances", json::array({ t.instanceId })}, {"samples", t.samples} }); + if (!all && t.zoneId != zone) continue; + players.push_back({ {"id", std::to_string(t.characterId) + ":" + std::to_string(t.instanceId)}, {"character", std::to_string(t.characterId)}, + {"name", CharacterName(t.characterId)}, {"zone", t.zoneId}, {"instances", json::array({ t.instanceId })}, {"samples", t.samples} }); } const auto from = cached->startUs / 1000000; const auto to = cached->bundle.records.empty() ? from + 1 : cached->bundle.records.back().header.timeUs / 1000000 + 1; - JsonSuccess(reply, { {"zone", zone}, {"zones", zones}, {"from", from}, {"to", to}, {"players", players}, - {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} }); + JsonSuccess(reply, { {"zone", all ? json(nullptr) : json(zone)}, {"zones", zones}, {"from", from}, {"to", to}, {"players", players}, + {"worlds", WorldsJson(*cached)}, {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} }); + }); + + 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) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto* cached = Load(*session, reply); + if (!cached) return; + JsonSuccess(reply, { {"worlds", WorldsJson(*cached)} }); }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/bundle", Perm(PERMISSION),