diff --git a/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml b/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml new file mode 100644 index 0000000..b6b2d1f --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/implement-scum-direct-data-plane/design.md b/openspec/changes/implement-scum-direct-data-plane/design.md new file mode 100644 index 0000000..93705ed --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/design.md @@ -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. diff --git a/openspec/changes/implement-scum-direct-data-plane/proposal.md b/openspec/changes/implement-scum-direct-data-plane/proposal.md new file mode 100644 index 0000000..5a106fa --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/proposal.md @@ -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 + + + +## 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. diff --git a/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md b/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md new file mode 100644 index 0000000..0b7e370 --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md @@ -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 + diff --git a/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md b/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md new file mode 100644 index 0000000..2bec025 --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md @@ -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 diff --git a/openspec/changes/implement-scum-direct-data-plane/tasks.md b/openspec/changes/implement-scum-direct-data-plane/tasks.md new file mode 100644 index 0000000..50d3373 --- /dev/null +++ b/openspec/changes/implement-scum-direct-data-plane/tasks.md @@ -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. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 7cfc9c9..cde55e9 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -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) diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go index 47ba825..895be12 100644 --- a/platform/api/scum_handlers.go +++ b/platform/api/scum_handlers.go @@ -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) diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index e2eee4d..d76aceb 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -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) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 319d3b8..bd21a49 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -69,8 +69,18 @@ type GameClientBridgeQueryTemplateDeclaration struct { TargetKey string ParameterSchemaRef string ResultSchemaRef string + SQLRef string MaxRows int TimeoutSeconds int + RowTarget *SCUMRowTargetDeclaration +} + +// SCUMRowTargetDeclaration is plugin-owned data-shaping metadata. The platform +// only applies this declaration; it does not infer a destination from a query key. +type SCUMRowTargetDeclaration struct { + TargetTable string + UpsertKeys []string + ColumnMappings map[string]string } type GameClientBridgeOperationKind string @@ -453,6 +463,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid } value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) + for index := range value.QueryTemplates { + if value.QueryTemplates[index].RowTarget != nil { + copy := CopySCUMRowTargetDeclaration(*value.QueryTemplates[index].RowTarget) + value.QueryTemplates[index].RowTarget = © + } + } value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) diff --git a/platform/domain/scum_projections.go b/platform/domain/scum_projections.go index df7eebc..d67f18c 100644 --- a/platform/domain/scum_projections.go +++ b/platform/domain/scum_projections.go @@ -25,6 +25,7 @@ type SCUMProjectionFilter struct { FlagID string SubjectType SCUMProjectionSubject QueryKey string + TargetTable SCUMDataSet Freshness SCUMProjectionFreshness Search string Limit int diff --git a/platform/domain/scum_workflows.go b/platform/domain/scum_workflows.go index d882614..27d791c 100644 --- a/platform/domain/scum_workflows.go +++ b/platform/domain/scum_workflows.go @@ -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 diff --git a/platform/dto/resources.go b/platform/dto/resources.go index be89354..d990332 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -290,16 +290,20 @@ type GameClientBridgeSnapshotDeclarationBody struct { } type GameClientBridgeQueryTemplateDeclarationBody struct { - Key string `json:"key"` - Title string `json:"title"` - Permission string `json:"permission"` - Engine string `json:"engine"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - ParameterSchemaRef string `json:"parameterSchemaRef"` - ResultSchemaRef string `json:"resultSchemaRef"` - MaxRows int `json:"maxRows"` - TimeoutSeconds int `json:"timeoutSeconds"` + Key string `json:"key"` + Title string `json:"title"` + Permission string `json:"permission"` + Engine string `json:"engine"` + TransportKey string `json:"transportKey"` + 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 { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index f7a64b0..6a9dc78 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -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) } diff --git a/platform/dto/scum_projections.go b/platform/dto/scum_projections.go index 56feff6..3ff9c23 100644 --- a/platform/dto/scum_projections.go +++ b/platform/dto/scum_projections.go @@ -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)} +} diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index d260152..132263a 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -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) diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 07dd920..6bc140f 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -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) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index f8fbfa5..e940835 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -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) && diff --git a/platform/service/resources.go b/platform/service/resources.go index 274828d..b1cec56 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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) diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go index b85f308..e4fa914 100644 --- a/platform/service/scum_projections.go +++ b/platform/service/scum_projections.go @@ -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 } - } - } - if strings.Contains(lower, "vehicle") { - for _, row := range rows { + case "scum.squad-members": + if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil { + return err + } + 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 diff --git a/platform/service/scum_projections_test.go b/platform/service/scum_projections_test.go index 7b510be..f228596 100644 --- a/platform/service/scum_projections_test.go +++ b/platform/service/scum_projections_test.go @@ -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) + } +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index b78fe3a..c2bd929 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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 diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 2332ca3..c6cbe45 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -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 () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index e2464ca..3d2fdb8 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`); } - async listSCUMSquads(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`); + async listSCUMUsers(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users`); + } + + async listSCUMSquads(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/datasets/squads`); + } + + async listSCUMActivity(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/activity`); + } + + async listSCUMGifts(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/gifts`); + } + + async listSCUMMapPoints(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/map-points`); } async listSCUMSquadMembers(serverInstanceId: string): Promise { diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index b3afca7..8b3db24 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1375,6 +1375,17 @@ export interface RemoteAdapterResponse { export type SCUMJsonRecord = Record; export interface SCUMListResponse { 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; +export type SCUMSquadsListResponse = SCUMListResponse; +export type SCUMActivityListResponse = SCUMListResponse; +export type SCUMGiftsListResponse = SCUMListResponse; +export type SCUMMapPointsListResponse = SCUMListResponse; 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; } diff --git a/platform_web/components/SCUMPersistedDataView.test.tsx b/platform_web/components/SCUMPersistedDataView.test.tsx new file mode 100644 index 0000000..de87784 --- /dev/null +++ b/platform_web/components/SCUMPersistedDataView.test.tsx @@ -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(); + expect(html).toContain("正在读取用户同步数据"); + expect(html).not.toContain("Prisoner One"); + }); +}); diff --git a/platform_web/components/SCUMPersistedDataView.tsx b/platform_web/components/SCUMPersistedDataView.tsx new file mode 100644 index 0000000..a248fea --- /dev/null +++ b/platform_web/components/SCUMPersistedDataView.tsx @@ -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; updatedAt?: string; freshness?: { observedAt?: string } }; +type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number }; + +const datasetCopy: Record = { + 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({ status: "loading" }); + const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]); + useEffect(() => { refresh(); }, [refresh]); + + return
+
+

