Implement SCUM direct data plane
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,57 @@
|
||||
## Context
|
||||
|
||||
SCUM database schema, log grammar, and configuration files change with the game. The present Platform implementation identifies SCUM row types from query-key substrings and shapes data into fixed projection structs. That puts version-specific knowledge in the wrong component and forces a Platform update for routine game changes.
|
||||
|
||||
The supplied SCUM v57 sample confirms the required real sources: SQLite tables (`user_profile`, `prisoner`, `squad`, `squad_member`, `vehicle_spawner`, `base`, timed gifts/tasks), UTF-16LE server logs, and INI/JSON configuration. Platform needs durable, queryable `scum_*` records; Run remains an independent generic executor and is not changed in this repository.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Package SCUM v57 SQL, log grammar, and configuration mappings with the SCUM plugin.
|
||||
- Make manifest validation accept safe package-relative SQL references and declarative row destinations while rejecting browser-provided or inline SQL.
|
||||
- Make Platform ingest typed result rows through declaration metadata and upsert relational `scum_*` tables without SCUM query-name dispatch.
|
||||
- Provide users, squads, activity, gifts, and map data to the management console from those persisted tables.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Adding a `run/` tree, implementing Run-side executors, or adding SCUM game details to Run.
|
||||
- Parsing a SCUM database from the browser or exposing host paths/raw SQL to browser users.
|
||||
- Supporting every historical SCUM database version in the initial pack, changing lifecycle management, or adding unrelated product areas.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Plugin package owns version-specific declarations
|
||||
|
||||
Each SCUM query template declares a package-relative `sqlRef`, `targetTable`, `upsertKeys`, and optional column mapping. SQL source is a `.sql` package asset named for SCUM database version. Logs/configuration use package assets with explicit encoding/format metadata.
|
||||
|
||||
This gives a SCUM version update one editable location: the plugin package. Inline SQL remains invalid so the browser continues to request only named templates and typed parameters. Storing SQL in Platform would reproduce the current coupling; accepting arbitrary browser SQL would make the contract unauditable.
|
||||
|
||||
### Platform performs generic declaration-driven ingestion
|
||||
|
||||
The execution path resolves a registered plugin declaration, validates the reported columns/keys, and applies its rows to the named `scum_*` relation. The generic layer never infers destination type by matching `player`, `squad`, or other SCUM words in a query key.
|
||||
|
||||
Tables are deliberately shaped for the first console features: sync runs, users, squads, squad members, vehicles, flags, map points, activity events, gift catalogs/grants, and configuration files. A generic JSON payload retains plugin-version fields not promoted to columns, avoiding a Platform release for every added SCUM field.
|
||||
|
||||
### APIs and console read persisted projections only
|
||||
|
||||
Platform dataset endpoints list scoped persisted rows. The console renders the five requested real datasets and uses empty states until a sync exists; it does not invent demo state or query SQLite directly.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [SCUM schema drift invalidates a SQL asset] -> Version the asset directory, identify its schema version in the plugin manifest, and return a typed sync error rather than corrupting a table.
|
||||
- [A mapping declares an unexpected relation] -> Manifest validation limits targets to `scum_*`, validates relative asset paths, key names, and declared columns before plugin registration.
|
||||
- [Initial normalized tables omit a future game field] -> Preserve unmapped source values in row payload JSON and add columns only when they become a first-class console field.
|
||||
- [Existing fixed projections have callers] -> Retain compatibility responses while migrating callers, then remove string-key dispatch in the same change after tests cover declaration-driven ingestion.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Register the new manifest schema and v57 package assets alongside existing declarations.
|
||||
2. Add relational `scum_*` repository models/migrations and generic ingest APIs.
|
||||
3. Switch SCUM sync result handling and console APIs to the declarations.
|
||||
4. Verify against fixture rows derived from the supplied SCUM v57 database/log/config corpus.
|
||||
5. Roll back by deploying the previous Platform and plugin version; existing `scum_*` rows are additive and can be ignored by the old runtime.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- The first pack uses `pragma user_version = 57`; future versions will be added as plugin package assets when their actual schema is supplied.
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
The current SCUM integration keeps game-version SQL and data-shaping knowledge in Platform projections, which makes a SCUM update require coordinated changes across components. The management console needs to operate on actual SCUM v57 users, squads, activity, gifts, and map data with game-specific knowledge packaged alongside the SCUM plugin.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a plugin-packaged SCUM data plane: versioned SQL files, log parser definitions, and configuration mappings referenced from the plugin manifest.
|
||||
- Let Platform validate and distribute those declarations, accept typed result rows, and persist them in relational `scum_*` tables without dispatching on SCUM query-name substrings.
|
||||
- Expose persisted SCUM user, squad, activity, gift, and map datasets through Platform APIs and the first-party game operations console.
|
||||
- Replace the old hard-coded SCUM projection pathway with declaration-driven target-table and upsert metadata.
|
||||
- **BREAKING** Plugin query-template declarations gain package-relative SQL references and row-target metadata; inline SQL remains invalid.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `scum-direct-data-plane`: Plugin-owned versioned SCUM data declarations, generic Platform ingestion, and `scum_*` persistence.
|
||||
- `scum-operations-console`: First-party views and APIs for persisted SCUM users, squads, activity, gifts, and map points.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- None. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `plugins/`: SCUM manifest/schema/validator/tests and versioned SQL, log, and config packages.
|
||||
- `platform/`: plugin contracts, generic ingest service, relational repository models/migrations, and SCUM dataset APIs.
|
||||
- `platform_web/`: server management SCUM data views using existing themed console conventions.
|
||||
- No `run/` source is added; Run continues to perform only generic plugin-declared SQL and file jobs supplied by Platform.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Plugin-declared SCUM data packs
|
||||
The system SHALL allow a SCUM plugin manifest to reference package-relative versioned SQL, log parser, and configuration mapping assets. Every query declaration that produces persisted SCUM rows MUST declare a `scum_*` target table and non-empty upsert keys; inline SQL and browser-supplied SQL MUST be rejected.
|
||||
|
||||
#### Scenario: v57 query declaration resolves a packaged statement
|
||||
- **WHEN** the SCUM v57 plugin package declares a query with a relative `.sql` reference and row-target metadata
|
||||
- **THEN** manifest validation accepts the declaration and distribution includes the referenced asset
|
||||
|
||||
#### Scenario: inline statement is rejected
|
||||
- **WHEN** a manifest contains SQL text instead of a package-relative SQL reference
|
||||
- **THEN** manifest validation rejects the manifest with a declaration error
|
||||
|
||||
### Requirement: Generic SCUM row ingestion
|
||||
The system SHALL ingest rows returned for a registered SCUM data template according to that template's declared target table, upsert keys, and column mappings, without routing by a query-key substring.
|
||||
|
||||
#### Scenario: user rows are ingested
|
||||
- **WHEN** a registered user template returns rows containing its declared key and columns
|
||||
- **THEN** Platform upserts those rows into `scum_users` and records their plugin payload
|
||||
|
||||
#### Scenario: unsupported row target is rejected
|
||||
- **WHEN** a registered data template names a target outside the allowed `scum_*` tables
|
||||
- **THEN** Platform rejects the result before any dataset row is written
|
||||
|
||||
### Requirement: SCUM v57 source coverage
|
||||
The initial SCUM pack SHALL include data declarations for users, squads and members, activity, gifts, and map points, plus parser/mapping declarations for supplied SCUM logs and configuration formats.
|
||||
|
||||
#### Scenario: operators inspect supplied source families
|
||||
- **WHEN** the SCUM plugin package is assembled for database version 57
|
||||
- **THEN** it contains SQL assets and declarations for all five console datasets and log/config parser assets
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Persisted SCUM operations datasets
|
||||
The system SHALL expose persisted SCUM users, squads, activity events, gifts, and map points through Platform APIs scoped to a server instance.
|
||||
|
||||
#### Scenario: a completed sync is queried
|
||||
- **WHEN** an operator requests a SCUM dataset for an instance with persisted records
|
||||
- **THEN** Platform returns only the corresponding persisted `scum_*` rows for that instance
|
||||
|
||||
### Requirement: SCUM operations console views
|
||||
The first-party management console SHALL provide themed views for 用户, 队伍, 活动, 礼包, and 地图 using Platform's persisted SCUM dataset APIs, with an explicit empty state when no sync has completed.
|
||||
|
||||
#### Scenario: data is available
|
||||
- **WHEN** an operator opens a SCUM-enabled server after a data sync
|
||||
- **THEN** the console displays the returned persisted dataset without browser-side SQLite or log access
|
||||
|
||||
#### Scenario: no data is available
|
||||
- **WHEN** an operator opens a SCUM-enabled server with no completed data sync
|
||||
- **THEN** each dataset view displays an empty state rather than synthetic records
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Plugin Data Pack
|
||||
|
||||
- [x] 1.1 Extend plugin manifest schema and validator with package-relative SQL references and declared SCUM row targets, keys, and mappings.
|
||||
- [x] 1.2 Add tested SCUM v57 SQL assets for users, squads/members, activity, gifts, and map data.
|
||||
- [x] 1.3 Add plugin-declared UTF-16LE log parser and INI/JSON configuration mapping assets for the supplied SCUM formats.
|
||||
|
||||
## 2. Platform Data Plane
|
||||
|
||||
- [x] 2.1 Replace SCUM query-key projection dispatch with declaration-driven row ingestion contracts and tests.
|
||||
- [x] 2.2 Add scoped relational `scum_*` repository models/migrations and generic upsert/list operations for the first five datasets.
|
||||
- [x] 2.3 Expose server-instance APIs for persisted users, squads, activity, gifts, and map points.
|
||||
|
||||
## 3. Operations Console
|
||||
|
||||
- [x] 3.1 Add themed server-management views and API clients for the five persisted SCUM datasets with real empty states.
|
||||
|
||||
## 4. Verification and Delivery
|
||||
|
||||
- [x] 4.1 Run manifest, Platform, frontend, OpenSpec strict, and structure checks; record evidence.
|
||||
- [ ] 4.2 Stage only this change, commit on `main`, and push the configured remote.
|
||||
@@ -104,7 +104,12 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/datasets/squads", h.serverSCUMDataSquads)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/activity", h.serverSCUMActivity)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/gifts", h.serverSCUMGifts)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/map-points", h.serverSCUMMapPoints)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags)
|
||||
|
||||
@@ -21,6 +21,41 @@ func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMUsers(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetUsers)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMDataSquads(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetSquads)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMActivity(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetActivity)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMGifts(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetGifts)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMMapPoints(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetMapPoints)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMDataSet(w http.ResponseWriter, r *http.Request, target domain.SCUMDataSet) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter := scumProjectionFilterFromRequest(r, r.PathValue("id"))
|
||||
filter.TargetTable = target
|
||||
items, err := h.core.ListSCUMDataRowsForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMDataRowsFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
|
||||
@@ -21,8 +21,9 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}, {Key: "scum-data", Kind: "sqlite", TargetKey: "scum-data", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "v57.directory.people", Title: "SCUM users", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-data", TargetKey: "scum-data", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 200, TimeoutSeconds: 15, RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "displayName": "display_name"}}}}
|
||||
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
|
||||
if _, err := core.CreateGamePlugin(plugin); err != nil {
|
||||
@@ -40,6 +41,9 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil {
|
||||
t.Fatalf("seed projection: %v", err)
|
||||
}
|
||||
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "v57.directory.people", Sequence: 1, Checksum: "sha256:api-users", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-api", "display_name": "API User"}}}); err != nil {
|
||||
t.Fatalf("seed SCUM users: %v", err)
|
||||
}
|
||||
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
@@ -49,6 +53,10 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
|
||||
t.Fatalf("unexpected SCUM players response: %+v", players)
|
||||
}
|
||||
users := getJSONWithAuth[dto.SCUMDataRowListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/users", auth.SessionID)
|
||||
if users.Count != 1 || users.Items[0].Fields["profileId"] != "profile-api" || users.Items[0].Payload["display_name"] != "API User" {
|
||||
t.Fatalf("unexpected persisted SCUM users response: %+v", users)
|
||||
}
|
||||
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
|
||||
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
|
||||
t.Fatalf("unexpected SCUM operation response: %+v", operation)
|
||||
|
||||
@@ -69,8 +69,18 @@ type GameClientBridgeQueryTemplateDeclaration struct {
|
||||
TargetKey string
|
||||
ParameterSchemaRef string
|
||||
ResultSchemaRef string
|
||||
SQLRef string
|
||||
MaxRows int
|
||||
TimeoutSeconds int
|
||||
RowTarget *SCUMRowTargetDeclaration
|
||||
}
|
||||
|
||||
// SCUMRowTargetDeclaration is plugin-owned data-shaping metadata. The platform
|
||||
// only applies this declaration; it does not infer a destination from a query key.
|
||||
type SCUMRowTargetDeclaration struct {
|
||||
TargetTable string
|
||||
UpsertKeys []string
|
||||
ColumnMappings map[string]string
|
||||
}
|
||||
|
||||
type GameClientBridgeOperationKind string
|
||||
@@ -453,6 +463,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
}
|
||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
for index := range value.QueryTemplates {
|
||||
if value.QueryTemplates[index].RowTarget != nil {
|
||||
copy := CopySCUMRowTargetDeclaration(*value.QueryTemplates[index].RowTarget)
|
||||
value.QueryTemplates[index].RowTarget = ©
|
||||
}
|
||||
}
|
||||
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
|
||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
|
||||
|
||||
@@ -25,6 +25,7 @@ type SCUMProjectionFilter struct {
|
||||
FlagID string
|
||||
SubjectType SCUMProjectionSubject
|
||||
QueryKey string
|
||||
TargetTable SCUMDataSet
|
||||
Freshness SCUMProjectionFreshness
|
||||
Search string
|
||||
Limit int
|
||||
|
||||
@@ -85,6 +85,33 @@ type SCUMObservationResult struct {
|
||||
Rows []map[string]any
|
||||
}
|
||||
|
||||
type SCUMDataSet string
|
||||
|
||||
const (
|
||||
SCUMDataSetUsers SCUMDataSet = "scum_users"
|
||||
SCUMDataSetSquads SCUMDataSet = "scum_squads"
|
||||
SCUMDataSetMembers SCUMDataSet = "scum_squad_members"
|
||||
SCUMDataSetVehicles SCUMDataSet = "scum_vehicles"
|
||||
SCUMDataSetFlags SCUMDataSet = "scum_flags"
|
||||
SCUMDataSetActivity SCUMDataSet = "scum_activity_events"
|
||||
SCUMDataSetGifts SCUMDataSet = "scum_gift_catalogs"
|
||||
SCUMDataSetMapPoints SCUMDataSet = "scum_map_points"
|
||||
)
|
||||
|
||||
type SCUMDataRow struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
TargetTable SCUMDataSet
|
||||
UpsertKey string
|
||||
Fields map[string]any
|
||||
Payload map[string]any
|
||||
PluginID string
|
||||
QueryKey string
|
||||
Freshness SCUMProjectionFreshnessState
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMProjectionFreshnessState struct {
|
||||
Status SCUMProjectionFreshness
|
||||
ObservationID string
|
||||
@@ -232,6 +259,19 @@ func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResul
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMRowTargetDeclaration(value SCUMRowTargetDeclaration) SCUMRowTargetDeclaration {
|
||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||
value.ColumnMappings = CopyStringMap(value.ColumnMappings)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMDataRow(value SCUMDataRow) SCUMDataRow {
|
||||
value.Fields = CopyGameClientBridgePayload(value.Fields)
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeRows(values []map[string]any) []map[string]any {
|
||||
if values == nil {
|
||||
return nil
|
||||
|
||||
@@ -298,8 +298,12 @@ type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
SQLRef string `json:"sqlRef,omitempty"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
TargetTable string `json:"targetTable,omitempty"`
|
||||
UpsertKeys []string `json:"upsertKeys,omitempty"`
|
||||
ColumnMappings map[string]string `json:"columnMappings,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeOperationSafetyBody struct {
|
||||
@@ -1205,7 +1209,12 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
}
|
||||
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
var rowTarget *domain.SCUMRowTargetDeclaration
|
||||
if template.TargetTable != "" || len(template.UpsertKeys) > 0 || len(template.ColumnMappings) > 0 {
|
||||
value := domain.SCUMRowTargetDeclaration{TargetTable: template.TargetTable, UpsertKeys: domain.CopyStringSlice(template.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.ColumnMappings)}
|
||||
rowTarget = &value
|
||||
}
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget}
|
||||
}
|
||||
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
|
||||
for index, template := range body.OperationTemplates {
|
||||
@@ -1637,7 +1646,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
}
|
||||
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
body := GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
if template.RowTarget != nil {
|
||||
body.TargetTable = template.RowTarget.TargetTable
|
||||
body.UpsertKeys = domain.CopyStringSlice(template.RowTarget.UpsertKeys)
|
||||
body.ColumnMappings = domain.CopyStringMap(template.RowTarget.ColumnMappings)
|
||||
}
|
||||
queryTemplates[index] = body
|
||||
}
|
||||
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
|
||||
for index, template := range value.OperationTemplates {
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10,
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/scum-db-v57/users.sql", TargetTable: "scum_users", UpsertKeys: []string{"userProfileId"}, ColumnMappings: map[string]string{"userProfileId": "userProfileId"}, MaxRows: 50, TimeoutSeconds: 10,
|
||||
}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
@@ -145,7 +145,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].SQLRef != "sql/scum-db-v57/users.sql" || domainManifest.QueryTemplates[0].RowTarget == nil || domainManifest.QueryTemplates[0].RowTarget.TargetTable != "scum_users" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||
}
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
@@ -168,7 +168,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||
t.Fatalf("decode safe query template projection: %v", err)
|
||||
}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "targetTable", "upsertKeys", "columnMappings", "maxRows", "timeoutSeconds"}
|
||||
if len(projection) != len(expectedFields) {
|
||||
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package dto
|
||||
|
||||
import "browser.local/platform/domain"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type SCUMPlayerLiveStateListResponse struct {
|
||||
Items []domain.SCUMPlayerLiveState `json:"items"`
|
||||
@@ -32,6 +36,25 @@ type SCUMCurrentPositionListResponse struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMDataRowResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
TargetTable string `json:"targetTable"`
|
||||
UpsertKey string `json:"upsertKey"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
PluginID string `json:"pluginId"`
|
||||
QueryKey string `json:"queryKey"`
|
||||
Freshness domain.SCUMProjectionFreshnessState `json:"freshness"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SCUMDataRowListResponse struct {
|
||||
Items []SCUMDataRowResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse {
|
||||
out := make([]domain.SCUMPlayerLiveState, len(values))
|
||||
for index, value := range values {
|
||||
@@ -79,3 +102,12 @@ func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCur
|
||||
}
|
||||
return SCUMCurrentPositionListResponse{Items: out, Count: len(out)}
|
||||
}
|
||||
|
||||
func SCUMDataRowsFromDomain(values []domain.SCUMDataRow) SCUMDataRowListResponse {
|
||||
items := make([]SCUMDataRowResponse, len(values))
|
||||
for index, value := range values {
|
||||
value = domain.CopySCUMDataRow(value)
|
||||
items[index] = SCUMDataRowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, TargetTable: string(value.TargetTable), UpsertKey: value.UpsertKey, Fields: value.Fields, Payload: value.Payload, PluginID: value.PluginID, QueryKey: value.QueryKey, Freshness: value.Freshness, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
||||
}
|
||||
return SCUMDataRowListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ type StoreSnapshot struct {
|
||||
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
|
||||
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
|
||||
SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"`
|
||||
SCUMDataRows []domain.SCUMDataRow `json:"scumDataRows"`
|
||||
SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"`
|
||||
SCUMSquads []domain.SCUMSquad `json:"scumSquads"`
|
||||
SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"`
|
||||
@@ -247,6 +248,9 @@ func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMDataRows() SCUMDataRowRepository {
|
||||
return &persistentRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataRows, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
|
||||
}
|
||||
@@ -347,7 +351,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), 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), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
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), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +397,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
|
||||
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
|
||||
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
|
||||
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
|
||||
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
|
||||
|
||||
@@ -205,7 +205,10 @@ func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
|
||||
return &mysqlSCUMObservationRepository{repository: store.MemoryStore.scumDataObservations, store: store}
|
||||
}
|
||||
func (store *MySQLStore) SCUMDataRows() SCUMDataRowRepository {
|
||||
return &mysqlSCUMDataRowRepository{repository: store.MemoryStore.scumDataRows, store: store}
|
||||
}
|
||||
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
|
||||
@@ -250,9 +253,158 @@ CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
|
||||
}
|
||||
for _, table := range []string{"scum_sync_runs", "scum_users", "scum_squads", "scum_squad_members", "scum_vehicles", "scum_flags", "scum_activity_events", "scum_gift_catalogs", "scum_map_points"} {
|
||||
statement := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
upsert_key VARCHAR(512) NOT NULL,
|
||||
fields_json JSON NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
plugin_id VARCHAR(191) NOT NULL,
|
||||
query_key VARCHAR(191) NOT NULL,
|
||||
freshness_json JSON NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
INDEX %s_server_updated (server_instance_id, updated_at)
|
||||
)`, table, table)
|
||||
if _, err := store.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("create mysql %s table: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mysqlSCUMDataRowRepository struct {
|
||||
repository mutableRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
|
||||
store *MySQLStore
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMDataRowRepository) Create(value domain.SCUMDataRow) error {
|
||||
if err := repository.repository.Create(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMDataRow(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) Get(id string) (domain.SCUMDataRow, error) {
|
||||
return repository.repository.Get(id)
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
|
||||
return repository.repository.List(filter)
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) Update(value domain.SCUMDataRow) error {
|
||||
if err := repository.repository.Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMDataRow(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
|
||||
type mysqlSCUMObservationRepository struct {
|
||||
repository mutableRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
|
||||
store *MySQLStore
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMObservationRepository) Create(value domain.SCUMDataObservation) error {
|
||||
if err := repository.repository.Create(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMSyncRun(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) Get(id string) (domain.SCUMDataObservation, error) {
|
||||
return repository.repository.Get(id)
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) {
|
||||
return repository.repository.List(filter)
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) Update(value domain.SCUMDataObservation) error {
|
||||
if err := repository.repository.Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMSyncRun(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persistSCUMDataRow(value domain.SCUMDataRow) error {
|
||||
table, ok := mysqlSCUMTable(value.TargetTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported SCUM data target %q", value.TargetTable)
|
||||
}
|
||||
fields, err := json.Marshal(value.Fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM fields: %w", err)
|
||||
}
|
||||
payload, err := json.Marshal(value.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM payload: %w", err)
|
||||
}
|
||||
freshness, err := json.Marshal(value.Freshness)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM freshness: %w", err)
|
||||
}
|
||||
return store.upsertSCUMPhysicalRow(table, value.ID, value.ServerInstanceID, value.UpsertKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.CreatedAt, value.UpdatedAt)
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persistSCUMSyncRun(value domain.SCUMDataObservation) error {
|
||||
freshness, err := json.Marshal(domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFreshness(value.Status), Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM sync freshness: %w", err)
|
||||
}
|
||||
fields, _ := json.Marshal(map[string]any{"status": value.Status, "errorCode": value.ErrorCode, "observedAt": value.ObservedAt, "receivedAt": value.ReceivedAt})
|
||||
payload, _ := json.Marshal(value.SafeSummary)
|
||||
return store.upsertSCUMPhysicalRow("scum_sync_runs", value.ID, value.ServerInstanceID, value.QueryKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.ReceivedAt, value.ReceivedAt)
|
||||
}
|
||||
|
||||
func (store *MySQLStore) upsertSCUMPhysicalRow(table, id, serverInstanceID, upsertKey string, fields, payload []byte, pluginID, queryKey string, freshness []byte, createdAt, updatedAt time.Time) error {
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
statement := fmt.Sprintf(`INSERT INTO %s (id, server_instance_id, upsert_key, fields_json, payload_json, plugin_id, query_key, freshness_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE server_instance_id=VALUES(server_instance_id), upsert_key=VALUES(upsert_key), fields_json=VALUES(fields_json), payload_json=VALUES(payload_json), plugin_id=VALUES(plugin_id), query_key=VALUES(query_key), freshness_json=VALUES(freshness_json), updated_at=VALUES(updated_at)`, table)
|
||||
if _, err := store.db.ExecContext(ctx, statement, id, serverInstanceID, upsertKey, string(fields), string(payload), pluginID, queryKey, string(freshness), createdAt, updatedAt); err != nil {
|
||||
return fmt.Errorf("write mysql %s row: %w", table, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlSCUMTable(target domain.SCUMDataSet) (string, bool) {
|
||||
switch target {
|
||||
case domain.SCUMDataSetUsers:
|
||||
return "scum_users", true
|
||||
case domain.SCUMDataSetSquads:
|
||||
return "scum_squads", true
|
||||
case domain.SCUMDataSetMembers:
|
||||
return "scum_squad_members", true
|
||||
case domain.SCUMDataSetVehicles:
|
||||
return "scum_vehicles", true
|
||||
case domain.SCUMDataSetFlags:
|
||||
return "scum_flags", true
|
||||
case domain.SCUMDataSetActivity:
|
||||
return "scum_activity_events", true
|
||||
case domain.SCUMDataSetGifts:
|
||||
return "scum_gift_catalogs", true
|
||||
case domain.SCUMDataSetMapPoints:
|
||||
return "scum_map_points", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) load() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -324,7 +476,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), 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), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
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), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +522,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
|
||||
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
|
||||
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
|
||||
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
|
||||
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
|
||||
|
||||
@@ -2,6 +2,7 @@ package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -301,6 +302,12 @@ type SCUMDataObservationRepository interface {
|
||||
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error)
|
||||
Update(domain.SCUMDataObservation) error
|
||||
}
|
||||
type SCUMDataRowRepository interface {
|
||||
Create(domain.SCUMDataRow) error
|
||||
Get(string) (domain.SCUMDataRow, error)
|
||||
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
|
||||
Update(domain.SCUMDataRow) error
|
||||
}
|
||||
type SCUMPlayerLiveStateRepository interface {
|
||||
Create(domain.SCUMPlayerLiveState) error
|
||||
Get(string) (domain.SCUMPlayerLiveState, error)
|
||||
@@ -398,6 +405,7 @@ type Store interface {
|
||||
GameGiftRevisions() GameGiftRevisionRepository
|
||||
GameGiftGrants() GameGiftGrantRepository
|
||||
SCUMDataObservations() SCUMDataObservationRepository
|
||||
SCUMDataRows() SCUMDataRowRepository
|
||||
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
|
||||
SCUMSquads() SCUMSquadRepository
|
||||
SCUMSquadMembers() SCUMSquadMemberRepository
|
||||
@@ -451,6 +459,7 @@ type MemoryStore struct {
|
||||
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
|
||||
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
|
||||
scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
|
||||
scumDataRows *memoryRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
|
||||
scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]
|
||||
scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]
|
||||
scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]
|
||||
@@ -609,6 +618,7 @@ func NewMemoryStore() *MemoryStore {
|
||||
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),
|
||||
scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation),
|
||||
scumDataRows: newMemoryRepository(func(v domain.SCUMDataRow) string { return v.ID }, domain.CopySCUMDataRow, matchSCUMDataRow),
|
||||
scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState),
|
||||
scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad),
|
||||
scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember),
|
||||
@@ -700,6 +710,7 @@ func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return stor
|
||||
func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return store.scumDataObservations
|
||||
}
|
||||
func (store *MemoryStore) SCUMDataRows() SCUMDataRowRepository { return store.scumDataRows }
|
||||
func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return store.scumPlayerLiveStates
|
||||
}
|
||||
@@ -1064,6 +1075,15 @@ func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProject
|
||||
(f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness)
|
||||
}
|
||||
|
||||
func matchSCUMDataRow(v domain.SCUMDataRow, f domain.SCUMProjectionFilter) bool {
|
||||
search := strings.ToLower(strings.TrimSpace(f.Search))
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
|
||||
(f.QueryKey == "" || v.QueryKey == f.QueryKey) &&
|
||||
(f.TargetTable == "" || v.TargetTable == f.TargetTable) &&
|
||||
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
|
||||
(search == "" || strings.Contains(strings.ToLower(v.UpsertKey), search) || strings.Contains(strings.ToLower(fmt.Sprint(v.Fields)), search))
|
||||
}
|
||||
|
||||
func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool {
|
||||
search := strings.ToLower(strings.TrimSpace(f.Search))
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
|
||||
|
||||
@@ -231,6 +231,7 @@ type Core interface {
|
||||
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
|
||||
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
|
||||
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
|
||||
ListSCUMDataRowsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
|
||||
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
|
||||
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
|
||||
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
|
||||
|
||||
@@ -59,12 +59,28 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
|
||||
return observation, nil
|
||||
}
|
||||
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
|
||||
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
|
||||
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
|
||||
if err != nil {
|
||||
return domain.SCUMDataObservation{}, err
|
||||
}
|
||||
if err := svc.applySCUMRows(target, result.PluginID, result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
|
||||
return domain.SCUMDataObservation{}, err
|
||||
}
|
||||
return observation, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMDataRowsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := svc.store.SCUMDataRows().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limitSCUMProjectionSlice(&values, filter.Limit)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
@@ -174,44 +190,39 @@ func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObserva
|
||||
return svc.store.SCUMDataObservations().Create(observation)
|
||||
}
|
||||
|
||||
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
lower := strings.ToLower(queryKey)
|
||||
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
|
||||
func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
if target.TargetTable != "" {
|
||||
for _, row := range rows {
|
||||
if err := svc.upsertSCUMDataRow(target, pluginID, queryKey, serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Compatibility declarations retain the old projections without guessing from substrings.
|
||||
for _, row := range rows {
|
||||
switch queryKey {
|
||||
case "scum.player.profile":
|
||||
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
|
||||
for _, row := range rows {
|
||||
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(lower, "squad") {
|
||||
for _, row := range rows {
|
||||
case "scum.squads":
|
||||
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
case "scum.squad-members":
|
||||
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "vehicle") {
|
||||
for _, row := range rows {
|
||||
case "scum.vehicles":
|
||||
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "flag") {
|
||||
for _, row := range rows {
|
||||
case "scum.flags":
|
||||
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
|
||||
for _, row := range rows {
|
||||
case "scum.positions":
|
||||
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -220,6 +231,78 @@ func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) scumRowTarget(pluginID, queryKey string) (domain.SCUMRowTargetDeclaration, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(pluginID)
|
||||
if err != nil {
|
||||
return domain.SCUMRowTargetDeclaration{}, err
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == queryKey && template.RowTarget != nil {
|
||||
return domain.CopySCUMRowTargetDeclaration(*template.RowTarget), nil
|
||||
}
|
||||
}
|
||||
if _, ok := legacySCUMQueryKeys[queryKey]; ok {
|
||||
return domain.SCUMRowTargetDeclaration{}, nil
|
||||
}
|
||||
return domain.SCUMRowTargetDeclaration{}, validationError("queryKey does not declare a SCUM row target")
|
||||
}
|
||||
|
||||
var legacySCUMQueryKeys = map[string]struct{}{
|
||||
"scum.player.profile": {}, "scum.squads": {}, "scum.squad-members": {}, "scum.vehicles": {}, "scum.flags": {}, "scum.positions": {},
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertSCUMDataRow(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
table := domain.SCUMDataSet(strings.TrimSpace(target.TargetTable))
|
||||
if !validSCUMDataSet(table) || len(target.UpsertKeys) == 0 {
|
||||
return validationError("SCUM row target is invalid")
|
||||
}
|
||||
fields := map[string]any{}
|
||||
for destination, source := range target.ColumnMappings {
|
||||
if value, ok := row[source]; ok {
|
||||
fields[destination] = value
|
||||
}
|
||||
}
|
||||
keyValues := make([]string, 0, len(target.UpsertKeys))
|
||||
for _, key := range target.UpsertKeys {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
value, ok = row[key]
|
||||
}
|
||||
text := firstString(map[string]any{"value": value}, "value")
|
||||
if !ok || text == "" {
|
||||
return validationError("SCUM row is missing declared upsert key " + key)
|
||||
}
|
||||
keyValues = append(keyValues, text)
|
||||
}
|
||||
upsertKey := strings.Join(keyValues, "\x00")
|
||||
id := scumProjectionID(string(table), serverID, upsertKey)
|
||||
value, err := svc.store.SCUMDataRows().Get(id)
|
||||
if err == repo.ErrNotFound {
|
||||
value = domain.SCUMDataRow{ID: id, ServerInstanceID: serverID, TargetTable: table, UpsertKey: upsertKey, CreatedAt: svc.now()}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if isProjectionOlder(freshness, value.Freshness) {
|
||||
return nil
|
||||
}
|
||||
value.Fields = domain.CopyGameClientBridgePayload(fields)
|
||||
value.Payload = domain.CopyGameClientBridgePayload(row)
|
||||
value.PluginID, value.QueryKey, value.Freshness, value.UpdatedAt = pluginID, queryKey, freshness, svc.now()
|
||||
if err == repo.ErrNotFound {
|
||||
return svc.store.SCUMDataRows().Create(value)
|
||||
}
|
||||
return svc.store.SCUMDataRows().Update(value)
|
||||
}
|
||||
|
||||
func validSCUMDataSet(value domain.SCUMDataSet) bool {
|
||||
switch value {
|
||||
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGifts, domain.SCUMDataSetMapPoints:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
|
||||
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
|
||||
@@ -558,8 +641,27 @@ func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition)
|
||||
|
||||
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
|
||||
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
|
||||
lower := strings.ToLower(result.QueryKey)
|
||||
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
|
||||
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.TargetTable != "" {
|
||||
values, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID, TargetTable: domain.SCUMDataSet(target.TargetTable)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if !isProjectionOlder(freshness, value.Freshness) {
|
||||
value.Freshness, value.UpdatedAt = freshness, svc.now()
|
||||
if err := svc.store.SCUMDataRows().Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch result.QueryKey {
|
||||
case "scum.player.profile":
|
||||
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -573,8 +675,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "squad") {
|
||||
case "scum.squads", "scum.squad-members":
|
||||
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -588,8 +689,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "vehicle") {
|
||||
case "scum.vehicles":
|
||||
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -603,8 +703,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "flag") {
|
||||
case "scum.flags":
|
||||
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -108,3 +108,29 @@ func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *t
|
||||
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMObservationUsesDeclaredRowTargetInsteadOfQueryKeyName(t *testing.T) {
|
||||
svc, _ := newRegisteredLogIngestService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("server.scum")
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
plugin.GameClientBridge.QueryTemplates = append(plugin.GameClientBridge.QueryTemplates, domain.GameClientBridgeQueryTemplateDeclaration{Key: "v57.catalog.people", RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "name": "display_name"}}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
_, err = svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "v57.catalog.people", Sequence: 1, ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-1", "display_name": "Moon", "unmapped": "kept"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("apply declared data row: %v", err)
|
||||
}
|
||||
rows, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", TargetTable: domain.SCUMDataSetUsers})
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
if rows[0].Fields["profileId"] != "profile-1" || rows[0].Payload["unmapped"] != "kept" {
|
||||
t.Fatalf("unexpected declared row: %+v", rows[0])
|
||||
}
|
||||
if states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1"}); err != nil || len(states) != 0 {
|
||||
t.Fatalf("query key leaked into legacy projection dispatch: states=%+v err=%v", states, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +578,22 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if template.RowTarget != nil {
|
||||
rowTarget := template.RowTarget
|
||||
if !validSCUMTargetTable(rowTarget.TargetTable) || len(rowTarget.UpsertKeys) == 0 {
|
||||
violations = append(violations, prefix+".rowTarget must declare an allowed scum_* table and upsert keys")
|
||||
}
|
||||
for _, key := range rowTarget.UpsertKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".rowTarget upsert key is invalid")
|
||||
}
|
||||
}
|
||||
for destination, source := range rowTarget.ColumnMappings {
|
||||
if !clientManagerIdentifierPattern.MatchString(destination) || !clientManagerIdentifierPattern.MatchString(source) {
|
||||
violations = append(violations, prefix+".rowTarget column mapping is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
transport, exists := transports[template.TransportKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
@@ -764,6 +780,15 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
return violations
|
||||
}
|
||||
|
||||
func validSCUMTargetTable(value string) bool {
|
||||
switch value {
|
||||
case "scum_users", "scum_squads", "scum_activity_events", "scum_gift_catalogs", "scum_map_points":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
|
||||
@@ -245,7 +245,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/users")) return jsonResponse({ items: [{ id: "scum-user-1", serverInstanceId: server.id, displayName: "Prisoner One", steamId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/datasets/squads")) return jsonResponse({ items: [{ id: "squad-1", serverInstanceId: server.id, fields: { squadId: "squad-1", name: "Alpha" } }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/activity")) return jsonResponse({ items: [{ id: "activity-1", serverInstanceId: server.id, kind: "login" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/gifts")) return jsonResponse({ items: [{ id: "gift-1", serverInstanceId: server.id, name: "Starter" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/map-points")) return jsonResponse({ items: [{ id: "point-1", serverInstanceId: server.id, kind: "base" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
|
||||
@@ -555,7 +559,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||
await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
|
||||
await expect(client.listSCUMUsers(server.id)).resolves.toMatchObject({ count: 1, items: [{ steamId: "steam-1" }] });
|
||||
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMActivity(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "login" }] });
|
||||
await expect(client.listSCUMGifts(server.id)).resolves.toMatchObject({ count: 1, items: [{ name: "Starter" }] });
|
||||
await expect(client.listSCUMMapPoints(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "base" }] });
|
||||
await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
@@ -624,7 +632,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(48);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(52);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
|
||||
@@ -102,9 +102,14 @@ import type {
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
SCUMListResponse,
|
||||
SCUMActivityListResponse,
|
||||
SCUMGiftsListResponse,
|
||||
SCUMMapPointsListResponse,
|
||||
SCUMOperationListResponse,
|
||||
SCUMOperationRequest,
|
||||
SCUMOperationResponse,
|
||||
SCUMSquadsListResponse,
|
||||
SCUMUsersListResponse,
|
||||
SCUMWorkflowCreateRequest,
|
||||
SCUMWorkflowListResponse,
|
||||
SCUMWorkflowResponse,
|
||||
@@ -587,8 +592,24 @@ export class PlatformApiClient {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
|
||||
}
|
||||
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
|
||||
async listSCUMUsers(serverInstanceId: string): Promise<SCUMUsersListResponse> {
|
||||
return this.request<SCUMUsersListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users`);
|
||||
}
|
||||
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMSquadsListResponse> {
|
||||
return this.request<SCUMSquadsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/datasets/squads`);
|
||||
}
|
||||
|
||||
async listSCUMActivity(serverInstanceId: string): Promise<SCUMActivityListResponse> {
|
||||
return this.request<SCUMActivityListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/activity`);
|
||||
}
|
||||
|
||||
async listSCUMGifts(serverInstanceId: string): Promise<SCUMGiftsListResponse> {
|
||||
return this.request<SCUMGiftsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/gifts`);
|
||||
}
|
||||
|
||||
async listSCUMMapPoints(serverInstanceId: string): Promise<SCUMMapPointsListResponse> {
|
||||
return this.request<SCUMMapPointsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/map-points`);
|
||||
}
|
||||
|
||||
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
|
||||
@@ -1375,6 +1375,17 @@ export interface RemoteAdapterResponse {
|
||||
|
||||
export type SCUMJsonRecord = Record<string, unknown>;
|
||||
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
|
||||
export interface SCUMPersistedRecord { id: string; serverInstanceId: string; observedAt?: string; syncedAt?: string; payload?: SCUMJsonRecord; }
|
||||
export interface SCUMUserRecord extends SCUMPersistedRecord { userProfileId?: string; steamId?: string; displayName?: string; famePoints?: number; normalBalance?: number; goldBalance?: number; lastLoginTime?: string; lastLogoutTime?: string; isAlive?: boolean; }
|
||||
export interface SCUMSquadRecord extends SCUMPersistedRecord { squadId?: string; name?: string; message?: string; score?: number; memberLimit?: number; memberCount?: number; }
|
||||
export interface SCUMActivityEventRecord extends SCUMPersistedRecord { eventId?: string; kind?: string; subjectId?: string; subjectName?: string; summary?: string; occurredAt?: string; locationX?: number; locationY?: number; locationZ?: number; }
|
||||
export interface SCUMGiftRecord extends SCUMPersistedRecord { giftId?: string; name?: string; status?: string; recipientUserProfileId?: string; recipientName?: string; availableAt?: string; expiresAt?: string; }
|
||||
export interface SCUMMapPointRecord extends SCUMPersistedRecord { pointId?: string; kind?: string; label?: string; locationX?: number; locationY?: number; locationZ?: number; mapId?: string; }
|
||||
export type SCUMUsersListResponse = SCUMListResponse<SCUMUserRecord>;
|
||||
export type SCUMSquadsListResponse = SCUMListResponse<SCUMSquadRecord>;
|
||||
export type SCUMActivityListResponse = SCUMListResponse<SCUMActivityEventRecord>;
|
||||
export type SCUMGiftsListResponse = SCUMListResponse<SCUMGiftRecord>;
|
||||
export type SCUMMapPointsListResponse = SCUMListResponse<SCUMMapPointRecord>;
|
||||
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
|
||||
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "./SCUMPersistedDataView";
|
||||
|
||||
describe("SCUMPersistedDataView", () => {
|
||||
it("maps the five declared SCUM plugin routes to persisted datasets", () => {
|
||||
expect(scumDatasetForPluginRoute("players")).toBe("users");
|
||||
expect(scumDatasetForPluginRoute("squads")).toBe("squads");
|
||||
expect(scumDatasetForPluginRoute("workflows")).toBe("activity");
|
||||
expect(scumDatasetForPluginRoute("gifts")).toBe("gifts");
|
||||
expect(scumDatasetForPluginRoute("live-map")).toBe("map");
|
||||
});
|
||||
|
||||
it("renders a themed loading state without synthetic records", () => {
|
||||
const html = renderToStaticMarkup(<SCUMPersistedDataView serverInstanceId="server-1" dataset="users" />);
|
||||
expect(html).toContain("正在读取用户同步数据");
|
||||
expect(html).not.toContain("Prisoner One");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Activity, Gift, MapPinned, RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { SCUMActivityEventRecord, SCUMGiftRecord, SCUMMapPointRecord, SCUMSquadRecord, SCUMUserRecord } from "../api/types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
export type SCUMPersistedDataset = "users" | "squads" | "activity" | "gifts" | "map";
|
||||
|
||||
interface SCUMPersistedDataViewProps {
|
||||
serverInstanceId: string;
|
||||
dataset: SCUMPersistedDataset;
|
||||
}
|
||||
|
||||
type DatasetRecord = (SCUMUserRecord | SCUMSquadRecord | SCUMActivityEventRecord | SCUMGiftRecord | SCUMMapPointRecord) & { fields?: Record<string, unknown>; updatedAt?: string; freshness?: { observedAt?: string } };
|
||||
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number };
|
||||
|
||||
const datasetCopy: Record<SCUMPersistedDataset, { title: string; summary: string; empty: string; icon: typeof UsersRound }> = {
|
||||
users: { title: "用户", summary: "来自 SCUM 用户、角色和账户记录的已同步数据。", empty: "还没有完成的用户同步记录。", icon: UsersRound },
|
||||
squads: { title: "队伍", summary: "来自 SCUM 队伍和成员记录的已同步数据。", empty: "还没有完成的队伍同步记录。", icon: UsersRound },
|
||||
activity: { title: "活动", summary: "来自已解析日志和活动记录的持久化事件。", empty: "还没有完成的活动同步记录。", icon: Activity },
|
||||
gifts: { title: "礼包", summary: "来自 SCUM 礼包目录和发放记录的已同步数据。", empty: "还没有完成的礼包同步记录。", icon: Gift },
|
||||
map: { title: "地图", summary: "来自基地、旗帜、载具和实体坐标的已同步地图点。", empty: "还没有完成的地图同步记录。", icon: MapPinned }
|
||||
};
|
||||
|
||||
export function scumDatasetForPluginRoute(routeKey: string): SCUMPersistedDataset | null {
|
||||
if (routeKey === "players") return "users";
|
||||
if (routeKey === "squads") return "squads";
|
||||
if (routeKey === "workflows") return "activity";
|
||||
if (routeKey === "gifts") return "gifts";
|
||||
if (routeKey === "live-map") return "map";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SCUMPersistedDataView({ serverInstanceId, dataset }: SCUMPersistedDataViewProps) {
|
||||
const copy = datasetCopy[dataset];
|
||||
const load = useCallback(async () => {
|
||||
if (!serverInstanceId) return { status: "error" as const, reason: "插件页面没有绑定服务器。" };
|
||||
try {
|
||||
const response = dataset === "users" ? await platformApiClient.listSCUMUsers(serverInstanceId)
|
||||
: dataset === "squads" ? await platformApiClient.listSCUMSquads(serverInstanceId)
|
||||
: dataset === "activity" ? await platformApiClient.listSCUMActivity(serverInstanceId)
|
||||
: dataset === "gifts" ? await platformApiClient.listSCUMGifts(serverInstanceId)
|
||||
: await platformApiClient.listSCUMMapPoints(serverInstanceId);
|
||||
return { status: "ready" as const, items: response.items.map(normalizePersistedRow) as DatasetRecord[], count: response.count };
|
||||
} catch (error) {
|
||||
return { status: "error" as const, reason: error instanceof Error ? error.message : `${copy.title}数据加载失败。` };
|
||||
}
|
||||
}, [copy.title, dataset, serverInstanceId]);
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
return <section className="console-panel" aria-label={`SCUM ${copy.title}`}>
|
||||
<div className="panel-header">
|
||||
<div><h2><copy.icon size={16} aria-hidden="true" /> {copy.title}</h2><p className="provider-id">{copy.summary}</p></div>
|
||||
<button type="button" className="icon-command" onClick={refresh} disabled={state.status === "loading"}><RefreshCw size={14} aria-hidden="true" /><span>刷新</span></button>
|
||||
</div>
|
||||
{state.status === "loading" && <LoadingState compact label={`正在读取${copy.title}同步数据…`} />}
|
||||
{state.status === "error" && <ErrorState compact title={`${copy.title}数据不可用`} reason={state.reason} onRetry={refresh} />}
|
||||
{state.status === "ready" && (state.items.length ? <SCUMDatasetTable dataset={dataset} items={state.items} count={state.count} /> : <EmptyState title={`暂无${copy.title}数据`} description={copy.empty} icon={<ShieldAlert size={26} />} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) {
|
||||
const columns = useMemo(() => tableColumns(dataset), [dataset]);
|
||||
return <div className="console-record-list">
|
||||
<dl className="console-stat-strip"><div><dt>已同步</dt><dd>{count}</dd></div><div><dt>当前展示</dt><dd>{Math.min(items.length, 100)}</dd></div><div><dt>数据源</dt><dd>平台数据库</dd></div></dl>
|
||||
<div className="resource-table-wrap"><table className="resource-table"><thead><tr>{columns.map((column) => <th key={column.label}>{column.label}</th>)}</tr></thead><tbody>{items.slice(0, 100).map((item) => <tr key={item.id}>{columns.map((column) => <td key={column.label}>{column.value(item)}</td>)}</tr>)}</tbody></table></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
type TableColumn = { label: string; value: (record: DatasetRecord) => string };
|
||||
function tableColumns(dataset: SCUMPersistedDataset): TableColumn[] {
|
||||
if (dataset === "users") return [column("名称", (value) => (value as SCUMUserRecord).displayName || "未命名"), column("Steam", (value) => (value as SCUMUserRecord).steamId), column("声望", (value) => numberText((value as SCUMUserRecord).famePoints)), column("余额", (value) => numberText((value as SCUMUserRecord).normalBalance)), column("最后登录", (value) => dateText((value as SCUMUserRecord).lastLoginTime || value.observedAt))];
|
||||
if (dataset === "squads") return [column("队伍", (value) => (value as SCUMSquadRecord).name || "未命名"), column("成员", (value) => numberText((value as SCUMSquadRecord).memberCount)), column("上限", (value) => numberText((value as SCUMSquadRecord).memberLimit)), column("分数", (value) => numberText((value as SCUMSquadRecord).score)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
||||
if (dataset === "activity") return [column("事件", (value) => (value as SCUMActivityEventRecord).kind || "活动"), column("对象", (value) => (value as SCUMActivityEventRecord).subjectName || (value as SCUMActivityEventRecord).subjectId), column("摘要", (value) => (value as SCUMActivityEventRecord).summary), column("发生时间", (value) => dateText((value as SCUMActivityEventRecord).occurredAt || value.observedAt)), column("坐标", (value) => coordinates(value as SCUMActivityEventRecord))];
|
||||
if (dataset === "gifts") return [column("礼包", (value) => (value as SCUMGiftRecord).name || "未命名礼包"), column("状态", (value) => (value as SCUMGiftRecord).status || "未知"), column("接收者", (value) => (value as SCUMGiftRecord).recipientName || (value as SCUMGiftRecord).recipientUserProfileId), column("可用时间", (value) => dateText((value as SCUMGiftRecord).availableAt)), column("过期时间", (value) => dateText((value as SCUMGiftRecord).expiresAt))];
|
||||
return [column("标记", (value) => (value as SCUMMapPointRecord).label || (value as SCUMMapPointRecord).kind || "地图点"), column("类型", (value) => (value as SCUMMapPointRecord).kind), column("地图", (value) => (value as SCUMMapPointRecord).mapId), column("坐标", (value) => coordinates(value as SCUMMapPointRecord)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
||||
}
|
||||
|
||||
function column(label: string, getValue: (record: DatasetRecord) => unknown): TableColumn { return { label, value: (record) => text(getValue(record)) }; }
|
||||
function normalizePersistedRow(row: DatasetRecord): DatasetRecord {
|
||||
const fields = row.fields ?? {};
|
||||
return { ...row, ...fields, syncedAt: row.updatedAt ?? row.syncedAt, observedAt: row.freshness?.observedAt ?? row.observedAt };
|
||||
}
|
||||
function text(value: unknown): string { return value === undefined || value === null || value === "" ? "--" : String(value); }
|
||||
function numberText(value: number | undefined): string { return value === undefined || value === null ? "--" : new Intl.NumberFormat("zh-CN").format(value); }
|
||||
function dateText(value: string | undefined): string { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
function coordinates(value: Pick<SCUMActivityEventRecord, "locationX" | "locationY" | "locationZ"> | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; }
|
||||
@@ -1,6 +1,10 @@
|
||||
export interface PluginPageWorkspaceActions {
|
||||
listSCUMUsers?: () => Promise<unknown>;
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMActivity?: () => Promise<unknown>;
|
||||
listSCUMGifts?: () => Promise<unknown>;
|
||||
listSCUMMapPoints?: () => Promise<unknown>;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
|
||||
@@ -85,10 +85,10 @@ describe("PluginPageHostPage", () => {
|
||||
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
||||
});
|
||||
|
||||
it("does not mount a bundle without client-side availability validation", () => {
|
||||
it("renders the persisted SCUM dataset surface without a browser-side bundle dependency", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
||||
expect(html).toContain("未绑定服务器");
|
||||
expect(html).toContain("正在校验并加载插件页面 bundle");
|
||||
expect(html).toContain("正在读取用户同步数据");
|
||||
});
|
||||
|
||||
it("remains a manifest-driven host without SCUM component imports or game branches", () => {
|
||||
@@ -100,6 +100,11 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
|
||||
expect(hostSource).toContain("hostContextRef.current = hostContext");
|
||||
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
|
||||
expect(hostSource).toContain("listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId)");
|
||||
expect(hostSource).toContain("listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId)");
|
||||
expect(hostSource).toContain("listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId)");
|
||||
expect(hostSource).toContain("listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId)");
|
||||
expect(hostSource).toContain("SCUMPersistedDataView");
|
||||
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
|
||||
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
|
||||
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType }
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { PageFrame } from "../components/PageFrame";
|
||||
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "../components/SCUMPersistedDataView";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
@@ -69,8 +70,12 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
||||
if (!pluginId) return undefined;
|
||||
return {
|
||||
listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId),
|
||||
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
|
||||
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
|
||||
listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId),
|
||||
listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId),
|
||||
listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId),
|
||||
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
|
||||
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
|
||||
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
|
||||
@@ -112,12 +117,14 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
if (!hostContext) {
|
||||
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
|
||||
}
|
||||
const scumDataset = state.plugin.serverType === "scum" ? scumDatasetForPluginRoute(routeKey) : null;
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
|
||||
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -145,9 +152,10 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
|
||||
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||
</section>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
|
||||
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"maps": [
|
||||
{ "key": "server-settings", "format": "ini", "encoding": "utf-8", "fileName": "ServerSettings.ini", "section": "General", "fields": { "scum.ServerName": "serverName", "scum.ServerDescription": "serverDescription", "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd", "scum.MaxPlayers": "maxPlayers", "scum.ServerPlaystyle": "playstyle" } },
|
||||
{ "key": "admin-users", "format": "ini-list", "encoding": "utf-8", "fileName": "AdminUsers.ini", "fields": { "steamId": "steamId", "permissions": "permissions" } },
|
||||
{ "key": "banned-users", "format": "ini-list", "encoding": "utf-8", "fileName": "BannedUsers.ini", "fields": { "steamId": "steamId" } },
|
||||
{ "key": "whitelisted-users", "format": "ini-list", "encoding": "utf-8", "fileName": "WhitelistedUsers.ini", "fields": { "steamId": "steamId" } },
|
||||
{ "key": "economy-override", "format": "json", "encoding": "utf-8", "fileName": "EconomyOverride.json", "rootPath": "economy-override", "fields": { "traders": "traders", "tradeable-code-prices": "tradeable-code-prices" } },
|
||||
{ "key": "raid-times", "format": "json", "encoding": "utf-8", "fileName": "RaidTimes.json", "rootPath": "raiding-times", "fields": { "Weekdays": "weekdays", "Weekend": "weekend" } },
|
||||
{ "key": "notifications", "format": "json", "encoding": "utf-8", "fileName": "Notifications.json", "rootPath": "Notifications", "fields": { "Notifications": "notifications" } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"encoding": "utf-16le",
|
||||
"lineEnding": "crlf",
|
||||
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
|
||||
"parsers": [
|
||||
{ "key": "login", "eventType": "scum.login", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged in at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
|
||||
{ "key": "logout", "eventType": "scum.logout", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged out at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
|
||||
{ "key": "chat", "eventType": "scum.chat", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' '([^:]+): (.*)'$", "fields": ["occurredAt", "steamId", "displayName", "channel", "message"] },
|
||||
{ "key": "admin", "eventType": "scum.admin", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' Command: '(.*)'$", "fields": ["occurredAt", "steamId", "displayName", "command"] },
|
||||
{ "key": "vehicle-destruction", "eventType": "scum.vehicle.destruction", "pattern": "^([^:]+): \\[VehicleInactiveTimerReached\\] ([^.]+)\\. VehicleId: ([0-9]+)\\. Owner: (.+)\\. Location: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "vehicleClass", "vehicleId", "owner", "x", "y", "z"] }
|
||||
]
|
||||
}
|
||||
@@ -292,6 +292,10 @@
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/users.sql",
|
||||
"targetTable": "scum_users",
|
||||
"upsertKeys": ["userProfileId"],
|
||||
"columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastSaveTime": "lastSaveTime", "lastLoginTime": "lastLoginTime", "lastLogoutTime": "lastLogoutTime", "isAlive": "isAlive" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
@@ -304,6 +308,10 @@
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/squads.sql",
|
||||
"targetTable": "scum_squads",
|
||||
"upsertKeys": ["squadId"],
|
||||
"columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "lastMemberLoginTime": "lastMemberLoginTime", "lastMemberLogoutTime": "lastMemberLogoutTime" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
@@ -316,6 +324,10 @@
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/squad-members.sql",
|
||||
"targetTable": "scum_squad_members",
|
||||
"upsertKeys": ["squadId", "userProfileId"],
|
||||
"columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
@@ -328,6 +340,10 @@
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/vehicles.sql",
|
||||
"targetTable": "scum_vehicles",
|
||||
"upsertKeys": ["vehicleId"],
|
||||
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
@@ -340,20 +356,68 @@
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/flags.sql",
|
||||
"targetTable": "scum_flags",
|
||||
"upsertKeys": ["flagId"],
|
||||
"columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "x": "x", "y": "y", "z": "z" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.positions",
|
||||
"title": "Read SCUM current player, vehicle, and flag coordinates",
|
||||
"title": "Read SCUM v57 player, vehicle, and base map points",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/map-points.sql",
|
||||
"targetTable": "scum_map_points",
|
||||
"upsertKeys": ["subjectType", "subjectId"],
|
||||
"columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.activity",
|
||||
"title": "Read SCUM v57 active tasks",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-activity.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-activity.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/activity.sql",
|
||||
"targetTable": "scum_activity_events",
|
||||
"upsertKeys": ["activityId"],
|
||||
"columnMappings": { "activityId": "activityId", "activityType": "activityType", "userProfileId": "userProfileId", "mapId": "mapId", "subject": "subject", "sequenceIndex": "sequenceIndex", "occurredAt": "occurredAt", "state": "state" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.gifts",
|
||||
"title": "Read SCUM v57 finished_timed_gift_spawner records",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-gifts.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-gifts.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/gifts.sql",
|
||||
"targetTable": "scum_gift_catalogs",
|
||||
"upsertKeys": ["giftId"],
|
||||
"columnMappings": { "giftId": "giftId", "giftType": "giftType", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt", "displayName": "displayName" },
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
}
|
||||
],
|
||||
"dataPacks": [
|
||||
{
|
||||
"key": "scum-db-v57",
|
||||
"databaseUserVersion": 57,
|
||||
"logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"],
|
||||
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json"]
|
||||
}
|
||||
],
|
||||
"operationTemplates": [
|
||||
@@ -538,7 +602,8 @@
|
||||
],
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.profile",
|
||||
"scum.positions"
|
||||
"scum.positions",
|
||||
"scum.activity"
|
||||
],
|
||||
"operationKeys": [
|
||||
"player.fame.set",
|
||||
@@ -575,10 +640,9 @@
|
||||
"flags"
|
||||
],
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.profile",
|
||||
"scum.positions",
|
||||
"scum.vehicles",
|
||||
"scum.flags",
|
||||
"scum.positions"
|
||||
"scum.flags"
|
||||
],
|
||||
"featureKeys": [
|
||||
"trajectory.collect"
|
||||
@@ -589,6 +653,9 @@
|
||||
"snapshotTypes": [
|
||||
"players"
|
||||
],
|
||||
"queryTemplateKeys": [
|
||||
"scum.gifts"
|
||||
],
|
||||
"operationKeys": [
|
||||
"reward.deliver",
|
||||
"player.notify"
|
||||
@@ -605,7 +672,9 @@
|
||||
"scum.squad-members",
|
||||
"scum.vehicles",
|
||||
"scum.flags",
|
||||
"scum.positions"
|
||||
"scum.positions",
|
||||
"scum.activity",
|
||||
"scum.gifts"
|
||||
],
|
||||
"operationKeys": [
|
||||
"player.fame.set",
|
||||
@@ -693,6 +762,46 @@
|
||||
{
|
||||
"path": "bin/scum-start.cmd",
|
||||
"mode": 448
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/users.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/squads.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/squad-members.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/vehicles.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/flags.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/activity.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/gifts.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "sql/scum-db-v57/map-points.sql",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/log-parsers.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/config-maps.json",
|
||||
"mode": 384
|
||||
}
|
||||
],
|
||||
"productionLifecycle": {
|
||||
@@ -786,7 +895,8 @@
|
||||
"server.game-client.command"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read"
|
||||
"server.instances.read",
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"reward.delivery"
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMActivityParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMActivityResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["activityId", "activityType", "state"],
|
||||
"properties": {
|
||||
"activityId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"activityType": { "type": "string", "minLength": 1, "maxLength": 64 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"subject": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"sequenceIndex": { "type": "integer" },
|
||||
"occurredAt": { "type": "string", "format": "date-time" },
|
||||
"state": { "type": "string", "minLength": 1, "maxLength": 64 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -17,9 +17,9 @@
|
||||
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
|
||||
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"overtakerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"overtakeEndTime": { "type": "string", "format": "date-time" },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" }
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMGiftsParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMGiftsResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["giftId", "giftType", "spawnTime"],
|
||||
"properties": {
|
||||
"giftId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"giftType": { "type": "string", "minLength": 1, "maxLength": 64 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"spawnTime": { "type": "integer" },
|
||||
"spawnAt": { "type": "string", "format": "date-time" },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -25,7 +25,10 @@
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" },
|
||||
"lastSaveTime": { "type": "string", "format": "date-time" }
|
||||
"lastSaveTime": { "type": "string", "format": "date-time" },
|
||||
"lastLoginTime": { "type": "string", "format": "date-time" },
|
||||
"lastLogoutTime": { "type": "string", "format": "date-time" },
|
||||
"isAlive": { "type": "integer", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"subjectType": { "enum": ["player", "vehicle", "flag"] },
|
||||
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
|
||||
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
|
||||
+3
-2
@@ -13,15 +13,16 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["subjectType", "subjectId", "x", "y"],
|
||||
"properties": {
|
||||
"subjectType": { "enum": ["player", "vehicle", "flag"] },
|
||||
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
|
||||
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" },
|
||||
"lastSaveTime": { "type": "string", "format": "date-time" }
|
||||
"observedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@
|
||||
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
||||
"score": { "type": "number" }
|
||||
"score": { "type": "number" },
|
||||
"memberLimit": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
||||
"lastMemberLoginTime": { "type": "string", "format": "date-time" },
|
||||
"lastMemberLogoutTime": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" }
|
||||
"z": { "type": "number" },
|
||||
"lastAccessTime": { "type": "string", "format": "date-time" },
|
||||
"isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
SELECT
|
||||
'active-task' AS activityType,
|
||||
CAST(task.id AS TEXT) AS activityId,
|
||||
CAST(task.user_profile_id AS TEXT) AS userProfileId,
|
||||
CAST(task.map_id AS TEXT) AS mapId,
|
||||
available.task_data_asset_path AS subject,
|
||||
task.sequence_index AS sequenceIndex,
|
||||
NULL AS occurredAt,
|
||||
CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'active' END AS state
|
||||
FROM active_task active
|
||||
JOIN tracking_data_set task ON task.id = active.id
|
||||
JOIN available_task available ON available.id = active.available_task_id
|
||||
WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId)
|
||||
ORDER BY task.id DESC
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,18 @@
|
||||
SELECT
|
||||
CAST(flag.element_id AS TEXT) AS flagId,
|
||||
CAST(flag.element_id AS TEXT) AS entityId,
|
||||
CAST(element.base_id AS TEXT) AS baseId,
|
||||
CAST(element.owner_profile_id AS TEXT) AS ownerProfileId,
|
||||
CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId,
|
||||
CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId,
|
||||
datetime(flag.overtake_end_time, 'unixepoch') AS overtakeEndTime,
|
||||
element.location_x AS x,
|
||||
element.location_y AS y,
|
||||
element.location_z AS z
|
||||
FROM base_element_flag flag
|
||||
JOIN base_element element ON element.element_id = flag.element_id
|
||||
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
|
||||
WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId)
|
||||
AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId)
|
||||
ORDER BY flag.element_id
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,14 @@
|
||||
SELECT
|
||||
'finished-timed-gift' AS giftType,
|
||||
CAST(gift.user_profile_id AS TEXT) || ':' || CAST(gift.map_id AS TEXT) || ':' || CAST(gift.spawn_time AS TEXT) AS giftId,
|
||||
CAST(gift.user_profile_id AS TEXT) AS userProfileId,
|
||||
CAST(gift.map_id AS TEXT) AS mapId,
|
||||
gift.spawn_time AS spawnTime,
|
||||
datetime(gift.spawn_time, 'unixepoch') AS spawnAt,
|
||||
COALESCE(profile.name, user.name, '') AS displayName
|
||||
FROM finished_timed_gift_spawner gift
|
||||
LEFT JOIN user_profile profile ON profile.id = gift.user_profile_id
|
||||
LEFT JOIN user ON user.id = profile.user_id
|
||||
WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId)
|
||||
ORDER BY gift.spawn_time DESC
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,62 @@
|
||||
SELECT
|
||||
'player' AS subjectType,
|
||||
CAST(profile.id AS TEXT) AS subjectId,
|
||||
CAST(profile.id AS TEXT) AS userProfileId,
|
||||
CAST(prisoner.id AS TEXT) AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
NULL AS baseId,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
datetime(prisoner.last_save_time, 'unixepoch') AS observedAt
|
||||
FROM user_profile profile
|
||||
JOIN prisoner ON prisoner.id = profile.prisoner_id
|
||||
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
|
||||
JOIN entity ON entity.id = prisoner_entity.entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'player')
|
||||
UNION ALL
|
||||
SELECT
|
||||
'vehicle' AS subjectType,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS subjectId,
|
||||
NULL AS userProfileId,
|
||||
NULL AS gamePlayerId,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
|
||||
NULL AS baseId,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS observedAt
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
|
||||
UNION ALL
|
||||
SELECT
|
||||
'base' AS subjectType,
|
||||
CAST(base.id AS TEXT) AS subjectId,
|
||||
CAST(base.owner_user_profile_id AS TEXT) AS userProfileId,
|
||||
NULL AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
CAST(base.id AS TEXT) AS baseId,
|
||||
base.location_x AS x,
|
||||
base.location_y AS y,
|
||||
0 AS z,
|
||||
NULL AS observedAt
|
||||
FROM base
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'base')
|
||||
UNION ALL
|
||||
SELECT
|
||||
'flag' AS subjectType,
|
||||
CAST(flag.element_id AS TEXT) AS subjectId,
|
||||
CAST(element.owner_profile_id AS TEXT) AS userProfileId,
|
||||
CAST(owner.prisoner_id AS TEXT) AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
CAST(element.base_id AS TEXT) AS baseId,
|
||||
element.location_x AS x,
|
||||
element.location_y AS y,
|
||||
element.location_z AS z,
|
||||
datetime(flag.overtake_end_time, 'unixepoch') AS observedAt
|
||||
FROM base_element_flag flag
|
||||
JOIN base_element element ON element.element_id = flag.element_id
|
||||
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'flag')
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,15 @@
|
||||
SELECT
|
||||
CAST(member.squad_id AS TEXT) AS squadId,
|
||||
CAST(member.user_profile_id AS TEXT) AS userProfileId,
|
||||
CAST(profile.prisoner_id AS TEXT) AS gamePlayerId,
|
||||
user.id AS steamId,
|
||||
COALESCE(profile.name, user.name, '') AS displayName,
|
||||
CAST(member.rank AS TEXT) AS rank,
|
||||
CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader
|
||||
FROM squad_member member
|
||||
JOIN user_profile profile ON profile.id = member.user_profile_id
|
||||
LEFT JOIN user ON user.id = profile.user_id
|
||||
WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId)
|
||||
AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId)
|
||||
ORDER BY member.squad_id, member.rank DESC, profile.name
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,19 @@
|
||||
SELECT
|
||||
CAST(s.id AS TEXT) AS squadId,
|
||||
COALESCE(s.name, '') AS name,
|
||||
CAST(leader.user_profile_id AS TEXT) AS leaderProfileId,
|
||||
CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId,
|
||||
COUNT(member.id) AS memberCount,
|
||||
s.score AS score,
|
||||
s.member_limit AS memberLimit,
|
||||
s.last_member_login_time AS lastMemberLoginTime,
|
||||
s.last_member_logout_time AS lastMemberLogoutTime
|
||||
FROM squad s
|
||||
LEFT JOIN squad_member member ON member.squad_id = s.id
|
||||
LEFT JOIN squad_member leader ON leader.squad_id = s.id AND leader.rank = 4
|
||||
LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id
|
||||
WHERE (:squadId IS NULL OR CAST(s.id AS TEXT) = :squadId)
|
||||
AND (:search IS NULL OR COALESCE(s.name, '') LIKE '%' || :search || '%')
|
||||
GROUP BY s.id
|
||||
ORDER BY s.score DESC, s.id
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,28 @@
|
||||
SELECT
|
||||
CAST(up.id AS TEXT) AS userProfileId,
|
||||
u.id AS steamId,
|
||||
CAST(p.id AS TEXT) AS gamePlayerId,
|
||||
COALESCE(up.name, u.name, '') AS displayName,
|
||||
up.fame_points AS famePoints,
|
||||
MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance,
|
||||
MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance,
|
||||
e.location_x AS x,
|
||||
e.location_y AS y,
|
||||
e.location_z AS z,
|
||||
datetime(p.last_save_time, 'unixepoch') AS lastSaveTime,
|
||||
up.last_login_time AS lastLoginTime,
|
||||
up.last_logout_time AS lastLogoutTime,
|
||||
p.is_alive AS isAlive
|
||||
FROM user_profile up
|
||||
JOIN user u ON u.id = up.user_id
|
||||
LEFT JOIN prisoner p ON p.id = up.prisoner_id
|
||||
LEFT JOIN prisoner_entity pe ON pe.prisoner_id = p.id
|
||||
LEFT JOIN entity e ON e.id = pe.entity_id
|
||||
LEFT JOIN bank_account_registry account ON account.account_owner_user_profile_id = up.id
|
||||
LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = account.id
|
||||
WHERE (:userProfileId IS NULL OR CAST(up.id AS TEXT) = :userProfileId)
|
||||
AND (:steamId IS NULL OR u.id = :steamId)
|
||||
AND (:search IS NULL OR COALESCE(up.name, u.name, '') LIKE '%' || :search || '%')
|
||||
GROUP BY up.id
|
||||
ORDER BY up.last_login_time DESC
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -0,0 +1,16 @@
|
||||
SELECT
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
|
||||
entity.class AS className,
|
||||
spawner.vehicle_alias AS label,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
|
||||
spawner.is_vehicle_functional AS isFunctional
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
|
||||
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
|
||||
ORDER BY spawner.vehicle_last_access_time DESC
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
@@ -271,6 +271,11 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"dataPacks": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
|
||||
"maxItems": 32
|
||||
},
|
||||
"operationTemplates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
|
||||
@@ -360,10 +365,25 @@
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
|
||||
"targetTable": { "type": "string", "pattern": "^scum_[a-z][a-z0-9_]{0,62}$" },
|
||||
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
|
||||
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
|
||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeDataPack": {
|
||||
"type": "object",
|
||||
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"databaseUserVersion": { "type": "integer", "minimum": 1, "maximum": 1000000 },
|
||||
"logParserRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 },
|
||||
"configMapRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeOperationSafety": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -440,6 +460,10 @@
|
||||
"type": "string",
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
|
||||
},
|
||||
"relativeSqlRef": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)sql/[a-zA-Z0-9_./-]+\\.sql$"
|
||||
},
|
||||
"runCapability": {
|
||||
"enum": [
|
||||
"process.install",
|
||||
|
||||
@@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
||||
}
|
||||
|
||||
function isSafeRelativeSQLRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)sql\/[a-zA-Z0-9_./-]+\.sql$/.test(value);
|
||||
}
|
||||
|
||||
function isSafeRelativePathRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
|
||||
}
|
||||
@@ -672,6 +676,10 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
targetKey?: string;
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
sqlRef?: string;
|
||||
targetTable?: string;
|
||||
upsertKeys?: string[];
|
||||
columnMappings?: Record<string, string>;
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
@@ -840,6 +848,24 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
const persistsSCUMRows = queryTemplate.targetTable !== undefined || queryTemplate.sqlRef !== undefined || queryTemplate.upsertKeys !== undefined || queryTemplate.columnMappings !== undefined;
|
||||
if (persistsSCUMRows) {
|
||||
if (!queryTemplate.sqlRef || !isSafeRelativeSQLRef(queryTemplate.sqlRef)) {
|
||||
errors.push(`${location}.sqlRef: persisted SCUM queries must reference a package-relative .sql asset`);
|
||||
}
|
||||
if (!/^scum_[a-z][a-z0-9_]{0,62}$/.test(queryTemplate.targetTable ?? "")) {
|
||||
errors.push(`${location}.targetTable: persisted SCUM queries must target a scum_* table`);
|
||||
}
|
||||
if (!Array.isArray(queryTemplate.upsertKeys) || queryTemplate.upsertKeys.length === 0 || !queryTemplate.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) {
|
||||
errors.push(`${location}.upsertKeys: persisted SCUM queries require non-empty safe keys`);
|
||||
}
|
||||
const mappings = queryTemplate.columnMappings;
|
||||
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([target, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(target) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) {
|
||||
errors.push(`${location}.columnMappings: persisted SCUM queries require safe target-to-source mappings`);
|
||||
} else if (Array.isArray(queryTemplate.upsertKeys) && !queryTemplate.upsertKeys.every((key) => key in mappings)) {
|
||||
errors.push(`${location}.upsertKeys: every upsert key must be declared in columnMappings`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
||||
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
||||
}
|
||||
@@ -990,6 +1016,59 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) return [];
|
||||
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
||||
const errors: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const [index, dataPack] of dataPacks.entries()) {
|
||||
const location = `manifest.gameClientBridge.dataPacks[${index}]`;
|
||||
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique safe data-pack key`);
|
||||
keys.add(dataPack.key ?? "");
|
||||
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
|
||||
for (const field of ["logParserRefs", "configMapRefs"] as const) {
|
||||
const refs = dataPack[field];
|
||||
if (!Array.isArray(refs) || refs.length === 0) {
|
||||
errors.push(`${location}.${field}: must declare at least one package mapping asset`);
|
||||
continue;
|
||||
}
|
||||
for (const ref of refs) {
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: must use package-relative JSON assets`);
|
||||
continue;
|
||||
}
|
||||
if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`);
|
||||
const target = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package mapping asset ${ref}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) return [];
|
||||
const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [index, template] of templates.entries()) {
|
||||
if (!template.sqlRef) continue;
|
||||
const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`;
|
||||
if (!isSafeRelativeSQLRef(template.sqlRef)) {
|
||||
errors.push(`${location}: must be a package-relative .sql asset`);
|
||||
continue;
|
||||
}
|
||||
if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`);
|
||||
const assetPath = path.resolve(manifestDir, template.sqlRef);
|
||||
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
|
||||
errors.push(`${location}: missing SQL asset ${template.sqlRef}`);
|
||||
continue;
|
||||
}
|
||||
const body = fs.readFileSync(assetPath, "utf8").trim();
|
||||
if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
@@ -1378,6 +1457,8 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||
errors.push(...assetValidation.errors);
|
||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||
errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared));
|
||||
|
||||
for (const declaration of referencedLifecycleActions(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
|
||||
@@ -56,3 +56,10 @@ The bridge must not expose:
|
||||
# Client Manager lifecycle bridge
|
||||
|
||||
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
|
||||
## Plugin-packaged database data packs
|
||||
|
||||
Database-backed game data stays with the game plugin. A browser request names a declared query template and supplies only values accepted by its parameter schema; it never supplies a SQL statement or a database path.
|
||||
|
||||
For a persisted game dataset, a query template declares package-relative `sqlRef`, `targetTable`, `upsertKeys`, and `columnMappings`. `sqlRef` must name a declared `sql/.../*.sql` asset containing one read-only `SELECT` statement. `targetTable` is a `scum_*` relation and every upsert key must have a mapping. The Platform resolves and distributes the plugin asset, while Run executes the declared statement through the template's SQLite transport.
|
||||
|
||||
Version-specific log parsers and configuration maps are declared in `gameClientBridge.dataPacks`. Each pack names the SQLite `databaseUserVersion` plus JSON parser/map assets. These assets describe the game format; they do not contain host paths, credentials, or browser-provided SQL.
|
||||
|
||||
@@ -262,10 +262,21 @@ export interface GameClientBridgeQueryTemplateDeclaration {
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef?: string;
|
||||
targetTable?: string;
|
||||
upsertKeys?: string[];
|
||||
columnMappings?: Record<string, string>;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeDataPackDeclaration {
|
||||
key: string;
|
||||
databaseUserVersion: number;
|
||||
logParserRefs: string[];
|
||||
configMapRefs: string[];
|
||||
}
|
||||
|
||||
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
|
||||
|
||||
export interface GameClientBridgeOperationSafety {
|
||||
@@ -337,6 +348,7 @@ export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
||||
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
|
||||
@@ -617,7 +617,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
|
||||
});
|
||||
|
||||
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
|
||||
it("declares packaged SCUM v57 query templates without browser-provided SQL", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
@@ -633,6 +633,10 @@ describe("plugin manifest validation", () => {
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef: string;
|
||||
targetTable: string;
|
||||
upsertKeys: string[];
|
||||
columnMappings: Record<string, string>;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}>;
|
||||
@@ -641,7 +645,7 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.activity", "scum.gifts"];
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -655,7 +659,14 @@ describe("plugin manifest validation", () => {
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.transportKey).toBe("scum-database");
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
|
||||
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
|
||||
expect(template.targetTable).toMatch(/^scum_/);
|
||||
expect(template.upsertKeys.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(template.columnMappings).length).toBeGreaterThan(0);
|
||||
expect(template.upsertKeys.every((key) => key in template.columnMappings)).toBe(true);
|
||||
const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8");
|
||||
expect(sql).toMatch(/^SELECT\b/i);
|
||||
expect(sql).not.toMatch(/\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|PRAGMA)\b/i);
|
||||
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
|
||||
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
|
||||
@@ -665,7 +676,7 @@ describe("plugin manifest validation", () => {
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions", "scum.activity"]));
|
||||
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
|
||||
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
for (const pageKey of ["players", "squads", "live-map"]) {
|
||||
@@ -675,6 +686,19 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("packages SCUM v57 UTF-16LE log and config maps", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[] }> } };
|
||||
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
|
||||
expect(pack).toMatchObject({ databaseUserVersion: 57 });
|
||||
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
|
||||
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
||||
expect(logParsers.encoding).toBe("utf-16le");
|
||||
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "logout", "chat", "admin", "vehicle-destruction"]));
|
||||
const serverSettings = configMaps.maps.find((map: { key: string }) => map.key === "server-settings");
|
||||
expect(serverSettings.fields).toMatchObject({ "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd" });
|
||||
});
|
||||
|
||||
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
@@ -905,6 +929,20 @@ describe("plugin manifest validation", () => {
|
||||
expect(errors.some((error) => error.includes("timeoutSeconds"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects inline SQL and incomplete SCUM row declarations", () => {
|
||||
const inlineErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "SELECT * FROM user_profile", targetTable: "scum_users", upsertKeys: ["userProfileId"], columnMappings: { userProfileId: "userProfileId" } });
|
||||
});
|
||||
expect(inlineErrors.some((error) => error.includes("sqlRef"))).toBe(true);
|
||||
|
||||
const incompleteErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "sql/scum-db-v57/users.sql", targetTable: "users", upsertKeys: [], columnMappings: {} });
|
||||
});
|
||||
expect(incompleteErrors.some((error) => error.includes("targetTable"))).toBe(true);
|
||||
expect(incompleteErrors.some((error) => error.includes("upsertKeys"))).toBe(true);
|
||||
expect(incompleteErrors.some((error) => error.includes("columnMappings"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires query templates to match a declared sqlite transport target and capability", () => {
|
||||
const targetErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.queryTemplates![0].targetKey = "db/other";
|
||||
|
||||
Reference in New Issue
Block a user