feat(scum): add map trajectory projection

This commit is contained in:
npc0-hue
2026-07-28 17:13:48 +08:00
parent 7f64765c1c
commit 5b15bd50cb
33 changed files with 1073 additions and 49 deletions
+316
View File
@@ -0,0 +1,316 @@
package service
import (
"math"
"sort"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
const maxMapTrajectoryWindow = 24 * time.Hour
const maxMapTrajectoryEntities = 20
const maxMapTrajectoryPointsPerEntity = 600
func (svc *CoreService) GetGameMapTrajectoriesForSession(sessionID string, query domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error) {
query = domain.CopyGameMapTrajectoryQuery(query)
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
return domain.GameMapTrajectoryView{}, err
}
declaration, err := svc.mapTrajectoryDeclaration(query.ServerInstanceID)
if err != nil {
return domain.GameMapTrajectoryView{}, err
}
if declaration == nil {
return domain.GameMapTrajectoryView{Status: "missing-map", Reason: "此插件未声明兼容的 SCUM 地图、坐标转换或保留策略。"}, nil
}
if query.To.IsZero() {
query.To = svc.now()
}
if query.From.IsZero() {
query.From = query.To.Add(-time.Hour)
}
if query.From.After(query.To) || query.To.Sub(query.From) > maxMapTrajectoryWindow || len(query.PlayerRecordIDs) > maxMapTrajectoryEntities || len(query.VehicleIDs) > maxMapTrajectoryEntities {
return domain.GameMapTrajectoryView{}, validationError("map trajectory window or entity filters are invalid")
}
if err := svc.pruneGameMapTrajectories(query.ServerInstanceID); err != nil {
return domain.GameMapTrajectoryView{}, err
}
view := domain.GameMapTrajectoryView{Status: "ready", Map: *declaration, From: query.From, To: query.To, Players: []domain.GameMapTrajectoryEntity{}, Vehicles: []domain.GameMapTrajectoryEntity{}, RideSegments: []domain.GameMapTrajectorySegment{}}
for _, id := range uniqueBoundedIDs(query.PlayerRecordIDs) {
player, getErr := svc.store.GamePlayers().Get(id)
if getErr == repo.ErrNotFound || player.ServerInstanceID != query.ServerInstanceID {
continue
}
if getErr != nil {
return domain.GameMapTrajectoryView{}, getErr
}
points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityPlayer, EntityID: player.GamePlayerID, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity})
if listErr != nil {
return domain.GameMapTrajectoryView{}, listErr
}
view.Players = append(view.Players, mapTrajectoryEntity(domain.GameMapTrackEntityPlayer, player.GamePlayerID, player.ID, player.DisplayName, points))
}
for _, id := range uniqueBoundedIDs(query.VehicleIDs) {
points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityVehicle, EntityID: id, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity})
if listErr != nil {
return domain.GameMapTrajectoryView{}, listErr
}
if len(points) > 0 {
view.Vehicles = append(view.Vehicles, mapTrajectoryEntity(domain.GameMapTrackEntityVehicle, id, "", id, points))
}
}
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, OccurredAfter: query.From, OccurredBefore: query.To, Limit: 200})
if err != nil {
return domain.GameMapTrajectoryView{}, err
}
playerSet, vehicleSet := idSet(query.PlayerRecordIDs), idSet(query.VehicleIDs)
for _, segment := range segments {
if (len(playerSet) == 0 || playerSet[segment.GamePlayerRecordID]) && (len(vehicleSet) == 0 || vehicleSet[segment.VehicleID]) {
view.RideSegments = append(view.RideSegments, domain.GameMapTrajectorySegment{GamePlayerRecordID: segment.GamePlayerRecordID, VehicleID: segment.VehicleID, StartedAt: segment.StartedAt, EndedAt: segment.EndedAt})
}
}
if len(view.Players) == 0 && len(view.Vehicles) == 0 {
view.Status = "empty"
view.Reason = "所选时间窗内没有已采集且兼容当前地图版本的轨迹。"
}
return domain.CopyGameMapTrajectoryView(view), nil
}
func (svc *CoreService) projectGameMapTrajectoryEvents(batch domain.LogBatchIngest) error {
for _, entry := range batch.Entries {
if err := svc.projectGameMapTrajectoryEvent(batch, entry); err != nil {
return err
}
}
return svc.pruneGameMapTrajectories(batch.ServerInstanceID)
}
func (svc *CoreService) projectGameMapTrajectoryEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error {
fields := entry.Fields
if fields == nil {
return nil
}
eventType := strings.TrimSpace(fields["eventType"])
if eventType != "player.position" && eventType != "vehicle.position" && eventType != "player.vehicle.enter" && eventType != "player.vehicle.leave" {
return nil
}
declaration, err := svc.mapTrajectoryDeclaration(batch.ServerInstanceID)
if err != nil || declaration == nil {
return err
}
if strings.TrimSpace(fields["mapId"]) != declaration.MapID || strings.TrimSpace(fields["mapVersion"]) != declaration.MapVersion {
return nil
}
source := strings.TrimSpace(fields["source"])
if source != "companion" && source != "log-projection" {
return nil
}
occurred := mapEventTime(entry, fields, svc.now())
collected := mapCollectedTime(fields, svc.now())
eventID := "map-event-" + entryID(batch.LogStreamID, entry.Seq)
if eventType == "player.position" || eventType == "vehicle.position" {
return svc.projectGameMapPosition(batch.ServerInstanceID, eventID, eventType, fields, occurred, collected, source, *declaration)
}
return svc.projectGameMapVehicleTransition(batch.ServerInstanceID, eventID, eventType, fields, occurred, *declaration)
}
func (svc *CoreService) projectGameMapPosition(serverID, eventID, eventType string, fields map[string]string, occurred, collected time.Time, source string, declaration domain.GameMapTrajectoryDeclaration) error {
entityKind, entityID := domain.GameMapTrackEntityVehicle, strings.TrimSpace(fields["vehicleId"])
playerRecordID := ""
if eventType == "player.position" {
entityKind, entityID = domain.GameMapTrackEntityPlayer, strings.TrimSpace(fields["playerId"])
playerRecordID = gamePlayerRecordID(serverID, entityID)
}
if !mapTrajectoryID(entityID) {
return nil
}
x, okX := mapNumber(fields["worldX"])
y, okY := mapNumber(fields["worldY"])
if !okX || !okY {
return nil
}
mapX, mapY, ok := projectMapPoint(declaration, x, y)
if !ok {
return nil
}
id := "map-point-" + fingerprintID(serverID, eventID)
if _, err := svc.store.GameMapTrackPoints().Get(id); err == nil {
return nil
} else if err != repo.ErrNotFound {
return err
}
existing, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID})
if err != nil {
return err
}
if compressedMapPoint(existing, occurred, mapX, mapY, declaration) {
return nil
}
return svc.store.GameMapTrackPoints().Create(domain.GameMapTrackPoint{ID: id, EventID: eventID, ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID, GamePlayerRecordID: playerRecordID, MapX: mapX, MapY: mapY, Source: source, OccurredAt: occurred, CollectedAt: collected, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)})
}
func (svc *CoreService) projectGameMapVehicleTransition(serverID, eventID, eventType string, fields map[string]string, occurred time.Time, declaration domain.GameMapTrajectoryDeclaration) error {
playerID, vehicleID := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["vehicleId"])
if !mapTrajectoryID(playerID) || !mapTrajectoryID(vehicleID) {
return nil
}
playerRecordID := gamePlayerRecordID(serverID, playerID)
id := "map-ride-" + fingerprintID(serverID, eventID)
if _, err := svc.store.GamePlayerVehicleSegments().Get(id); err == nil {
return nil
} else if err != repo.ErrNotFound {
return err
}
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, MapID: declaration.MapID, MapVersion: declaration.MapVersion})
if err != nil {
return err
}
for _, segment := range segments {
if segment.EndedAt.IsZero() && !segment.StartedAt.After(occurred) && (eventType == "player.vehicle.enter" || segment.VehicleID == vehicleID) {
segment.EndedAt = occurred
if err := svc.store.GamePlayerVehicleSegments().Update(segment); err != nil {
return err
}
}
}
if eventType == "player.vehicle.leave" {
return nil
}
return svc.store.GamePlayerVehicleSegments().Create(domain.GamePlayerVehicleSegment{ID: id, EventID: eventID, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: playerID, VehicleID: vehicleID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, StartedAt: occurred, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)})
}
func (svc *CoreService) pruneGameMapTrajectories(serverID string) error {
now := svc.now()
points, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID})
if err != nil {
return err
}
for _, point := range points {
if !point.ExpiresAt.After(now) {
if err := svc.store.GameMapTrackPoints().Delete(point.ID); err != nil {
return err
}
}
}
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID})
if err != nil {
return err
}
for _, segment := range segments {
if !segment.ExpiresAt.After(now) {
if err := svc.store.GamePlayerVehicleSegments().Delete(segment.ID); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) mapTrajectoryDeclaration(serverID string) (*domain.GameMapTrajectoryDeclaration, error) {
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return nil, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return nil, err
}
if plugin.MapTrajectories == nil {
return nil, nil
}
declaration := domain.CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
if !validMapTrajectoryDeclaration(declaration) {
return nil, validationError("plugin map trajectory declaration is invalid")
}
return &declaration, nil
}
func validMapTrajectoryDeclaration(v domain.GameMapTrajectoryDeclaration) bool {
return v.MapID != "" && v.MapVersion != "" && v.WorldMaxX > v.WorldMinX && v.WorldMaxY > v.WorldMinY && v.ImageWidth > 0 && v.ImageHeight > 0 && v.Precision > 0 && v.SampleDistance >= 0 && v.SampleIntervalSeconds >= 0 && v.RetentionSeconds > 0 && v.RetentionSeconds <= 31*24*60*60
}
func projectMapPoint(v domain.GameMapTrajectoryDeclaration, x, y float64) (float64, float64, bool) {
if !finite(x) || !finite(y) || x < v.WorldMinX || x > v.WorldMaxX || y < v.WorldMinY || y > v.WorldMaxY {
return 0, 0, false
}
return roundMap((x-v.WorldMinX)/(v.WorldMaxX-v.WorldMinX)*1000, v.Precision), roundMap((y-v.WorldMinY)/(v.WorldMaxY-v.WorldMinY)*1000, v.Precision), true
}
func compressedMapPoint(points []domain.GameMapTrackPoint, occurred time.Time, x, y float64, declaration domain.GameMapTrajectoryDeclaration) bool {
var prior *domain.GameMapTrackPoint
for i := range points {
if !points[i].OccurredAt.After(occurred) && (prior == nil || points[i].OccurredAt.After(prior.OccurredAt)) {
prior = &points[i]
}
}
if prior == nil {
return false
}
seconds := occurred.Sub(prior.OccurredAt).Seconds()
distance := math.Hypot(x-prior.MapX, y-prior.MapY)
return seconds < float64(declaration.SampleIntervalSeconds) && distance < declaration.SampleDistance
}
func mapTrajectoryEntity(kind domain.GameMapTrackEntityKind, entityID, playerID, label string, points []domain.GameMapTrackPoint) domain.GameMapTrajectoryEntity {
sort.Slice(points, func(i, j int) bool { return points[i].OccurredAt.Before(points[j].OccurredAt) })
sources := map[string]struct{}{}
var collected time.Time
for _, point := range points {
sources[point.Source] = struct{}{}
if point.CollectedAt.After(collected) {
collected = point.CollectedAt
}
}
values := make([]string, 0, len(sources))
for source := range sources {
values = append(values, source)
}
sort.Strings(values)
return domain.GameMapTrajectoryEntity{Kind: kind, EntityID: entityID, GamePlayerRecordID: playerID, Label: label, Points: points, CollectedAt: collected, Sources: values}
}
func mapEventTime(entry domain.LogEntry, fields map[string]string, fallback time.Time) time.Time {
if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["occurredAt"])); err == nil {
return value
}
if !entry.Timestamp.IsZero() {
return entry.Timestamp
}
return fallback
}
func mapCollectedTime(fields map[string]string, fallback time.Time) time.Time {
if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["collectedAt"])); err == nil {
return value
}
return fallback
}
func mapNumber(value string) (float64, bool) {
number, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return number, err == nil && finite(number)
}
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func roundMap(value, precision float64) float64 { return math.Round(value/precision) * precision }
func mapTrajectoryID(value string) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > 96 {
return false
}
for _, char := range value {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_' || char == '.' || char == ':') {
return false
}
}
return true
}
func uniqueBoundedIDs(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
if mapTrajectoryID(value) && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}
func idSet(values []string) map[string]bool {
result := map[string]bool{}
for _, value := range uniqueBoundedIDs(values) {
result[value] = true
}
return result
}
@@ -0,0 +1,95 @@
package service
import (
"errors"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMMapTrajectoryProjectionFiltersAndRetention(t *testing.T) {
svc, runToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
enableSCUMMapTrajectory(t, svc)
registered, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Map Owner", Email: "map-owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatal(err)
}
operator := registered.SessionID
instance, _ := svc.store.ServerInstances().Get("server-1")
instance.OwnerUserID = registered.User.ID
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatal(err)
}
base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
entries := []domain.LogEntry{
{Seq: 1, Timestamp: base, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-map", "playerName": "Moon", "sessionId": "map", "outcome": "accepted"}},
mapEntry(2, base, "player.position", "steam-map", "", "0", "0"), mapEntry(3, base.Add(time.Second), "player.position", "steam-map", "", "1", "1"),
mapEntry(4, base.Add(-time.Minute), "player.position", "steam-map", "", "-100", "-100"), mapEntry(5, base.Add(2*time.Minute), "vehicle.position", "", "jeep-1", "200", "300"),
mapEntry(6, base.Add(3*time.Minute), "player.vehicle.enter", "steam-map", "jeep-1", "", ""), mapEntry(7, base.Add(4*time.Minute), "player.vehicle.enter", "steam-map", "truck-2", "", ""),
}
if _, err := svc.IngestLogBatch(gamePlayerBatch(t, runToken, 1, entries)); err != nil {
t.Fatal(err)
}
players, _ := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"})
if len(players) != 1 {
t.Fatalf("expected player projection, got %+v", players)
}
view, err := svc.GetGameMapTrajectoriesForSession(operator, domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1", From: base.Add(-2 * time.Hour), To: base.Add(time.Hour), PlayerRecordIDs: []string{players[0].ID}, VehicleIDs: []string{"jeep-1"}})
if err != nil {
t.Fatal(err)
}
if view.Status != "ready" || len(view.Players) != 1 || len(view.Players[0].Points) != 2 || view.Players[0].Points[0].MapX >= view.Players[0].Points[1].MapX || view.Players[0].Points[1].MapX != 500 {
t.Fatalf("expected sorted converted and sampled player trail, got %+v", view.Players)
}
if len(view.Vehicles) != 1 || view.Vehicles[0].Points[0].MapX != 700 || len(view.RideSegments) != 1 || view.RideSegments[0].VehicleID != "jeep-1" || view.RideSegments[0].EndedAt.IsZero() {
t.Fatalf("expected filtered vehicle and closed cross-vehicle segment, got vehicles=%+v segments=%+v", view.Vehicles, view.RideSegments)
}
if err := svc.store.GameMapTrackPoints().Create(domain.GameMapTrackPoint{ID: "cross-server", EventID: "cross", ServerInstanceID: "server-2", MapID: "scum-island", MapVersion: "0.9", EntityKind: domain.GameMapTrackEntityVehicle, EntityID: "cross-vehicle", MapX: 1, MapY: 1, OccurredAt: base, CollectedAt: base, ExpiresAt: base.Add(time.Hour)}); err != nil {
t.Fatal(err)
}
isolation, err := svc.GetGameMapTrajectoriesForSession(operator, domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1", From: base.Add(-time.Hour), To: base.Add(time.Hour), VehicleIDs: []string{"cross-vehicle"}})
if err != nil || len(isolation.Vehicles) != 0 {
t.Fatalf("cross-server vehicle leaked: %+v err=%v", isolation, err)
}
if _, err := svc.GetGameMapTrajectoriesForSession("", domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1"}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected unauthorized denial, got %v", err)
}
points, _ := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: "server-1"})
points[0].ExpiresAt = base.Add(-time.Second)
if err := svc.store.GameMapTrackPoints().Delete(points[0].ID); err != nil {
t.Fatal(err)
}
if err := svc.store.GameMapTrackPoints().Create(points[0]); err != nil {
t.Fatal(err)
}
svc.now = func() time.Time { return base }
if err := svc.pruneGameMapTrajectories("server-1"); err != nil {
t.Fatal(err)
}
if _, err := svc.store.GameMapTrackPoints().Get(points[0].ID); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("expired map point retained: %v", err)
}
}
func mapEntry(seq uint64, at time.Time, eventType, playerID, vehicleID, x, y string) domain.LogEntry {
fields := map[string]string{"eventType": eventType, "occurredAt": at.Format(time.RFC3339), "collectedAt": at.Add(time.Second).Format(time.RFC3339), "source": "companion", "mapId": "scum-island", "mapVersion": "0.9", "playerId": playerID, "vehicleId": vehicleID}
if x != "" {
fields["worldX"] = x
fields["worldY"] = y
}
return domain.LogEntry{Seq: seq, Timestamp: at, Line: eventType, Fields: fields}
}
func enableSCUMMapTrajectory(t *testing.T, svc *CoreService) {
t.Helper()
plugin, err := svc.store.GamePlugins().Get("server.scum")
if err != nil {
t.Fatal(err)
}
plugin.MapTrajectories = &domain.GameMapTrajectoryDeclaration{MapID: "scum-island", MapVersion: "0.9", WorldMinX: -500, WorldMinY: -500, WorldMaxX: 500, WorldMaxY: 500, ImageWidth: 2048, ImageHeight: 2048, Precision: 1, SampleDistance: 4, SampleIntervalSeconds: 20, RetentionSeconds: 3600}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
}
+6
View File
@@ -35,6 +35,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
return domain.LogBatchIngestResult{
Accepted: true,
LogStreamID: batch.LogStreamID,
@@ -70,6 +73,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
return domain.LogBatchIngestResult{
Accepted: true,
LogStreamID: batch.LogStreamID,
+3
View File
@@ -205,6 +205,7 @@ type Core interface {
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, error)
GetGameMapTrajectoriesForSession(string, domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error)
GetGamePlayerStateForSession(string, string) (domain.GamePlayerStateSnapshot, error)
RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error)
ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error)
@@ -726,6 +727,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
RemoteAccess: manifest.RemoteAccess,
RuntimeProfiles: manifest.RuntimeProfiles,
GameClientBridge: manifest.GameClientBridge,
MapTrajectories: manifest.MapTrajectories,
Status: domain.GamePluginStatusInstalled,
}
}
@@ -1492,6 +1494,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
RemoteAccess: plugin.RemoteAccess,
RuntimeProfiles: plugin.RuntimeProfiles,
GameClientBridge: plugin.GameClientBridge,
MapTrajectories: plugin.MapTrajectories,
ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status,
Source: "platform-registry",