{copy.summary}

+ +
+ {state.status === "loading" && } + {state.status === "error" && } + {state.status === "ready" && (state.items.length ? : } />)} +
; +} + +function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) { + const columns = useMemo(() => tableColumns(dataset), [dataset]); + return
+
已同步
{count}
当前展示
{Math.min(items.length, 100)}
数据源
平台数据库
+
{columns.map((column) => )}{items.slice(0, 100).map((item) => {columns.map((column) => )})}
{column.label}
{column.value(item)}
+
; +} + +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 | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; } diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 16e71a2..2e58ba2 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,6 +1,10 @@ export interface PluginPageWorkspaceActions { + listSCUMUsers?: () => Promise; listSCUMPlayers?: () => Promise; listSCUMSquads?: () => Promise; + listSCUMActivity?: () => Promise; + listSCUMGifts?: () => Promise; + listSCUMMapPoints?: () => Promise; listSCUMSquadMembers?: () => Promise; listSCUMVehicles?: () => Promise; listSCUMFlags?: () => Promise; diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 9ee5f77..a7d8966 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -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(); 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)"); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 53185d2..94faafe 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -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(() => { 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 ; } + const scumDataset = state.plugin.serverType === "scum" ? scumDatasetForPluginRoute(routeKey) : null; if (embedded) { return ( <> - {bundleError && } - {!bundle && !bundleError && } - {bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })} + {scumDataset && } + {!scumDataset && bundleError && } + {!scumDataset && !bundle && !bundleError && } + {!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })} ); } @@ -145,9 +152,10 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}Companion {availability.available ? "可用" : "不可用"}
- {bundleError && } - {!bundle && !bundleError && } - {bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })} + {scumDataset && } + {!scumDataset && bundleError && } + {!scumDataset && !bundle && !bundleError && } + {!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })} ); } diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json new file mode 100644 index 0000000..9a6b424 --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json @@ -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" } } + ] +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json new file mode 100644 index 0000000..e0679ad --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json @@ -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"] } + ] +} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index cbbec6b..93f1e07 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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": { @@ -773,7 +882,7 @@ "trajectory.collect" ] }, - { + { "key": "gifts", "title": "礼包管理", "path": "/gifts", @@ -785,9 +894,10 @@ "server.game-client.read", "server.game-client.command" ], - "bridgeActions": [ - "server.instances.read" - ], + "bridgeActions": [ + "server.instances.read", + "remote.access.request" + ], "featureKeys": [ "reward.delivery" ] diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json new file mode 100644 index 0000000..1cefcee --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json @@ -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 } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json new file mode 100644 index 0000000..0e4381c --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json @@ -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" } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json index b30e349..5f1866d 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json @@ -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" } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json new file mode 100644 index 0000000..f7da70e --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json @@ -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 } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json new file mode 100644 index 0000000..6acd4d7 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json @@ -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" } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json index 44a068d..b765f98 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json @@ -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 } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json index 84f3eaf..35bc0a2 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json @@ -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 } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json index 6a47e18..5388acc 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json index 6e3da4a..5c4a15f 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json index ecef9ca..3f1efa3 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json @@ -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 } } } }, diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql new file mode 100644 index 0000000..80e7617 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql new file mode 100644 index 0000000..54ec45e --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql new file mode 100644 index 0000000..6564f78 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql new file mode 100644 index 0000000..b3dfe3a --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql new file mode 100644 index 0000000..556a56c --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql new file mode 100644 index 0000000..6c92c2e --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql new file mode 100644 index 0000000..08dfb07 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql new file mode 100644 index 0000000..5efa7e2 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql @@ -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) diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 45ede5b..2462c10 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index b213ecd..9e24393 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -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; 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[] { + 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(); + 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[] { + 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)) { diff --git a/plugins/sdk/bridge-contract.md b/plugins/sdk/bridge-contract.md index 25771bb..2e61e7f 100644 --- a/plugins/sdk/bridge-contract.md +++ b/plugins/sdk/bridge-contract.md @@ -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. diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index aad4070..a9bfe6e 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -262,10 +262,21 @@ export interface GameClientBridgeQueryTemplateDeclaration { targetKey: string; parameterSchemaRef: string; resultSchemaRef: string; + sqlRef?: string; + targetTable?: string; + upsertKeys?: string[]; + columnMappings?: Record; 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; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index e2a1335..fe86574 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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; 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";