Implement SCUM direct data plane

This commit is contained in:
npc0-hue
2026-08-13 16:33:11 +08:00
parent b07a792784
commit 8b236d7c15
56 changed files with 1466 additions and 90 deletions
@@ -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.
@@ -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
@@ -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.
+5
View File
@@ -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)
+35
View File
@@ -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)
+10 -2
View File
@@ -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)
+16
View File
@@ -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 = &copy
}
}
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
+1
View File
@@ -25,6 +25,7 @@ type SCUMProjectionFilter struct {
FlagID string
SubjectType SCUMProjectionSubject
QueryKey string
TargetTable SCUMDataSet
Freshness SCUMProjectionFreshness
Search string
Limit int
+40
View File
@@ -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
+17 -2
View File
@@ -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 {
+3 -3
View File
@@ -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)
}
+33 -1
View File
@@ -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)}
}
+6 -1
View File
@@ -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)
+155 -2
View File
@@ -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)
+20
View File
@@ -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) &&
+1
View File
@@ -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)
+132 -33
View File
@@ -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
+26
View File
@@ -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)
}
}
+25
View File
@@ -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
+10 -2
View File
@@ -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 () => {
+23 -2
View File
@@ -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> {
+11
View File
@@ -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}`}`; }
+4
View File
@@ -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)");
+14 -6
View File
@@ -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"
@@ -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 }
}
}
@@ -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" }
}
}
@@ -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" }
@@ -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 }
}
}
@@ -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" }
}
}
@@ -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 }
}
}
},
@@ -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 }
}
@@ -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" }
}
}
},
@@ -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" }
}
}
},
@@ -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",
+81
View File
@@ -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)) {
+7
View File
@@ -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.
+12
View File
@@ -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;
+42 -4
View File
@@ -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";