From 5b15bd50cbc6e386daeff04ff6736fe377453765 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 28 Jul 2026 17:13:48 +0800 Subject: [PATCH] feat(scum): add map trajectory projection --- .../add-scum-map-trajectories/.openspec.yaml | 2 + .../add-scum-map-trajectories/README.md | 3 + .../add-scum-map-trajectories/design.md | 45 +++ .../add-scum-map-trajectories/proposal.md | 23 ++ .../specs/scum-map-trajectories/spec.md | 59 ++++ .../add-scum-map-trajectories/tasks.md | 19 ++ platform/api/game_map_trajectory_handlers.go | 66 ++++ platform/api/resource_handlers.go | 1 + platform/domain/game_map_trajectories.go | 103 ++++++ platform/domain/resources.go | 15 + platform/dto/game_map_trajectories.go | 69 ++++ platform/dto/resources.go | 36 ++ platform/model/game_map_trajectories.go | 40 +++ platform/repo/file_store.go | 12 +- platform/repo/mysql_store.go | 10 +- platform/repo/resources.go | 125 ++++--- platform/service/game_map_trajectories.go | 316 ++++++++++++++++++ .../service/game_map_trajectories_test.go | 95 ++++++ platform/service/log_ingest.go | 6 + platform/service/resources.go | 3 + platform/validator/resources.go | 12 + platform_web/api/client.ts | 2 + platform_web/api/types.ts | 5 + .../components/ScumMapTrajectoryPanel.test.ts | 9 + .../components/ScumMapTrajectoryPanel.tsx | 22 ++ platform_web/pages/PluginPageHostPage.tsx | 2 + platform_web/theme/base.css | 1 + .../examples/scum-server-plugin/manifest.json | 5 + .../player-position.event.schema.json | 1 + .../player-vehicle-enter.event.schema.json | 1 + .../player-vehicle-leave.event.schema.json | 1 + .../vehicle-position.event.schema.json | 1 + .../game-plugin.manifest.schema.json | 12 + 33 files changed, 1073 insertions(+), 49 deletions(-) create mode 100644 openspec/changes/add-scum-map-trajectories/.openspec.yaml create mode 100644 openspec/changes/add-scum-map-trajectories/README.md create mode 100644 openspec/changes/add-scum-map-trajectories/design.md create mode 100644 openspec/changes/add-scum-map-trajectories/proposal.md create mode 100644 openspec/changes/add-scum-map-trajectories/specs/scum-map-trajectories/spec.md create mode 100644 openspec/changes/add-scum-map-trajectories/tasks.md create mode 100644 platform/api/game_map_trajectory_handlers.go create mode 100644 platform/domain/game_map_trajectories.go create mode 100644 platform/dto/game_map_trajectories.go create mode 100644 platform/model/game_map_trajectories.go create mode 100644 platform/service/game_map_trajectories.go create mode 100644 platform/service/game_map_trajectories_test.go create mode 100644 platform_web/components/ScumMapTrajectoryPanel.test.ts create mode 100644 platform_web/components/ScumMapTrajectoryPanel.tsx create mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json diff --git a/openspec/changes/add-scum-map-trajectories/.openspec.yaml b/openspec/changes/add-scum-map-trajectories/.openspec.yaml new file mode 100644 index 0000000..e8209ff --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/add-scum-map-trajectories/README.md b/openspec/changes/add-scum-map-trajectories/README.md new file mode 100644 index 0000000..13f291d --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/README.md @@ -0,0 +1,3 @@ +# add-scum-map-trajectories + +SCUM player and vehicle map trajectory projection diff --git a/openspec/changes/add-scum-map-trajectories/design.md b/openspec/changes/add-scum-map-trajectories/design.md new file mode 100644 index 0000000..d9006a7 --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/design.md @@ -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 0–1000 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. diff --git a/openspec/changes/add-scum-map-trajectories/proposal.md b/openspec/changes/add-scum-map-trajectories/proposal.md new file mode 100644 index 0000000..0b30e8b --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/proposal.md @@ -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. diff --git a/openspec/changes/add-scum-map-trajectories/specs/scum-map-trajectories/spec.md b/openspec/changes/add-scum-map-trajectories/specs/scum-map-trajectories/spec.md new file mode 100644 index 0000000..385e6fe --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/specs/scum-map-trajectories/spec.md @@ -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 diff --git a/openspec/changes/add-scum-map-trajectories/tasks.md b/openspec/changes/add-scum-map-trajectories/tasks.md new file mode 100644 index 0000000..0a1a30b --- /dev/null +++ b/openspec/changes/add-scum-map-trajectories/tasks.md @@ -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`. diff --git a/platform/api/game_map_trajectory_handlers.go b/platform/api/game_map_trajectory_handlers.go new file mode 100644 index 0000000..6304d05 --- /dev/null +++ b/platform/api/game_map_trajectory_handlers.go @@ -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 +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 93c527b..ff4514a 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -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) diff --git a/platform/domain/game_map_trajectories.go b/platform/domain/game_map_trajectories.go new file mode 100644 index 0000000..6d41bb8 --- /dev/null +++ b/platform/domain/game_map_trajectories.go @@ -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 +} diff --git a/platform/domain/resources.go b/platform/domain/resources.go index b1bd44b..2eb0e76 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 } diff --git a/platform/dto/game_map_trajectories.go b/platform/dto/game_map_trajectories.go new file mode 100644 index 0000000..6a9f678 --- /dev/null +++ b/platform/dto/game_map_trajectories.go @@ -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 +} diff --git a/platform/dto/resources.go b/platform/dto/resources.go index f944916..fbabea5 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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, diff --git a/platform/model/game_map_trajectories.go b/platform/model/game_map_trajectories.go new file mode 100644 index 0000000..2196dae --- /dev/null +++ b/platform/model/game_map_trajectories.go @@ -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" } diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 6141296..60a28cb 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -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) diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index be27469..b4a33c1 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -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) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 571d9a0..3bec628 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -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 } diff --git a/platform/service/game_map_trajectories.go b/platform/service/game_map_trajectories.go new file mode 100644 index 0000000..ff741fc --- /dev/null +++ b/platform/service/game_map_trajectories.go @@ -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 +} diff --git a/platform/service/game_map_trajectories_test.go b/platform/service/game_map_trajectories_test.go new file mode 100644 index 0000000..66dc834 --- /dev/null +++ b/platform/service/game_map_trajectories_test.go @@ -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) + } +} diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index 08807e7..9e982c7 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -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, diff --git a/platform/service/resources.go b/platform/service/resources.go index fb9578e..8e4e11b 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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", diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 9d93d03..d8e3973 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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"} diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 879555e..e001e66 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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 { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`); } + async getGameMapTrajectories(serverInstanceId: string, filter: { from?: string; to?: string; playerIds?: string[]; vehicleIds?: string[] } = {}): Promise { 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(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-map-trajectories${query ? `?${query}` : ""}`); } async getGamePlayerState(serverInstanceId: string, playerId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); } async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 3579cb5..19f49ca 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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[]; } diff --git a/platform_web/components/ScumMapTrajectoryPanel.test.ts b/platform_web/components/ScumMapTrajectoryPanel.test.ts new file mode 100644 index 0000000..990e8d7 --- /dev/null +++ b/platform_web/components/ScumMapTrajectoryPanel.test.ts @@ -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"); + }); +}); diff --git a/platform_web/components/ScumMapTrajectoryPanel.tsx b/platform_web/components/ScumMapTrajectoryPanel.tsx new file mode 100644 index 0000000..82f1065 --- /dev/null +++ b/platform_web/components/ScumMapTrajectoryPanel.tsx @@ -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({ 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 ; if (state.status === "error") return void load()} />; + const value = state.value; return

