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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-28
@@ -0,0 +1,3 @@
# add-scum-map-trajectories
SCUM player and vehicle map trajectory projection
@@ -0,0 +1,45 @@
## Context
The existing SCUM player intelligence projection owns server-local identities and sessions, while the game-client bridge owns declared Companion snapshots and commands. Neither layer presents movement. The map must remain an explainable, bounded operational view and may not turn the browser into a route to raw logs, Run, game databases, or Companion sockets.
## Goals / Non-Goals
**Goals:**
- Collect only declared semantic position/vehicle transition events from a platform-managed Companion or accepted log projection.
- Convert plugin-declared world coordinates into safe normalized map coordinates and retain only a sampled, bounded trail per server/entity.
- Return a fixed bounded window with player/vehicle filters, vehicle-riding segments, data source, collection time, map version, and precision.
- Enforce existing server authorization before resolving map status or trajectory data; preserve server isolation.
- Render readable point and line trails with explicit empty and missing-map states and detail navigation contracts.
**Non-Goals:**
- OCR, screenshot parsing, mouse/keyboard injection, desktop automation, direct game-window access, direct sockets, direct database access, raw coordinate/database querying, indefinite retention, real-time all-server heatmaps, or raw log/browser delivery.
## Decisions
1. **A small typed event catalog is the only collector contract.** The SCUM manifest declares four semantic events: `player.position`, `vehicle.position`, `player.vehicle.enter`, and `player.vehicle.leave`. Every event includes a bounded entity ID, occurred time, collection time, source (`companion` or `log-projection`), and a plugin map ID/version. Position events carry only finite world X/Y values. They enter through the existing durable log/Companion snapshot channel, never browser-to-game traffic.
2. **Plugin map declaration defines the safe projection.** `mapTrajectories` in the manifest declares map ID/version, world bounds, image dimensions, axis orientation, sampling distance/time, and retention seconds. Platform validates it once, converts world coordinates to normalized 01000 map units, rounds to declared precision, and rejects mismatched map metadata or points outside declared bounds. The frontend receives no host path, original world coordinate, Companion endpoint, or raw record body.
3. **Project by server/entity/time with event identity and compression.** `GameMapTrackPoint` is unique by accepted event ID and is scoped by server plus entity kind/ID. Projection sorts logically by event time; duplicates are ignored, late points remain ordered in query results, and a point is retained only when it advances the declared sampling interval or distance (transitions are always retained). The repository filters time on server/entity indexes and prunes expired points and ride segments during projection and query.
4. **Derive riding intervals from enter/leave events.** A player enter opens one vehicle segment; entering a different vehicle closes the prior segment at the new event time. A leave closes only the matching active vehicle. Out-of-order and duplicate transitions cannot produce overlapping active segments. The map response projects these segments as player-associated vehicle trail intervals, not inferred ownership.
5. **Use a bounded read model instead of arbitrary map queries.** The API accepts a maximum 24-hour time window and at most 20 known player IDs plus 20 known vehicle IDs. It authorizes server access before lookup, verifies player IDs belong to that server, limits output points per entity, and returns map metadata plus empty/missing-map statuses. Vehicle IDs are only accepted if observed in the same server's declared vehicle snapshots or trajectory records.
6. **Keep detail linking as explicit identifiers.** Player trail summaries provide the existing `gamePlayerRecordId`; vehicles provide their safe vehicle ID and link intent. The frontend may navigate to the existing player selection/detail endpoint or invoke the pre-existing vehicle lookup context. It does not receive a route to Run, a raw query template, or vehicle storage details.
## Risks / Trade-offs
- [Companion cannot provide typed position events] → the map shows a clear collection-unavailable/empty state; no substitute screen or input automation is attempted.
- [Map version changes] → points are queried only for the declared map version; mismatched events are rejected and the response explains the missing compatible map.
- [High event volume] → server-side sampling, per-entity output caps, and retention pruning bound storage and response size.
- [Late events] → query sorting and identity deduplication keep trails deterministic; transition rules avoid reopened or cross-vehicle overlap.
## Migration Plan
1. Add model-first track/segment records, memory/file/MySQL persistence, projection, retention, and test coverage.
2. Register the plugin map declaration and typed schemas; unsupported deployments remain visibly empty.
3. Deploy the authorized API and console map. Existing player and vehicle views remain unchanged.
4. Roll back by removing the map panel/declaration; expired projected records are pruned normally and no raw data needs migration.
@@ -0,0 +1,23 @@
## Why
SCUM operations currently show player identity and vehicle snapshots, but operators cannot explain where a selected player or vehicle has been over a bounded period. A server-scoped, permission-checked map projection is needed without exposing raw logs, Companion connectivity, host data, or arbitrary coordinate queries.
## What Changes
- Define controlled SCUM Companion/log-projection events for `player.position`, `vehicle.position`, `player.vehicle.enter`, and `player.vehicle.leave`.
- Require the plugin to declare map version, coordinate conversion, sampling precision, and retention policy; project accepted events into server-isolated trajectory records.
- Add a bounded, authorized map API with time windows, player/vehicle filters, vehicle-riding segments, source/collection timestamps, and explicit missing-map/empty states.
- Add a Chinese SCUM console map view with point-and-line trajectories and links to the existing player detail and vehicle context.
## Capabilities
### New Capabilities
- `scum-map-trajectories`: Controlled collection, projection, retention, authorization, and map rendering of SCUM player and vehicle trajectories.
## Impact
- Affects platform domain/model/repository/service/validation/API DTOs and durable metadata snapshots.
- Extends the SCUM plugin manifest and schemas with bounded map metadata and semantic trajectory events.
- Adds platform-web API contracts and a shared-console map surface; it receives only safe projected coordinates and metadata.
- Does not add OCR, screen/input automation, direct game/window access, raw logs, unbounded location retention, or live heatmaps.
@@ -0,0 +1,59 @@
## ADDED Requirements
### Requirement: Controlled SCUM trajectory event contracts
The system SHALL accept SCUM location information only as platform-managed Companion or accepted log-projection semantic events named `player.position`, `vehicle.position`, `player.vehicle.enter`, and `player.vehicle.leave`, each carrying bounded IDs, occurrence and collection timestamps, declared source, and matching map metadata.
#### Scenario: An undeclared or malformed location event arrives
- **WHEN** an event has an unknown type, malformed bounded ID, non-finite coordinate, invalid source, or mismatched map declaration
- **THEN** the system SHALL reject it from trajectory projection and SHALL not expose raw event material to the browser
### Requirement: Plugin-declared safe map conversion
The SCUM plugin SHALL declare a map ID/version, coordinate transform/world bounds, sampling precision, and finite retention period. The platform SHALL convert accepted world points into a rounded safe map projection before persistence or API delivery.
#### Scenario: A point is outside declared map bounds
- **WHEN** a valid position event has world coordinates outside the declared transform bounds
- **THEN** the platform SHALL not persist or return a map point for that event
#### Scenario: Map metadata is absent
- **WHEN** a server plugin has no compatible map declaration
- **THEN** the map API and console SHALL return a readable missing-map state without attempting alternative collection
### Requirement: Ordered, sampled, and retained server-isolated trajectories
The system SHALL index points by server, entity, and occurrence time; tolerate duplicate and out-of-order events; apply declared sampling compression; and remove points and closed ride segments after the declared retention period.
#### Scenario: Duplicate or delayed position event
- **WHEN** the same accepted event is delivered twice or an older point arrives after a newer point
- **THEN** the system SHALL retain no duplicate and SHALL return all accepted points ordered by occurrence time without regressing current sampling state
#### Scenario: A point is below the sampling threshold
- **WHEN** a same-entity point occurs inside the declared minimum time and distance thresholds
- **THEN** the system SHALL compress it rather than persist another display point
#### Scenario: Retention expires
- **WHEN** normal projection or map retrieval occurs after a point or closed riding segment passes its retention deadline
- **THEN** the system SHALL remove the expired record while preserving unrelated player identity and vehicle snapshot data
### Requirement: Vehicle ride association
The system SHALL derive player vehicle segments from typed enter/leave events and SHALL close a prior active segment before opening a segment for a different vehicle.
#### Scenario: A player changes vehicles without a leave event
- **WHEN** a player enters a second vehicle while a first vehicle segment remains active
- **THEN** the system SHALL close the first segment at the second enter time and open one segment for the second vehicle
### Requirement: Authorized bounded map read model
The system SHALL authorize server access before returning a maximum 24-hour trajectory window and SHALL expose only safe projected points, declared map metadata, collection/source labels, entity summaries, and detail-link identifiers. It SHALL not expose raw coordinates, raw logs, IPs, paths, credentials, host information, or Run/Companion connectivity.
#### Scenario: Unauthorized map request
- **WHEN** a session lacks access to the requested server instance
- **THEN** the API SHALL deny the request without revealing map availability, entity existence, or trajectory data
#### Scenario: Cross-server entity selector
- **WHEN** a requested player or vehicle ID belongs only to another server
- **THEN** the response SHALL not include its points or reveal the other server association
### Requirement: Explainable SCUM map console
The SCUM console SHALL render declared map metadata, collection source/times, selected player and vehicle points/lines, ride segments, time and entity filters, and textual empty or missing-map states. Map entity interactions SHALL use the returned safe detail-link identifiers.
#### Scenario: An operator selects a trail entity
- **WHEN** an operator selects a player or vehicle map item
- **THEN** the console SHALL navigate or invoke the matching player/vehicle detail context using the returned identifier without constructing arbitrary coordinate or data queries
@@ -0,0 +1,19 @@
## 1. Specification and contracts
- [x] 1.1 Define the platform domain/model/repository contracts for declared map metadata, safe points, ride segments, bounded filters, and map read response.
- [x] 1.2 Add SCUM manifest map declaration and typed semantic event schemas for position and vehicle transitions.
## 2. Platform projection and API
- [x] 2.1 Implement map declaration validation, safe coordinate conversion, sampling compression, event deduplication/order handling, ride-segment projection, and expiry pruning.
- [x] 2.2 Add authorized server map route, named DTOs, and safe response mapping with time/entity bounds and missing-map state.
- [x] 2.3 Add backend tests for conversion, duplicate/out-of-order events, cross-vehicle segments, filters, authorization, sampling, retention, and cross-server isolation.
## 3. Console
- [x] 3.1 Add frontend API/types/contracts and a shared-theme SCUM map component with time/player/vehicle filters, point/line trails, source/timestamp labels, and empty/missing-map states.
- [x] 3.2 Wire safe player/vehicle detail interactions and add focused component/schema tests.
## 4. Verification
- [x] 4.1 Run relevant Go, plugin, and frontend tests/build; run `openspec validate add-scum-map-trajectories --strict` and `scripts/check-structure.sh`.
@@ -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
}
+1
View File
@@ -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)
+103
View File
@@ -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
}
+15
View File
@@ -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
}
+69
View File
@@ -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
}
+36
View File
@@ -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,
+40
View File
@@ -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" }
+11 -1
View File
@@ -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)
+9 -1
View File
@@ -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
View File
@@ -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
}
+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",
+12
View File
@@ -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"}
+2
View File
@@ -54,6 +54,7 @@ import type {
GamePlayerStatePatchRequest,
GamePlayerStatePatchResponse,
GamePlayerStateResponse,
GameMapTrajectoryResponse,
GameGiftCatalogListResponse,
GameGiftCatalogRequest,
GameGiftCatalogResponse,
@@ -596,6 +597,7 @@ export class PlatformApiClient {
async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise<GamePlayerProfileResponse> {
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
}
async getGameMapTrajectories(serverInstanceId: string, filter: { from?: string; to?: string; playerIds?: string[]; vehicleIds?: string[] } = {}): Promise<GameMapTrajectoryResponse> { const params = new URLSearchParams(); if (filter.from) params.set("from", filter.from); if (filter.to) params.set("to", filter.to); if (filter.playerIds?.length) params.set("playerId", filter.playerIds.join(",")); if (filter.vehicleIds?.length) params.set("vehicleId", filter.vehicleIds.join(",")); const query = params.toString(); return this.request<GameMapTrajectoryResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-map-trajectories${query ? `?${query}` : ""}`); }
async getGamePlayerState(serverInstanceId: string, playerId: string): Promise<GamePlayerStateResponse> { return this.request<GamePlayerStateResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); }
async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise<GamePlayerStatePatchListResponse> { return this.request<GamePlayerStatePatchListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); }
+5
View File
@@ -310,6 +310,11 @@ export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: n
export interface GamePlayerStatePatchRequest { gameVersion: string; expectedStateVersion: string; safetyWindow: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; }
export interface GamePlayerStatePatchResponse { id: string; gameVersion: string; expectedStateVersion: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "execution-failed" | "execution-unknown" | "confirmation-failed" | "confirmed"; bridgeCommandId?: string; executionSummary?: string; confirmedStateVersion?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GamePlayerStatePatchListResponse { items: GamePlayerStatePatchResponse[]; }
export interface GameMapTrajectoryPointResponse { mapX: number; mapY: number; occurredAt: string; collectedAt: string; source: "companion" | "log-projection"; }
export interface GameMapTrajectoryEntityResponse { kind: "player" | "vehicle"; entityId: string; gamePlayerRecordId?: string; label: string; points: GameMapTrajectoryPointResponse[]; collectedAt?: string; sources: string[]; }
export interface GameMapTrajectorySegmentResponse { gamePlayerRecordId: string; vehicleId: string; startedAt: string; endedAt?: string; }
export interface GameMapTrajectoryMapResponse { mapId: string; mapVersion: string; imageWidth: number; imageHeight: number; precision: number; }
export interface GameMapTrajectoryResponse { status: "ready" | "empty" | "missing-map"; reason?: string; map?: GameMapTrajectoryMapResponse; from?: string; to?: string; players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; rideSegments: GameMapTrajectorySegmentResponse[]; }
export interface GameGiftItemRequest { catalogItemKey: string; quantity: number; }
export interface GameGiftItemResponse extends GameGiftItemRequest { label: string; }
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: GameGiftItemRequest[]; }
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { trajectoryPath } from "./ScumMapTrajectoryPanel";
describe("trajectoryPath", () => {
it("projects normalized map points into a stable SVG path", () => {
expect(trajectoryPath([{ mapX: 0, mapY: 0 }, { mapX: 500, mapY: 250 }, { mapX: 1000, mapY: 1000 }])).toBe("M0,1000 L500,750 L1000,0");
});
});
@@ -0,0 +1,22 @@
import { CarFront, MapPinned, RefreshCw, UserRound } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameMapTrajectoryEntityResponse, GameMapTrajectoryResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: GameMapTrajectoryResponse };
export function ScumMapTrajectoryPanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" }); const [hours, setHours] = useState("1"); const [playerIds, setPlayerIds] = useState(""); const [vehicleIds, setVehicleIds] = useState(""); const [detail, setDetail] = useState("");
const load = useCallback(async () => { setState({ status: "loading" }); const to = new Date(); const from = new Date(to.valueOf() - Math.max(1, Math.min(24, Number(hours) || 1)) * 3600000); try { setState({ status: "ready", value: await platformApiClient.getGameMapTrajectories(serverInstanceId, { from: from.toISOString(), to: to.toISOString(), playerIds: ids(playerIds), vehicleIds: ids(vehicleIds) }) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "地图轨迹读取失败。" }); } }, [hours, playerIds, serverInstanceId, vehicleIds]);
useEffect(() => { void load(); }, [load]);
async function openPlayer(entity: GameMapTrajectoryEntityResponse) { if (!entity.gamePlayerRecordId) return; try { const profile = await platformApiClient.getGamePlayerProfile(serverInstanceId, entity.gamePlayerRecordId); setDetail(`玩家详情:${profile.player.displayName}${profile.player.gamePlayerId}),别名 ${profile.aliases.length} 条,会话 ${profile.sessions.length} 条。`); } catch (error) { setDetail(error instanceof Error ? error.message : "玩家详情不可用。"); } }
if (state.status === "loading") return <LoadingState label="正在读取 SCUM 安全地图投影…" />; if (state.status === "error") return <ErrorState title="地图轨迹不可用" reason={state.reason} onRetry={() => void load()} />;
const value = state.value; return <section className="console-panel scum-map-panel" aria-label="SCUM 玩家与车辆地图轨迹"><div className="panel-header"><div><h2><MapPinned size={16} /> SCUM </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="console-row-actions"><label><select value={hours} onChange={(event) => setHours(event.target.value)}><option value="1"> 1 </option><option value="6"> 6 </option><option value="24"> 24 </option></select></label><label> ID<input value={playerIds} onChange={(event) => setPlayerIds(event.target.value)} placeholder="逗号分隔" /></label><label> ID<input value={vehicleIds} onChange={(event) => setVehicleIds(event.target.value)} placeholder="逗号分隔" /></label><button type="button" className="command-button" onClick={() => void load()}></button></div>{value.status === "missing-map" ? <p className="page-status">{value.reason}</p> : <><p className="page-status"> {value.map?.mapId} · {value.map?.mapVersion} · {value.map?.precision} {formatTime(value.from)} {formatTime(value.to)}</p>{value.status === "empty" ? <p className="page-status">{value.reason}</p> : <TrajectoryMap players={value.players} vehicles={value.vehicles} onPlayer={openPlayer} onVehicle={(entity) => setDetail(`车辆详情上下文:${entity.entityId}。此链接只使用服务器内安全车辆标识,不查询原始存储。`)} />}{value.rideSegments.length > 0 && <div className="console-record-list">{value.rideSegments.map((segment) => <div className="console-record" key={`${segment.gamePlayerRecordId}-${segment.vehicleId}-${segment.startedAt}`}><strong></strong><span> {segment.gamePlayerRecordId} · {segment.vehicleId}</span><small>{formatTime(segment.startedAt)} {segment.endedAt ? formatTime(segment.endedAt) : "采集窗口内仍在车上"}</small></div>)}</div>}</>}{detail && <p className="page-status">{detail}</p>}</section>;
}
export function trajectoryPath(points: { mapX: number; mapY: number }[]) { return points.map((point, index) => `${index ? "L" : "M"}${point.mapX},${1000 - point.mapY}`).join(" "); }
function TrajectoryMap({ players, vehicles, onPlayer, onVehicle }: { players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; onPlayer: (entity: GameMapTrajectoryEntityResponse) => void; onVehicle: (entity: GameMapTrajectoryEntityResponse) => void }) { const entities = useMemo(() => [...players, ...vehicles], [players, vehicles]); return <><div className="scum-map-canvas" role="img" aria-label="已筛选的玩家和车辆地图轨迹"><svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet">{entities.map((entity) => <g key={`${entity.kind}-${entity.entityId}`}><path className={entity.kind === "player" ? "scum-map-player-line" : "scum-map-vehicle-line"} d={trajectoryPath(entity.points)} />{entity.points.map((point, index) => <circle key={`${point.occurredAt}-${index}`} className={entity.kind === "player" ? "scum-map-player-point" : "scum-map-vehicle-point"} cx={point.mapX} cy={1000 - point.mapY} r="7"><title>{`${entity.label} · ${formatTime(point.occurredAt)} · ${point.source}`}</title></circle>)}</g>)}</svg></div><div className="console-record-list">{entities.map((entity) => <div className="console-record" key={`${entity.kind}-${entity.entityId}`}><strong>{entity.kind === "player" ? <UserRound size={15} /> : <CarFront size={15} />} {entity.label}</strong><span>{entity.points.length} · {entity.sources.join(" / ") || "--"}</span><small>{formatTime(entity.collectedAt)}</small><button type="button" className="icon-command" onClick={() => entity.kind === "player" ? onPlayer(entity) : onVehicle(entity)}>{entity.kind === "player" ? <UserRound size={14} /> : <CarFront size={14} />}<span>{entity.kind === "player" ? "进入玩家详情" : "进入车辆上下文"}</span></button></div>)}</div></>; }
function ids(value: string) { return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 20); }
function formatTime(value: string | undefined) { if (!value) return "--"; const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
@@ -6,6 +6,7 @@ import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
import { ScumMapTrajectoryPanel } from "../components/ScumMapTrajectoryPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
@@ -108,6 +109,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
</section>
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
</div>
);
}
+1
View File
@@ -755,6 +755,7 @@ button.operations-inline-warning{cursor:pointer}
.operations-tray-item-copy code{color:var(--gold);font-size:10px}
.operations-tray-item time{color:var(--ink-faint);font-size:10px;white-space:nowrap}
.operations-tray-empty{margin:10px 0 2px;color:var(--ink-faint);font-size:11px}
.scum-map-panel .console-row-actions{align-items:end}.scum-map-panel label{display:grid;gap:4px;min-width:150px;color:var(--ink-soft);font-size:12px}.scum-map-panel input,.scum-map-panel select{max-width:220px}.scum-map-canvas{margin:12px 0;border:1px solid var(--line);background:var(--frosted-surface),linear-gradient(90deg,color-mix(in srgb,var(--line) 36%,transparent) 1px,transparent 1px),linear-gradient(180deg,color-mix(in srgb,var(--line) 28%,transparent) 1px,transparent 1px);background-size:auto,10% 10%,10% 10%;box-shadow:var(--jelly-inset);aspect-ratio:16/9;overflow:hidden}.scum-map-canvas svg{display:block;width:100%;height:100%}.scum-map-player-line,.scum-map-vehicle-line{fill:none;stroke-width:5;stroke-linecap:round;stroke-linejoin:round}.scum-map-player-line{stroke:var(--accent)}.scum-map-vehicle-line{stroke:var(--gold)}.scum-map-player-point{fill:var(--accent-deep);stroke:var(--accent);stroke-width:3}.scum-map-vehicle-point{fill:var(--gold);stroke:var(--pink);stroke-width:3}
.app-shell-sidebar-collapsed .operations-tray{width:44px}
.app-shell-sidebar-collapsed .operations-tray-trigger{grid-template-columns:1fr;place-items:center;padding:6px 0}
.app-shell-sidebar-collapsed .operations-tray-copy,.app-shell-sidebar-collapsed .operations-tray-trigger>svg{display:none}
@@ -389,6 +389,7 @@
"mediation": "platform",
"configWritePolicy": "review-required"
},
"mapTrajectories": { "mapId": "scum-island", "mapVersion": "0.9", "worldMinX": -500000, "worldMinY": -500000, "worldMaxX": 500000, "worldMaxY": 500000, "imageWidth": 2048, "imageHeight": 2048, "precision": 1, "sampleDistance": 4, "sampleIntervalSeconds": 20, "retentionSeconds": 604800 },
"runtimeProfiles": {
"discovery": [
{
@@ -636,6 +637,10 @@
}
],
"logEvents": [
{ "key": "scum-player-position", "title": "SCUM player position", "sourceKey": "scum-client-events", "eventType": "player.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-position.event.schema.json", "retentionDays": 7, "severity": "info" },
{ "key": "scum-vehicle-position", "title": "SCUM vehicle position", "sourceKey": "scum-client-events", "eventType": "vehicle.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/vehicle-position.event.schema.json", "retentionDays": 7, "severity": "info" },
{ "key": "scum-player-vehicle-enter", "title": "SCUM player vehicle enter", "sourceKey": "scum-client-events", "eventType": "player.vehicle.enter", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json", "retentionDays": 7, "severity": "info" },
{ "key": "scum-player-vehicle-leave", "title": "SCUM player vehicle leave", "sourceKey": "scum-client-events", "eventType": "player.vehicle.leave", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json", "retentionDays": 7, "severity": "info" },
{
"key": "scum-chat",
"title": "SCUM chat message",
@@ -0,0 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
@@ -0,0 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -0,0 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -0,0 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
@@ -50,6 +50,7 @@
"gameClientBridge": {
"$ref": "#/$defs/gameClientBridgeManifest"
},
"mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" },
"capabilities": {
"type": "array",
"items": { "$ref": "#/$defs/runCapability" },
@@ -203,6 +204,17 @@
}
},
"$defs": {
"mapTrajectoryDeclaration": {
"type": "object",
"required": ["mapId", "mapVersion", "worldMinX", "worldMinY", "worldMaxX", "worldMaxY", "imageWidth", "imageHeight", "precision", "sampleDistance", "sampleIntervalSeconds", "retentionSeconds"],
"additionalProperties": false,
"properties": {
"mapId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" }, "mapVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
"worldMinX": { "type": "number" }, "worldMinY": { "type": "number" }, "worldMaxX": { "type": "number" }, "worldMaxY": { "type": "number" },
"imageWidth": { "type": "number", "exclusiveMinimum": 0 }, "imageHeight": { "type": "number", "exclusiveMinimum": 0 }, "precision": { "type": "number", "exclusiveMinimum": 0 }, "sampleDistance": { "type": "number", "minimum": 0 },
"sampleIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }, "retentionSeconds": { "type": "integer", "minimum": 1, "maximum": 2678400 }
}
},
"pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } },
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } },
"pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } },