feat(scum): add map trajectory projection
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// serverGameMapTrajectories godoc
|
||||
// @Summary Read bounded SCUM map trajectories
|
||||
// @Description Returns only authorized safe map projections, never raw logs, world coordinates, paths, or Companion connection material.
|
||||
// @Tags game-map-trajectories
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param from query string false "RFC3339 window start"
|
||||
// @Param to query string false "RFC3339 window end"
|
||||
// @Param playerId query string false "Comma-separated game player record IDs"
|
||||
// @Param vehicleId query string false "Comma-separated vehicle IDs"
|
||||
// @Success 200 {object} dto.GameMapTrajectoryResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/game-map-trajectories [get]
|
||||
func (h *coreHandlers) serverGameMapTrajectories(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
from, err := mapTrajectoryTime(r.URL.Query().Get("from"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid map trajectory from time", nil)
|
||||
return
|
||||
}
|
||||
to, err := mapTrajectoryTime(r.URL.Query().Get("to"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid map trajectory to time", nil)
|
||||
return
|
||||
}
|
||||
value, err := h.core.GetGameMapTrajectoriesForSession(bearerToken(r), domain.GameMapTrajectoryQuery{ServerInstanceID: r.PathValue("id"), From: from, To: to, PlayerRecordIDs: mapTrajectoryIDs(r.URL.Query().Get("playerId")), VehicleIDs: mapTrajectoryIDs(r.URL.Query().Get("vehicleId"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameMapTrajectoryFromDomain(value))
|
||||
}
|
||||
func mapTrajectoryTime(value string) (time.Time, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.Parse(time.RFC3339, value)
|
||||
}
|
||||
func mapTrajectoryIDs(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
result := []string{}
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -98,6 +98,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches", h.serverGamePlayerStatePatches)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve", h.serverGamePlayerStatePatchApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-map-trajectories", h.serverGameMapTrajectories)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts", h.serverGameGiftCatalogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/publish", h.serverGameGiftCatalogPublish)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const SCUMMapTrajectoryMapID = "scum-island"
|
||||
|
||||
// GameMapTrajectoryDeclaration is plugin-owned metadata used to safely project world coordinates.
|
||||
type GameMapTrajectoryDeclaration struct {
|
||||
MapID, MapVersion string
|
||||
WorldMinX, WorldMinY, WorldMaxX, WorldMaxY float64
|
||||
ImageWidth, ImageHeight, Precision, SampleDistance float64
|
||||
SampleIntervalSeconds, RetentionSeconds int
|
||||
}
|
||||
|
||||
type GameMapTrackEntityKind string
|
||||
|
||||
const (
|
||||
GameMapTrackEntityPlayer GameMapTrackEntityKind = "player"
|
||||
GameMapTrackEntityVehicle GameMapTrackEntityKind = "vehicle"
|
||||
)
|
||||
|
||||
type GameMapTrackPoint struct {
|
||||
ID, EventID, ServerInstanceID, MapID, MapVersion, EntityID, GamePlayerRecordID, Source string
|
||||
EntityKind GameMapTrackEntityKind
|
||||
MapX, MapY float64
|
||||
OccurredAt, CollectedAt, ExpiresAt time.Time
|
||||
}
|
||||
type GameMapTrackPointFilter struct {
|
||||
ServerInstanceID, MapID, MapVersion, EntityID string
|
||||
EntityKind GameMapTrackEntityKind
|
||||
OccurredAfter, OccurredBefore time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GamePlayerVehicleSegment struct {
|
||||
ID, EventID, ServerInstanceID, GamePlayerRecordID, GamePlayerID, VehicleID, MapID, MapVersion string
|
||||
StartedAt, EndedAt, ExpiresAt time.Time
|
||||
}
|
||||
type GamePlayerVehicleSegmentFilter struct {
|
||||
ServerInstanceID, GamePlayerRecordID, VehicleID, MapID, MapVersion string
|
||||
OccurredAfter, OccurredBefore time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GameMapTrajectoryQuery struct {
|
||||
ServerInstanceID string
|
||||
From, To time.Time
|
||||
PlayerRecordIDs, VehicleIDs []string
|
||||
}
|
||||
type GameMapTrajectoryEntity struct {
|
||||
Kind GameMapTrackEntityKind
|
||||
EntityID, GamePlayerRecordID, Label string
|
||||
Points []GameMapTrackPoint
|
||||
CollectedAt time.Time
|
||||
Sources []string
|
||||
}
|
||||
type GameMapTrajectorySegment struct {
|
||||
GamePlayerRecordID, VehicleID string
|
||||
StartedAt, EndedAt time.Time
|
||||
}
|
||||
type GameMapTrajectoryView struct {
|
||||
Status, Reason string
|
||||
Map GameMapTrajectoryDeclaration
|
||||
From, To time.Time
|
||||
Players, Vehicles []GameMapTrajectoryEntity
|
||||
RideSegments []GameMapTrajectorySegment
|
||||
}
|
||||
|
||||
func CopyGameMapTrajectoryDeclaration(v GameMapTrajectoryDeclaration) GameMapTrajectoryDeclaration {
|
||||
return v
|
||||
}
|
||||
func CopyGameMapTrackPoint(v GameMapTrackPoint) GameMapTrackPoint { return v }
|
||||
func CopyGameMapTrackPoints(v []GameMapTrackPoint) []GameMapTrackPoint {
|
||||
out := make([]GameMapTrackPoint, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
}
|
||||
func CopyGamePlayerVehicleSegment(v GamePlayerVehicleSegment) GamePlayerVehicleSegment { return v }
|
||||
func CopyGamePlayerVehicleSegments(v []GamePlayerVehicleSegment) []GamePlayerVehicleSegment {
|
||||
out := make([]GamePlayerVehicleSegment, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
}
|
||||
func CopyGameMapTrajectoryQuery(v GameMapTrajectoryQuery) GameMapTrajectoryQuery {
|
||||
v.PlayerRecordIDs = CopyStringSlice(v.PlayerRecordIDs)
|
||||
v.VehicleIDs = CopyStringSlice(v.VehicleIDs)
|
||||
return v
|
||||
}
|
||||
func CopyGameMapTrajectoryView(v GameMapTrajectoryView) GameMapTrajectoryView {
|
||||
v.Map = CopyGameMapTrajectoryDeclaration(v.Map)
|
||||
v.Players = append([]GameMapTrajectoryEntity(nil), v.Players...)
|
||||
v.Vehicles = append([]GameMapTrajectoryEntity(nil), v.Vehicles...)
|
||||
for i := range v.Players {
|
||||
v.Players[i].Points = CopyGameMapTrackPoints(v.Players[i].Points)
|
||||
v.Players[i].Sources = CopyStringSlice(v.Players[i].Sources)
|
||||
}
|
||||
for i := range v.Vehicles {
|
||||
v.Vehicles[i].Points = CopyGameMapTrackPoints(v.Vehicles[i].Points)
|
||||
v.Vehicles[i].Sources = CopyStringSlice(v.Vehicles[i].Sources)
|
||||
}
|
||||
v.RideSegments = append([]GameMapTrajectorySegment(nil), v.RideSegments...)
|
||||
return v
|
||||
}
|
||||
@@ -620,6 +620,7 @@ type GamePluginManifest struct {
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
MapTrajectories *GameMapTrajectoryDeclaration
|
||||
}
|
||||
|
||||
type GamePluginManifestRegistration struct {
|
||||
@@ -651,6 +652,7 @@ type GamePlugin struct {
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
MapTrajectories *GameMapTrajectoryDeclaration
|
||||
ValidationViolations []string
|
||||
Status GamePluginStatus
|
||||
}
|
||||
@@ -678,6 +680,7 @@ type PluginMarketplacePlugin struct {
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
MapTrajectories *GameMapTrajectoryDeclaration
|
||||
ValidationViolations []string
|
||||
Status GamePluginStatus
|
||||
Source string
|
||||
@@ -1639,6 +1642,10 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||
if plugin.MapTrajectories != nil {
|
||||
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
|
||||
plugin.MapTrajectories = &value
|
||||
}
|
||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||
return plugin
|
||||
}
|
||||
@@ -1656,6 +1663,10 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla
|
||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||
if plugin.MapTrajectories != nil {
|
||||
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
|
||||
plugin.MapTrajectories = &value
|
||||
}
|
||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||
return plugin
|
||||
}
|
||||
@@ -1690,6 +1701,10 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
|
||||
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
|
||||
if manifest.MapTrajectories != nil {
|
||||
value := CopyGameMapTrajectoryDeclaration(*manifest.MapTrajectories)
|
||||
manifest.MapTrajectories = &value
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameMapTrajectoryPointResponse struct {
|
||||
MapX float64 `json:"mapX"`
|
||||
MapY float64 `json:"mapY"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
type GameMapTrajectoryEntityResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
EntityID string `json:"entityId"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Points []GameMapTrajectoryPointResponse `json:"points"`
|
||||
CollectedAt time.Time `json:"collectedAt,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
}
|
||||
type GameMapTrajectorySegmentResponse struct {
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId"`
|
||||
VehicleID string `json:"vehicleId"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
}
|
||||
type GameMapTrajectoryMapResponse struct {
|
||||
MapID string `json:"mapId"`
|
||||
MapVersion string `json:"mapVersion"`
|
||||
ImageWidth float64 `json:"imageWidth"`
|
||||
ImageHeight float64 `json:"imageHeight"`
|
||||
Precision float64 `json:"precision"`
|
||||
}
|
||||
type GameMapTrajectoryResponse struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Map *GameMapTrajectoryMapResponse `json:"map,omitempty"`
|
||||
From time.Time `json:"from,omitempty"`
|
||||
To time.Time `json:"to,omitempty"`
|
||||
Players []GameMapTrajectoryEntityResponse `json:"players"`
|
||||
Vehicles []GameMapTrajectoryEntityResponse `json:"vehicles"`
|
||||
RideSegments []GameMapTrajectorySegmentResponse `json:"rideSegments"`
|
||||
}
|
||||
|
||||
func GameMapTrajectoryFromDomain(value domain.GameMapTrajectoryView) GameMapTrajectoryResponse {
|
||||
value = domain.CopyGameMapTrajectoryView(value)
|
||||
response := GameMapTrajectoryResponse{Status: value.Status, Reason: value.Reason, From: value.From, To: value.To, Players: mapTrajectoryEntitiesFromDomain(value.Players), Vehicles: mapTrajectoryEntitiesFromDomain(value.Vehicles), RideSegments: make([]GameMapTrajectorySegmentResponse, len(value.RideSegments))}
|
||||
if value.Status != "missing-map" {
|
||||
response.Map = &GameMapTrajectoryMapResponse{MapID: value.Map.MapID, MapVersion: value.Map.MapVersion, ImageWidth: value.Map.ImageWidth, ImageHeight: value.Map.ImageHeight, Precision: value.Map.Precision}
|
||||
}
|
||||
for i, segment := range value.RideSegments {
|
||||
response.RideSegments[i] = GameMapTrajectorySegmentResponse{GamePlayerRecordID: segment.GamePlayerRecordID, VehicleID: segment.VehicleID, StartedAt: segment.StartedAt, EndedAt: segment.EndedAt}
|
||||
}
|
||||
return response
|
||||
}
|
||||
func mapTrajectoryEntitiesFromDomain(values []domain.GameMapTrajectoryEntity) []GameMapTrajectoryEntityResponse {
|
||||
result := make([]GameMapTrajectoryEntityResponse, len(values))
|
||||
for i, value := range values {
|
||||
points := make([]GameMapTrajectoryPointResponse, len(value.Points))
|
||||
for j, point := range value.Points {
|
||||
points[j] = GameMapTrajectoryPointResponse{MapX: point.MapX, MapY: point.MapY, OccurredAt: point.OccurredAt, CollectedAt: point.CollectedAt, Source: point.Source}
|
||||
}
|
||||
result[i] = GameMapTrajectoryEntityResponse{Kind: string(value.Kind), EntityID: value.EntityID, GamePlayerRecordID: value.GamePlayerRecordID, Label: value.Label, Points: points, CollectedAt: value.CollectedAt, Sources: domain.CopyStringSlice(value.Sources)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -321,6 +321,20 @@ type GameClientBridgeManifestBody struct {
|
||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||
}
|
||||
type GameMapTrajectoryDeclarationBody struct {
|
||||
MapID string `json:"mapId"`
|
||||
MapVersion string `json:"mapVersion"`
|
||||
WorldMinX float64 `json:"worldMinX"`
|
||||
WorldMinY float64 `json:"worldMinY"`
|
||||
WorldMaxX float64 `json:"worldMaxX"`
|
||||
WorldMaxY float64 `json:"worldMaxY"`
|
||||
ImageWidth float64 `json:"imageWidth"`
|
||||
ImageHeight float64 `json:"imageHeight"`
|
||||
Precision float64 `json:"precision"`
|
||||
SampleDistance float64 `json:"sampleDistance"`
|
||||
SampleIntervalSeconds int `json:"sampleIntervalSeconds"`
|
||||
RetentionSeconds int `json:"retentionSeconds"`
|
||||
}
|
||||
|
||||
type GamePluginManifestBody struct {
|
||||
ID string `json:"id"`
|
||||
@@ -341,6 +355,7 @@ type GamePluginManifestBody struct {
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestRegistrationRequest struct {
|
||||
@@ -372,6 +387,7 @@ type GamePluginCreateRequest struct {
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -399,6 +415,7 @@ type GamePluginResponse struct {
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
}
|
||||
@@ -430,6 +447,7 @@ type MarketplacePluginResponse struct {
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
Source string `json:"source"`
|
||||
@@ -974,10 +992,25 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
||||
GameClientBridge: request.Manifest.GameClientBridge.ToDomain(),
|
||||
MapTrajectories: mapTrajectoryDeclarationToDomain(request.Manifest.MapTrajectories),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mapTrajectoryDeclarationToDomain(value *GameMapTrajectoryDeclarationBody) *domain.GameMapTrajectoryDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := domain.GameMapTrajectoryDeclaration{MapID: value.MapID, MapVersion: value.MapVersion, WorldMinX: value.WorldMinX, WorldMinY: value.WorldMinY, WorldMaxX: value.WorldMaxX, WorldMaxY: value.WorldMaxY, ImageWidth: value.ImageWidth, ImageHeight: value.ImageHeight, Precision: value.Precision, SampleDistance: value.SampleDistance, SampleIntervalSeconds: value.SampleIntervalSeconds, RetentionSeconds: value.RetentionSeconds}
|
||||
return &result
|
||||
}
|
||||
func mapTrajectoryDeclarationFromDomain(value *domain.GameMapTrajectoryDeclaration) *GameMapTrajectoryDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &GameMapTrajectoryDeclarationBody{MapID: value.MapID, MapVersion: value.MapVersion, WorldMinX: value.WorldMinX, WorldMinY: value.WorldMinY, WorldMaxX: value.WorldMaxX, WorldMaxY: value.WorldMaxY, ImageWidth: value.ImageWidth, ImageHeight: value.ImageHeight, Precision: value.Precision, SampleDistance: value.SampleDistance, SampleIntervalSeconds: value.SampleIntervalSeconds, RetentionSeconds: value.RetentionSeconds}
|
||||
}
|
||||
|
||||
func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorkspace {
|
||||
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey}
|
||||
for _, item := range body.Directories {
|
||||
@@ -1119,6 +1152,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
||||
RemoteAccess: request.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
|
||||
GameClientBridge: request.GameClientBridge.ToDomain(),
|
||||
MapTrajectories: mapTrajectoryDeclarationToDomain(request.MapTrajectories),
|
||||
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
||||
}
|
||||
}
|
||||
@@ -1367,6 +1401,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||
MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
}
|
||||
@@ -1458,6 +1493,7 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||
MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: plugin.Source,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GameMapTrackPoint is the safe, map-normalized and retention-bounded trajectory table shape.
|
||||
type GameMapTrackPoint struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
EventID string `json:"eventId" db:"event_id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
MapID string `json:"mapId" db:"map_id"`
|
||||
MapVersion string `json:"mapVersion" db:"map_version"`
|
||||
EntityKind string `json:"entityKind" db:"entity_kind"`
|
||||
EntityID string `json:"entityId" db:"entity_id"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId,omitempty" db:"game_player_record_id"`
|
||||
MapX float64 `json:"mapX" db:"map_x"`
|
||||
MapY float64 `json:"mapY" db:"map_y"`
|
||||
Source string `json:"source" db:"source"`
|
||||
OccurredAt time.Time `json:"occurredAt" db:"occurred_at"`
|
||||
CollectedAt time.Time `json:"collectedAt" db:"collected_at"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
}
|
||||
|
||||
func (GameMapTrackPoint) TableName() string { return "game_map_track_points" }
|
||||
|
||||
// GamePlayerVehicleSegment is a server-local, typed ride association; it contains no vehicle storage details.
|
||||
type GamePlayerVehicleSegment struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
EventID string `json:"eventId" db:"event_id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"`
|
||||
GamePlayerID string `json:"gamePlayerId" db:"game_player_id"`
|
||||
VehicleID string `json:"vehicleId" db:"vehicle_id"`
|
||||
MapID string `json:"mapId" db:"map_id"`
|
||||
MapVersion string `json:"mapVersion" db:"map_version"`
|
||||
StartedAt time.Time `json:"startedAt" db:"started_at"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty" db:"ended_at"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
}
|
||||
|
||||
func (GamePlayerVehicleSegment) TableName() string { return "game_player_vehicle_segments" }
|
||||
@@ -48,6 +48,8 @@ type StoreSnapshot struct {
|
||||
GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"`
|
||||
GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"`
|
||||
GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"`
|
||||
GameMapTrackPoints []domain.GameMapTrackPoint `json:"gameMapTrackPoints"`
|
||||
GamePlayerVehicleSegments []domain.GamePlayerVehicleSegment `json:"gamePlayerVehicleSegments"`
|
||||
GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"`
|
||||
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
|
||||
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
|
||||
@@ -217,6 +219,12 @@ func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *FileStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GameMapTrackPoints() GameMapTrackPointRepository {
|
||||
return &persistentRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter]{repository: store.MemoryStore.gameMapTrackPoints, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository {
|
||||
return &persistentRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter]{repository: store.MemoryStore.gamePlayerVehicleSegments, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GameGiftCatalogs() GameGiftCatalogRepository {
|
||||
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
|
||||
}
|
||||
@@ -299,7 +307,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +347,8 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
loadRepository(store.MemoryStore.gameMapTrackPoints, snapshot.GameMapTrackPoints)
|
||||
loadRepository(store.MemoryStore.gamePlayerVehicleSegments, snapshot.GamePlayerVehicleSegments)
|
||||
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
|
||||
@@ -189,6 +189,12 @@ func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *MySQLStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GameMapTrackPoints() GameMapTrackPointRepository {
|
||||
return &persistentRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter]{repository: store.MemoryStore.gameMapTrackPoints, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository {
|
||||
return &persistentRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter]{repository: store.MemoryStore.gamePlayerVehicleSegments, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GameGiftCatalogs() GameGiftCatalogRepository {
|
||||
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
|
||||
}
|
||||
@@ -288,7 +294,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +334,8 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
loadRepository(store.MemoryStore.gameMapTrackPoints, snapshot.GameMapTrackPoints)
|
||||
loadRepository(store.MemoryStore.gamePlayerVehicleSegments, snapshot.GamePlayerVehicleSegments)
|
||||
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
|
||||
+78
-47
@@ -265,6 +265,19 @@ type GamePlayerStatePatchRepository interface {
|
||||
List(domain.GamePlayerStatePatchFilter) ([]domain.GamePlayerStatePatch, error)
|
||||
Update(domain.GamePlayerStatePatch) error
|
||||
}
|
||||
type GameMapTrackPointRepository interface {
|
||||
Create(domain.GameMapTrackPoint) error
|
||||
Get(string) (domain.GameMapTrackPoint, error)
|
||||
List(domain.GameMapTrackPointFilter) ([]domain.GameMapTrackPoint, error)
|
||||
Delete(string) error
|
||||
}
|
||||
type GamePlayerVehicleSegmentRepository interface {
|
||||
Create(domain.GamePlayerVehicleSegment) error
|
||||
Get(string) (domain.GamePlayerVehicleSegment, error)
|
||||
List(domain.GamePlayerVehicleSegmentFilter) ([]domain.GamePlayerVehicleSegment, error)
|
||||
Update(domain.GamePlayerVehicleSegment) error
|
||||
Delete(string) error
|
||||
}
|
||||
type GameGiftCatalogRepository interface {
|
||||
Create(domain.GameGiftCatalog) error
|
||||
Get(string) (domain.GameGiftCatalog, error)
|
||||
@@ -319,50 +332,54 @@ type Store interface {
|
||||
GameAccessAttempts() GameAccessAttemptRepository
|
||||
GameSecuritySignals() GameSecuritySignalRepository
|
||||
GamePlayerStatePatches() GamePlayerStatePatchRepository
|
||||
GameMapTrackPoints() GameMapTrackPointRepository
|
||||
GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository
|
||||
GameGiftCatalogs() GameGiftCatalogRepository
|
||||
GameGiftRevisions() GameGiftRevisionRepository
|
||||
GameGiftGrants() GameGiftGrantRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
users *memoryRepository[domain.User, domain.UserFilter]
|
||||
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
||||
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||
jobs *memoryJobRepository
|
||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
|
||||
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
|
||||
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
|
||||
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
|
||||
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
|
||||
gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]
|
||||
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
|
||||
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
|
||||
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
|
||||
users *memoryRepository[domain.User, domain.UserFilter]
|
||||
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
||||
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||
jobs *memoryJobRepository
|
||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
|
||||
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
|
||||
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
|
||||
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
|
||||
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
|
||||
gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]
|
||||
gameMapTrackPoints *memoryRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter]
|
||||
gamePlayerVehicleSegments *memoryRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter]
|
||||
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
|
||||
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
|
||||
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -500,15 +517,17 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyGameClientBridgeSnapshotStream,
|
||||
matchGameClientBridgeSnapshotStream,
|
||||
),
|
||||
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
|
||||
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
|
||||
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
|
||||
gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt),
|
||||
gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal),
|
||||
gamePlayerStatePatches: newMemoryRepository(func(v domain.GamePlayerStatePatch) string { return v.ID }, domain.CopyGamePlayerStatePatch, matchGamePlayerStatePatch),
|
||||
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
|
||||
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
|
||||
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
|
||||
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
|
||||
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
|
||||
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
|
||||
gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt),
|
||||
gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal),
|
||||
gamePlayerStatePatches: newMemoryRepository(func(v domain.GamePlayerStatePatch) string { return v.ID }, domain.CopyGamePlayerStatePatch, matchGamePlayerStatePatch),
|
||||
gameMapTrackPoints: newMemoryRepository(func(v domain.GameMapTrackPoint) string { return v.ID }, domain.CopyGameMapTrackPoint, matchGameMapTrackPoint),
|
||||
gamePlayerVehicleSegments: newMemoryRepository(func(v domain.GamePlayerVehicleSegment) string { return v.ID }, domain.CopyGamePlayerVehicleSegment, matchGamePlayerVehicleSegment),
|
||||
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
|
||||
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
|
||||
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +596,12 @@ func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return store.gamePlayerStatePatches
|
||||
}
|
||||
func (store *MemoryStore) GameMapTrackPoints() GameMapTrackPointRepository {
|
||||
return store.gameMapTrackPoints
|
||||
}
|
||||
func (store *MemoryStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository {
|
||||
return store.gamePlayerVehicleSegments
|
||||
}
|
||||
func (store *MemoryStore) GameGiftCatalogs() GameGiftCatalogRepository { return store.gameGiftCatalogs }
|
||||
func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository {
|
||||
return store.gameGiftRevisions
|
||||
@@ -901,6 +926,12 @@ func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecurityS
|
||||
func matchGamePlayerStatePatch(v domain.GamePlayerStatePatch, f domain.GamePlayerStatePatchFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID)
|
||||
}
|
||||
func matchGameMapTrackPoint(v domain.GameMapTrackPoint, f domain.GameMapTrackPointFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.MapID == "" || v.MapID == f.MapID) && (f.MapVersion == "" || v.MapVersion == f.MapVersion) && (f.EntityID == "" || v.EntityID == f.EntityID) && (f.EntityKind == "" || v.EntityKind == f.EntityKind) && (f.OccurredAfter.IsZero() || !v.OccurredAt.Before(f.OccurredAfter)) && (f.OccurredBefore.IsZero() || !v.OccurredAt.After(f.OccurredBefore))
|
||||
}
|
||||
func matchGamePlayerVehicleSegment(v domain.GamePlayerVehicleSegment, f domain.GamePlayerVehicleSegmentFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.VehicleID == "" || v.VehicleID == f.VehicleID) && (f.MapID == "" || v.MapID == f.MapID) && (f.MapVersion == "" || v.MapVersion == f.MapVersion) && (f.OccurredAfter.IsZero() || !v.EndedAt.Before(f.OccurredAfter)) && (f.OccurredBefore.IsZero() || !v.StartedAt.After(f.OccurredBefore))
|
||||
}
|
||||
func matchGameGiftCatalog(v domain.GameGiftCatalog, f domain.GameGiftCatalogFilter) bool {
|
||||
return f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -157,6 +157,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||
violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...)
|
||||
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||
return finish(violations)
|
||||
@@ -227,10 +228,21 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||
violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...)
|
||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateMapTrajectoryDeclaration(prefix string, value *domain.GameMapTrajectoryDeclaration) []string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if value.MapID == "" || value.MapVersion == "" || value.WorldMaxX <= value.WorldMinX || value.WorldMaxY <= value.WorldMinY || value.ImageWidth <= 0 || value.ImageHeight <= 0 || value.Precision <= 0 || value.SampleDistance < 0 || value.SampleIntervalSeconds < 0 || value.RetentionSeconds <= 0 || value.RetentionSeconds > 31*24*60*60 {
|
||||
return []string{prefix + " is invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string {
|
||||
if len(fields) > 32 {
|
||||
return []string{prefix + " must contain at most 32 fields"}
|
||||
|
||||
Reference in New Issue
Block a user