SCUM 玩家与车辆地图轨迹

仅显示插件声明坐标转换后的安全投影;数据来源与采集时间始终可解释。

{value.status === "missing-map" ?

缺失地图声明:{value.reason}

: <>

地图 {value.map?.mapId} · 版本 {value.map?.mapVersion} · 精度 {value.map?.precision};窗口 {formatTime(value.from)} 至 {formatTime(value.to)}

{value.status === "empty" ?

{value.reason}

: setDetail(`车辆详情上下文:${entity.entityId}。此链接只使用服务器内安全车辆标识,不查询原始存储。`)} />}{value.rideSegments.length > 0 &&
{value.rideSegments.map((segment) =>
乘车区段玩家 {segment.gamePlayerRecordId} · 车辆 {segment.vehicleId}{formatTime(segment.startedAt)} → {segment.endedAt ? formatTime(segment.endedAt) : "采集窗口内仍在车上"}
)}
}}{detail &&

{detail}

}
; +} + +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 <>
{entities.map((entity) => {entity.points.map((point, index) => {`${entity.label} · ${formatTime(point.occurredAt)} · ${point.source}`})})}
{entities.map((entity) =>
{entity.kind === "player" ? : } {entity.label}{entity.points.length} 个采样点 · 来源 {entity.sources.join(" / ") || "--"}最后采集:{formatTime(entity.collectedAt)}
)}
; } +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 }); } diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index b3a3e81..3aa2b02 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -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 {scumResolution?.available && state.plugin.fileWorkspace && } {scumResolution?.available && } + {scumResolution?.available && } ); } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 851092f..0467b17 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index e86203b..6096779 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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", diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json new file mode 100644 index 0000000..8cf9a42 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json new file mode 100644 index 0000000..2bbc446 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json new file mode 100644 index 0000000..2bbc446 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json new file mode 100644 index 0000000..8b95322 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json @@ -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}}} diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 59dda1f..56dc941 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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"] } } },