diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index bbb8482..aaaf068 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "strconv" "strings" @@ -1689,6 +1690,9 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques writeServiceError(w, err) return } + if err := h.core.ReconcileSCUMQueryTemplatesForRunEndpoint(request.RunEndpointID); err != nil { + log.Printf("SCUM query template dispatch skipped run_endpoint=%s error=%s", request.RunEndpointID, err.Error()) + } writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result)) } diff --git a/platform/domain/scum.go b/platform/domain/scum.go index 537f4d3..8663082 100644 --- a/platform/domain/scum.go +++ b/platform/domain/scum.go @@ -7,6 +7,7 @@ const ( SCUMWelcomeMessageDelay = time.Minute SCUMDefaultListLimit = 500 SCUMDefaultTrajectoryLimit = 500 + SCUMFactBatchLimit = 1000 ) type SCUMPosition struct { diff --git a/platform/service/resources.go b/platform/service/resources.go index 4408c0b..4069eed 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -179,6 +179,7 @@ type Core interface { QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) ReconcileGameClientBridgeCommands() error + ReconcileSCUMQueryTemplatesForRunEndpoint(string) error GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error) ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error) diff --git a/platform/service/scum.go b/platform/service/scum.go index 9494a06..7321fa4 100644 --- a/platform/service/scum.go +++ b/platform/service/scum.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "log" "sort" "strings" "time" @@ -151,6 +152,9 @@ func (svc *CoreService) GetSCUMSurfaceForSession(sessionID, serverInstanceID str if _, err := svc.reconcileSCUMUserPresence(serverInstanceID, svc.now()); err != nil { return domain.SCUMSurface{}, err } + if err := svc.ReconcileSCUMQueryTemplates(serverInstanceID); err != nil { + log.Printf("SCUM query template dispatch skipped server=%s error=%s", serverInstanceID, err.Error()) + } users, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit}) if err != nil { return domain.SCUMSurface{}, err @@ -222,9 +226,6 @@ func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCU if len(batch.Users) == 0 && len(batch.Vehicles) == 0 { return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required") } - if len(batch.Users) > 1000 || len(batch.Vehicles) > 1000 { - return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large") - } if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { return domain.SCUMFactIngestResult{}, err } @@ -242,7 +243,24 @@ func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCU if !strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") { return domain.SCUMFactIngestResult{}, ErrForbidden } - stamp := svc.now() + return svc.ingestSCUMFactsForInstance(instance, batch, svc.now()) +} + +// ingestSCUMFactsForInstance applies one already-authorized fact batch to the +// typed SCUM tables. Both the signed run ingest channel and the plugin-declared +// database projection path converge here. +// +// Call-count budget for one batch: one bounded stale-user convergence read per +// instance, then one read plus one insert/update per reported user and vehicle, +// and at most one trajectory sample per moved subject. +func (svc *CoreService) ingestSCUMFactsForInstance(instance domain.ServerInstance, batch domain.SCUMFactIngest, stamp time.Time) (domain.SCUMFactIngestResult, error) { + batch = domain.CopySCUMFactIngest(batch) + if len(batch.Users) == 0 && len(batch.Vehicles) == 0 { + return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required") + } + if len(batch.Users) > domain.SCUMFactBatchLimit || len(batch.Vehicles) > domain.SCUMFactBatchLimit { + return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large") + } if _, err := svc.reconcileSCUMUserPresence(instance.ID, stamp); err != nil { return domain.SCUMFactIngestResult{}, err } @@ -304,7 +322,11 @@ func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact previousActivity := user.LastActivityAt freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter)) moved := fact.Online && fact.Position != nil && userPositionMoved(user, fact.Position) - if fact.Login || created { + loginEvidence := fact.Login || created + if !loginEvidence && !fact.LoginObservedAt.IsZero() && loginAt.After(user.LastLoginAt) { + loginEvidence = true + } + if loginEvidence { user.LastLoginAt = loginAt } user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName) diff --git a/platform/service/scum_query_ingest.go b/platform/service/scum_query_ingest.go new file mode 100644 index 0000000..10bea0d --- /dev/null +++ b/platform/service/scum_query_ingest.go @@ -0,0 +1,510 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "math" + "strconv" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +// Plugin-declared SCUM database projection collections. The plugin owns the SQL +// text and the row-to-field mapping; the platform owns the typed tables those +// rows are projected into. +const ( + scumProjectionUsers = "scum.users" + scumProjectionVehicles = "scum.vehicles" +) + +const scumQueryTemplateIdempotencyPrefix = "scum-query:" + +// ReconcileSCUMQueryTemplatesForRunEndpoint dispatches due SCUM database read +// templates for every live server instance registered to one run endpoint. +func (svc *CoreService) ReconcileSCUMQueryTemplatesForRunEndpoint(runEndpointID string) error { + runEndpointID = strings.TrimSpace(runEndpointID) + if runEndpointID == "" { + return nil + } + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID}) + if err != nil { + return err + } + for _, instance := range instances { + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + return err + } + } + return nil +} + +// ReconcileSCUMQueryTemplates queues the plugin-declared SCUM database read +// templates that are due for one server instance. +// +// Call-count budget for one call: one server instance read, one plugin read, one +// run endpoint read, one in-memory dispatch lookup per declared template, and at +// most one job insert per declared template per poll interval. Job inserts are +// de-duplicated by run endpoint plus idempotency key, so repeated page loads and +// run heartbeats inside the same poll interval add no storage work. +func (svc *CoreService) ReconcileSCUMQueryTemplates(serverInstanceID string) error { + serverInstanceID = strings.TrimSpace(serverInstanceID) + if serverInstanceID == "" { + return nil + } + instance, err := svc.store.ServerInstances().Get(serverInstanceID) + if errors.Is(err, repo.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if instance.State == domain.ServerInstanceStateDeleted || strings.TrimSpace(instance.RunEndpointID) == "" { + return nil + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if errors.Is(err, repo.ErrNotFound) { + return nil + } + if err != nil { + return err + } + templates := scumIngestQueryTemplates(plugin) + if len(templates) == 0 { + return nil + } + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) + if err != nil { + return nil + } + transports := map[string]domain.RuntimeTransportProfile{} + for _, transport := range plugin.RuntimeProfiles.TransportProfiles { + transports[transport.Key] = transport + } + stamp := svc.now() + for _, template := range templates { + transport, ok := transports[template.TransportKey] + if !ok || transport.Kind != "sqlite" || transport.TargetKey != template.TargetKey { + continue + } + if !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + continue + } + if !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + continue + } + if !svc.endpointSupports(endpoint, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + continue + } + interval := int64(template.PollIntervalSeconds) + if interval <= 0 { + continue + } + idempotencyKey := fmt.Sprintf("%s%s:%d", scumQueryTemplateIdempotencyPrefix, template.Key, stamp.Unix()/interval) + job := domain.Job{ + ID: jobIDFromParts("job-scum-query", instance.ID, idempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, + TargetKey: template.TargetKey, + InputRef: fmt.Sprintf("input://scum-queries/%s/%s", instance.ID, template.Key), + IdempotencyKey: idempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: "SCUM database read queued"}, + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 10}, + ExecutionInput: domain.JobExecutionInput{ + WorkspaceScope: svc.runtimeProfileScope(instance.ID), + RemoteAdapterKey: transport.Key, + RemoteAdapterKind: string(domain.RemoteAdapterDatabase), + TimeoutSeconds: scumQueryTemplateTimeout(template), + PluginID: plugin.ID, + Inputs: map[string]string{ + "templateKey": template.Key, + "sqlRef": template.SQLRef, + "maxRows": strconv.Itoa(scumQueryTemplateMaxRows(template)), + }, + }, + } + if _, err := svc.CreateJob(job); err != nil { + return err + } + } + return nil +} + +func scumIngestQueryTemplates(plugin domain.GamePlugin) []domain.GameClientBridgeQueryTemplateDeclaration { + result := make([]domain.GameClientBridgeQueryTemplateDeclaration, 0, len(plugin.GameClientBridge.QueryTemplates)) + for _, template := range plugin.GameClientBridge.QueryTemplates { + if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 { + continue + } + if strings.TrimSpace(template.SQLRef) == "" { + continue + } + supported := false + for _, projection := range template.Projections { + if scumProjectionCollectionSupported(projection.Collection) { + supported = true + break + } + } + if !supported { + continue + } + result = append(result, template) + } + return result +} + +func scumProjectionCollectionSupported(collection string) bool { + switch strings.TrimSpace(collection) { + case scumProjectionUsers, scumProjectionVehicles: + return true + default: + return false + } +} + +func scumQueryTemplateTimeout(template domain.GameClientBridgeQueryTemplateDeclaration) int { + if template.TimeoutSeconds > 0 { + return template.TimeoutSeconds + } + return 15 +} + +func scumQueryTemplateMaxRows(template domain.GameClientBridgeQueryTemplateDeclaration) int { + if template.MaxRows > 0 { + return template.MaxRows + } + return domain.SCUMDefaultListLimit +} + +// projectSCUMQueryTemplateResult maps one completed database read into the typed +// SCUM tables through the projection declarations of the owning plugin. +func (svc *CoreService) projectSCUMQueryTemplateResult(job domain.Job, stamp time.Time) error { + templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"]) + if templateKey == "" || job.ExecutionResult.Kind != "sqlite.query" { + return nil + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if errors.Is(err, repo.ErrNotFound) { + return nil + } + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if errors.Is(err, repo.ErrNotFound) { + return nil + } + if err != nil { + return err + } + template, ok := scumQueryTemplateByKey(plugin.GameClientBridge.QueryTemplates, templateKey) + if !ok { + return nil + } + rows, err := decodeSCUMQueryRows(job.ExecutionResult.Content) + if err != nil { + log.Printf("SCUM query projection skipped job=%s server=%s template=%s reason=rows_unreadable", job.ID, instance.ID, templateKey) + return nil + } + batch := domain.SCUMFactIngest{ServerInstanceID: instance.ID} + for _, projection := range template.Projections { + switch strings.TrimSpace(projection.Collection) { + case scumProjectionUsers: + batch.Users = append(batch.Users, scumUserFactsFromRows(rows, projection)...) + case scumProjectionVehicles: + batch.Vehicles = append(batch.Vehicles, scumVehicleFactsFromRows(rows, projection)...) + } + } + if len(batch.Users) > domain.SCUMFactBatchLimit { + batch.Users = batch.Users[:domain.SCUMFactBatchLimit] + } + if len(batch.Vehicles) > domain.SCUMFactBatchLimit { + batch.Vehicles = batch.Vehicles[:domain.SCUMFactBatchLimit] + } + if len(batch.Users) == 0 && len(batch.Vehicles) == 0 { + return nil + } + result, err := svc.ingestSCUMFactsForInstance(instance, batch, stamp) + if err != nil { + return err + } + log.Printf("SCUM query projection applied job=%s server=%s template=%s users=%d vehicles=%d accepted=%d/%d", job.ID, instance.ID, templateKey, len(batch.Users), len(batch.Vehicles), result.AcceptedUserCount, result.AcceptedVehicleCount) + return nil +} + +func scumQueryTemplateByKey(templates []domain.GameClientBridgeQueryTemplateDeclaration, key string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) { + for _, template := range templates { + if template.Key == key { + return template, true + } + } + return domain.GameClientBridgeQueryTemplateDeclaration{}, false +} + +type scumQueryRow map[string]any + +func decodeSCUMQueryRows(content string) ([]scumQueryRow, error) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil, errors.New("query result is empty") + } + var payload struct { + Rows []map[string]any `json:"rows"` + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, err + } + rows := make([]scumQueryRow, 0, len(payload.Rows)) + for _, row := range payload.Rows { + if len(row) == 0 { + continue + } + rows = append(rows, scumQueryRow(row)) + } + return rows, nil +} + +func scumRowField(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (any, bool) { + if source, ok := projection.FieldMappings[field]; ok { + value, exists := row[source] + if !exists { + return nil, false + } + return value, true + } + if value, ok := projection.FixedValues[field]; ok { + return value, true + } + value, exists := row[field] + return value, exists +} + +func scumRowText(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) string { + value, ok := scumRowField(row, projection, field) + if !ok || value == nil { + return "" + } + return strings.TrimSpace(scumScalarText(value)) +} + +func scumScalarText(value any) string { + switch typed := value.(type) { + case string: + return typed + case bool: + if typed { + return "true" + } + return "false" + case float64: + if typed == math.Trunc(typed) && math.Abs(typed) < 1e15 { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case int64: + return strconv.FormatInt(typed, 10) + case json.Number: + return typed.String() + default: + return fmt.Sprintf("%v", typed) + } +} + +func scumRowBool(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (bool, bool) { + value, ok := scumRowField(row, projection, field) + if !ok || value == nil { + return false, false + } + switch typed := value.(type) { + case bool: + return typed, true + case float64: + return typed != 0, true + case int64: + return typed != 0, true + default: + text := strings.ToLower(strings.TrimSpace(scumScalarText(typed))) + switch text { + case "1", "true", "yes", "online", "active", "connected": + return true, true + case "0", "false", "no", "offline", "": + return false, true + default: + return false, false + } + } +} + +func scumRowFloat(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (float64, bool) { + value, ok := scumRowField(row, projection, field) + if !ok || value == nil { + return 0, false + } + switch typed := value.(type) { + case float64: + return typed, true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + if err != nil { + return 0, false + } + return parsed, true + default: + parsed, err := strconv.ParseFloat(strings.TrimSpace(scumScalarText(typed)), 64) + if err != nil { + return 0, false + } + return parsed, true + } +} + +func scumRowInt64(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (int64, bool) { + value, ok := scumRowField(row, projection, field) + if !ok || value == nil { + return 0, false + } + switch typed := value.(type) { + case float64: + return int64(math.Round(typed)), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + if err != nil { + return 0, false + } + return parsed, true + default: + parsed, err := strconv.ParseInt(strings.TrimSpace(scumScalarText(typed)), 10, 64) + if err != nil { + return 0, false + } + return parsed, true + } +} + +func scumRowTime(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (time.Time, bool) { + value, ok := scumRowField(row, projection, field) + if !ok || value == nil { + return time.Time{}, false + } + switch typed := value.(type) { + case float64: + seconds, fraction := math.Modf(typed) + return time.Unix(int64(seconds), int64(fraction*float64(time.Second))).UTC(), true + case int64: + return time.Unix(typed, 0).UTC(), true + default: + text := strings.TrimSpace(scumScalarText(typed)) + if text == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000Z", "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, text); err == nil { + return parsed.UTC(), true + } + } + if seconds, err := strconv.ParseInt(text, 10, 64); err == nil { + return time.Unix(seconds, 0).UTC(), true + } + return time.Time{}, false + } +} + +func scumUserFactsFromRows(rows []scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) []domain.SCUMUserFact { + facts := make([]domain.SCUMUserFact, 0, len(rows)) + for _, row := range rows { + steamID := scumRowText(row, projection, "steamId") + if steamID == "" { + continue + } + fact := domain.SCUMUserFact{ + SteamID: steamID, + DisplayName: scumRowText(row, projection, "displayName"), + LoginIP: scumRowText(row, projection, "loginIp"), + RiddenGameVehicleID: scumRowText(row, projection, "riddenGameVehicleId"), + } + if online, ok := scumRowBool(row, projection, "online"); ok { + fact.Online = online + } + if login, ok := scumRowBool(row, projection, "login"); ok { + fact.Login = login + } + if observedAt, ok := scumRowTime(row, projection, "observedAt"); ok { + fact.ObservedAt = observedAt + } + if loginAt, ok := scumRowTime(row, projection, "loginObservedAt"); ok { + fact.LoginObservedAt = loginAt + } + if squadID, ok := scumRowInt64(row, projection, "squadId"); ok { + value := squadID + fact.SquadID = &value + } + if balance, ok := scumRowInt64(row, projection, "bankBalance"); ok { + value := balance + fact.BankBalance = &value + } + if gold, ok := scumRowInt64(row, projection, "goldBars"); ok { + value := gold + fact.GoldBars = &value + } + if position, ok := scumRowPosition(row, projection); ok { + fact.Position = position + } + facts = append(facts, fact) + } + return facts +} + +func scumVehicleFactsFromRows(rows []scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) []domain.SCUMVehicleFact { + facts := make([]domain.SCUMVehicleFact, 0, len(rows)) + for _, row := range rows { + gameVehicleID := scumRowText(row, projection, "gameVehicleId") + if gameVehicleID == "" { + continue + } + fact := domain.SCUMVehicleFact{ + GameVehicleID: gameVehicleID, + VehicleClass: scumRowText(row, projection, "vehicleClass"), + DisplayName: scumRowText(row, projection, "displayName"), + LockedBySteamID: scumRowText(row, projection, "lockedBySteamId"), + } + if exists, ok := scumRowBool(row, projection, "exists"); ok { + value := exists + fact.Exists = &value + } + if locked, ok := scumRowBool(row, projection, "locked"); ok { + value := locked + fact.Locked = &value + } + if lockedAt, ok := scumRowTime(row, projection, "lockedAt"); ok { + fact.LockedAt = lockedAt + } + if observedAt, ok := scumRowTime(row, projection, "observedAt"); ok { + fact.ObservedAt = observedAt + } + if position, ok := scumRowPosition(row, projection); ok { + fact.Position = position + } + facts = append(facts, fact) + } + return facts +} + +func scumRowPosition(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) (*domain.SCUMPosition, bool) { + x, hasX := scumRowFloat(row, projection, "x") + y, hasY := scumRowFloat(row, projection, "y") + z, hasZ := scumRowFloat(row, projection, "z") + if !hasX || !hasY || !hasZ { + return nil, false + } + return &domain.SCUMPosition{X: x, Y: y, Z: z}, true +} diff --git a/platform/service/scum_query_ingest_test.go b/platform/service/scum_query_ingest_test.go new file mode 100644 index 0000000..c76f7db --- /dev/null +++ b/platform/service/scum_query_ingest_test.go @@ -0,0 +1,227 @@ +package service + +import ( + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +func newSCUMQueryIngestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance, *time.Time) { + t.Helper() + clock := fixedTime + svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return clock }) + capability := domain.JobCapabilityRemoteRunDBSQLiteQuery + plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ + ID: "server.scum", + Name: "SCUM", + Version: "1.0.0", + ServerType: "scum", + ManifestRef: "artifact://manifests/server.scum/1.0.0", + CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", + RequiredRunCapabilities: []string{capability}, + DeclaredPermissions: []string{"server.game-client.read"}, + Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, + RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}, + RuntimeProfiles: domain.GamePluginRuntimeProfiles{ + LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{capability}, Platforms: []string{"windows"}}}, + TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{capability}}}, + DataTargets: []domain.RuntimeDataTarget{{Key: "scum-database", Kind: "sqlite.snapshot", TransportKey: "scum-database", SourceRootKey: "server-root", SourcePath: "SCUM/Saved/SaveFiles/SCUM.db", WorkspaceKey: "databases/scum/SCUM.db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 1 << 30, Platforms: []string{"windows"}}}, + }, + GameClientBridge: domain.GameClientBridgeManifest{Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{ + Key: "scum.database.players", Title: "Read SCUM players", Permission: "server.game-client.read", Engine: "sqlite", + TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/players.parameters.schema.json", + ResultSchemaRef: "schemas/queries/players.result.schema.json", SQLRef: "sql/sum-db-v57/players.sql", + PollIntervalSeconds: 60, MaxRows: 500, TimeoutSeconds: 30, + Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{ + Collection: scumProjectionUsers, RowPath: "rows", UpsertKeys: []string{"steamId"}, + FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "loginIp": "lastLoginIp", "online": "online", "login": "freshLogin", "loginObservedAt": "lastLoginTime", "bankBalance": "normalBalance", "goldBars": "goldBalance", "x": "x", "y": "y", "z": "z"}, + }}, + }, { + Key: "scum.database.vehicles", Title: "Read SCUM vehicles", Permission: "server.game-client.read", Engine: "sqlite", + TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/vehicles.parameters.schema.json", + ResultSchemaRef: "schemas/queries/vehicles.result.schema.json", SQLRef: "sql/scum-db-v57/vehicles.sql", + PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30, + Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{ + Collection: scumProjectionVehicles, RowPath: "rows", UpsertKeys: []string{"gameVehicleId"}, + FieldMappings: map[string]string{"gameVehicleId": "gameVehicleId", "vehicleClass": "vehicleClass", "displayName": "displayName", "exists": "existsInGame", "x": "x", "y": "y", "z": "z"}, + }}, + }}}, + }) + if err != nil { + t.Fatalf("create SCUM plugin: %v", err) + } + endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("create run endpoint: %v", err) + } + session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-scum-owner", DisplayName: "SCUM Owner", Email: "scum-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-scum-query", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Query Server", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "run-local", Bindings: map[string]string{}}, true) + if err != nil { + t.Fatalf("build runtime binding: %v", err) + } + if err := svc.store.RuntimeBindings().Create(binding); err != nil { + t.Fatalf("store runtime binding: %v", err) + } + hello := validRunControlHello() + hello.CapabilityReport.Capabilities = []string{capability} + hello.CapabilityReport.Fingerprint = "cap-scum-query" + if _, err := svc.RegisterRunHello(hello); err != nil { + t.Fatalf("register run hello: %v", err) + } + return svc, session, instance, &clock +} + +func refreshSCUMQueryRunHeartbeat(t *testing.T, svc *CoreService, stamp time.Time) { + t.Helper() + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get run endpoint: %v", err) + } + endpoint.LastHeartbeatAt = stamp + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("refresh run endpoint: %v", err) + } +} + +func countSCUMQueryJobs(t *testing.T, svc *CoreService, instanceID string) []domain.Job { + t.Helper() + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instanceID}) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + return jobs +} + +func TestSCUMQueryTemplatesDispatchWithinPollInterval(t *testing.T) { + svc, _, instance, clock := newSCUMQueryIngestFixture(t) + aligned := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC) + *clock = aligned + refreshSCUMQueryRunHeartbeat(t, svc, aligned) + + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("reconcile query templates: %v", err) + } + jobs := countSCUMQueryJobs(t, svc, instance.ID) + if len(jobs) != 2 { + t.Fatalf("expected one job per declared template, got %d", len(jobs)) + } + for _, job := range jobs { + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.TargetKey != "scum-database" { + t.Fatalf("unexpected dispatch job: %+v", job) + } + if job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.RemoteAdapterKind != string(domain.RemoteAdapterDatabase) { + t.Fatalf("unexpected dispatch scope: %+v", job.ExecutionInput) + } + if job.ExecutionInput.Inputs["sqlRef"] == "" || job.ExecutionInput.Inputs["templateKey"] == "" || job.ExecutionInput.Inputs["maxRows"] != "500" { + t.Fatalf("unexpected dispatch inputs: %+v", job.ExecutionInput.Inputs) + } + } + + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("repeat reconcile: %v", err) + } + if repeated := countSCUMQueryJobs(t, svc, instance.ID); len(repeated) != 2 { + t.Fatalf("expected dispatch de-duplication inside one poll interval, got %d jobs", len(repeated)) + } + + *clock = aligned.Add(61 * time.Second) + refreshSCUMQueryRunHeartbeat(t, svc, *clock) + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("reconcile after player poll interval: %v", err) + } + if afterPlayers := countSCUMQueryJobs(t, svc, instance.ID); len(afterPlayers) != 3 { + t.Fatalf("expected only the players template to be due after 61s, got %d jobs", len(afterPlayers)) + } + + *clock = aligned.Add(301 * time.Second) + refreshSCUMQueryRunHeartbeat(t, svc, *clock) + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("reconcile after both poll intervals: %v", err) + } + if afterBoth := countSCUMQueryJobs(t, svc, instance.ID); len(afterBoth) != 5 { + t.Fatalf("expected both templates to be due after 301s, got %d jobs", len(afterBoth)) + } + + *clock = aligned.Add(4000 * time.Second) + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("reconcile with stale heartbeat: %v", err) + } + if staleJobs := countSCUMQueryJobs(t, svc, instance.ID); len(staleJobs) != 5 { + t.Fatalf("expected no dispatch while the run heartbeat is stale, got %d jobs", len(staleJobs)) + } +} + +func TestSCUMQueryProjectionFillsTypedTables(t *testing.T) { + svc, session, instance, _ := newSCUMQueryIngestFixture(t) + if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil { + t.Fatalf("reconcile query templates: %v", err) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + playerJob, vehicleJob := jobs[0], jobs[1] + if playerJob.ExecutionInput.Inputs["templateKey"] != "scum.database.players" { + playerJob, vehicleJob = vehicleJob, playerJob + } + + playerJob.State = domain.JobStateSucceeded + playerJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111","displayName":"love_fitting","lastLoginIp":"218.29.163.205","squadId":"38","online":1,"freshLogin":0,"normalBalance":100000,"goldBalance":7,"lastLoginTime":"2026-09-16T08:00:00.000Z","lastSaveTime":"2026-09-16T08:05:00Z","x":182046.75,"y":573985.875,"z":100962.578}]}`} + if err := svc.projectRemoteAdapterJobResult(playerJob, fixedTime); err != nil { + t.Fatalf("project player rows: %v", err) + } + vehicleJob.State = domain.JobStateSucceeded + vehicleJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"gameVehicleId":"5923426","vehicleClass":"MountainBike_ES","displayName":"","existsInGame":1,"x":-406400.84375,"y":558707.4375,"z":83294.6171875,"observedAt":"2026-09-16T08:05:00Z"}]}`} + if err := svc.projectRemoteAdapterJobResult(vehicleJob, fixedTime); err != nil { + t.Fatalf("project vehicle rows: %v", err) + } + + users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID}) + if err != nil || len(users) != 1 { + t.Fatalf("list users: users=%+v err=%v", users, err) + } + user := users[0] + if user.SteamID != "76561199510658111" || user.DisplayName != "love_fitting" || !user.Online || user.X == nil || *user.X != 182046.75 { + t.Fatalf("unexpected projected user: %+v", user) + } + if user.BankBalance == nil || *user.BankBalance != 100000 || user.GoldBars == nil || *user.GoldBars != 7 || user.SquadID != 38 { + t.Fatalf("unexpected projected economy: %+v", user) + } + if user.LastLoginAt.IsZero() || !user.LastLoginAt.Equal(time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)) { + t.Fatalf("expected last login from database evidence, got %s", user.LastLoginAt) + } + vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID}) + if err != nil || len(vehicles) != 1 || vehicles[0].VehicleClass != "MountainBike_ES" || vehicles[0].X == nil || *vehicles[0].X != -406400.84375 { + t.Fatalf("unexpected projected vehicle: vehicles=%+v err=%v", vehicles, err) + } + userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID}) + if err != nil || len(userTracks) != 1 { + t.Fatalf("expected one projected user trajectory, rows=%+v err=%v", userTracks, err) + } + vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID}) + if err != nil || len(vehicleTracks) != 1 { + t.Fatalf("expected one projected vehicle trajectory, rows=%+v err=%v", vehicleTracks, err) + } +} + +func TestSCUMQueryProjectionIgnoresUndeclaredTemplateResults(t *testing.T) { + svc, session, instance, _ := newSCUMQueryIngestFixture(t) + job := domain.Job{ + ID: "job-unrelated", ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded, + ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": "scum.database.unknown"}}, + ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111"}]}`}, + } + if err := svc.projectRemoteAdapterJobResult(job, fixedTime); err != nil { + t.Fatalf("project undeclared template: %v", err) + } + users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID}) + if err != nil || len(users) != 0 { + t.Fatalf("expected no projected users for an undeclared template, users=%+v err=%v", users, err) + } +} diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index d2dcdb7..49b590d 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -71,7 +71,10 @@ func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) { return nil } - return nil + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.State != domain.JobStateSucceeded { + return nil + } + return svc.projectSCUMQueryTemplateResult(job, stamp) } func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error { diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index ff02a75..ebb210a 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -3,7 +3,7 @@ "id": "game.scum", "name": "SCUM Server", "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.", - "version": "0.1.19", + "version": "0.1.22", "kind": "game-plugin", "tags": [ "scum", @@ -75,6 +75,7 @@ "remote.run.logs.transfer", "remote.run.rcon.command", "remote.run.program.command", + "remote.run.db.sqlite.query", "artifacts.read", "artifacts.write", "ai.invoke" @@ -92,10 +93,13 @@ "remote.run.process.stop", "remote.run.logs.transfer", "remote.run.rcon.command", - "remote.run.program.command" + "remote.run.program.command", + "remote.run.db.sqlite.query" ], "rcon": true, - "databaseEngines": [], + "databaseEngines": [ + "sqlite" + ], "logTransfer": true }, "bridge": { @@ -324,6 +328,79 @@ "player.intelligence" ] } + ], + "queryTemplates": [ + { + "key": "scum.database.players", + "title": "Read SCUM player, squad, economy, and position facts", + "permission": "server.game-client.read", + "engine": "sqlite", + "transportKey": "scum-database", + "targetKey": "scum-database", + "parameterSchemaRef": "schemas/bridge/queries/scum-database-players.parameters.schema.json", + "resultSchemaRef": "schemas/bridge/queries/scum-database-players.result.schema.json", + "sqlRef": "sql/scum-db-v57/players.sql", + "pollIntervalSeconds": 60, + "maxRows": 180, + "timeoutSeconds": 30, + "projections": [ + { + "collection": "scum.users", + "rowPath": "rows", + "upsertKeys": [ + "steamId" + ], + "fieldMappings": { + "steamId": "steamId", + "displayName": "displayName", + "loginIp": "lastLoginIp", + "squadId": "squadId", + "online": "online", + "login": "freshLogin", + "loginObservedAt": "lastLoginTime", + "bankBalance": "normalBalance", + "goldBars": "goldBalance", + "riddenGameVehicleId": "riddenGameVehicleId", + "x": "x", + "y": "y", + "z": "z" + } + } + ] + }, + { + "key": "scum.database.vehicles", + "title": "Read SCUM vehicle positions and classes", + "permission": "server.game-client.read", + "engine": "sqlite", + "transportKey": "scum-database", + "targetKey": "scum-database", + "parameterSchemaRef": "schemas/bridge/queries/scum-database-vehicles.parameters.schema.json", + "resultSchemaRef": "schemas/bridge/queries/scum-database-vehicles.result.schema.json", + "sqlRef": "sql/scum-db-v57/vehicles.sql", + "pollIntervalSeconds": 120, + "maxRows": 220, + "timeoutSeconds": 30, + "projections": [ + { + "collection": "scum.vehicles", + "rowPath": "rows", + "upsertKeys": [ + "gameVehicleId" + ], + "fieldMappings": { + "gameVehicleId": "gameVehicleId", + "vehicleClass": "vehicleClass", + "displayName": "displayName", + "exists": "existsInGame", + "x": "x", + "y": "y", + "z": "z", + "observedAt": "observedAt" + } + } + ] + } ] }, "permissions": [ @@ -857,6 +934,14 @@ { "path": "data-packs/scum-config-v57/map-geometry.json", "mode": 384 + }, + { + "path": "sql/scum-db-v57/players.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/vehicles.sql", + "mode": 384 } ], "productionLifecycle": { @@ -1148,7 +1233,21 @@ "retentionDays": 30 } ], - "dataTargets": [], + "dataTargets": [ + { + "key": "scum-database", + "kind": "sqlite.snapshot", + "transportKey": "scum-database", + "sourceRootKey": "server-root", + "sourcePath": "SCUM/Saved/SaveFiles/SCUM.db", + "workspaceKey": "databases/scum/SCUM.db", + "refreshPolicy": "on-demand-snapshot", + "maxBytes": 1073741824, + "platforms": [ + "windows" + ] + } + ], "transportProfiles": [ { "key": "server-files", @@ -1177,6 +1276,14 @@ "remote.rsync.write" ] }, + { + "key": "scum-database", + "kind": "sqlite", + "targetKey": "scum-database", + "capabilities": [ + "remote.run.db.sqlite.query" + ] + }, { "key": "scum-management", "kind": "rcon", diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.parameters.schema.json new file mode 100644 index 0000000..638b57a --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.parameters.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ScumDatabasePlayersParameters", + "type": "object", + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "description": "Bounded row limit supplied by the platform row cap." + } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.result.schema.json new file mode 100644 index 0000000..e92dd61 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-players.result.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ScumDatabasePlayersResult", + "type": "object", + "additionalProperties": false, + "required": ["rows"], + "properties": { + "rows": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["steamId"], + "properties": { + "steamId": { "type": "string", "minLength": 1, "maxLength": 32 }, + "displayName": { "type": ["string", "null"], "maxLength": 80 }, + "lastLoginIp": { "type": ["string", "null"], "maxLength": 64 }, + "userProfileId": { "type": ["string", "null"], "maxLength": 32 }, + "gamePlayerId": { "type": ["string", "null"], "maxLength": 32 }, + "squadId": { "type": ["string", "null"], "maxLength": 32 }, + "squadName": { "type": ["string", "null"], "maxLength": 80 }, + "riddenGameVehicleId": { "type": ["string", "null"], "maxLength": 32 }, + "x": { "type": ["number", "null"] }, + "y": { "type": ["number", "null"] }, + "z": { "type": ["number", "null"] }, + "moneyBalance": { "type": ["integer", "null"] }, + "normalBalance": { "type": ["integer", "null"] }, + "goldBalance": { "type": ["integer", "null"] }, + "lastLoginTime": { "type": ["string", "null"], "maxLength": 64 }, + "lastLogoutTime": { "type": ["string", "null"], "maxLength": 64 }, + "lastSaveTime": { "type": ["string", "null"], "maxLength": 64 }, + "online": { "type": ["integer", "null"], "enum": [0, 1, null] }, + "freshLogin": { "type": ["integer", "null"], "enum": [0, 1, null] } + } + } + } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.parameters.schema.json new file mode 100644 index 0000000..a635ab1 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.parameters.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ScumDatabaseVehiclesParameters", + "type": "object", + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "description": "Bounded row limit supplied by the platform row cap." + } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.result.schema.json new file mode 100644 index 0000000..2c47d21 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-database-vehicles.result.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ScumDatabaseVehiclesResult", + "type": "object", + "additionalProperties": false, + "required": ["rows"], + "properties": { + "rows": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["gameVehicleId"], + "properties": { + "gameVehicleId": { "type": "string", "minLength": 1, "maxLength": 32 }, + "vehicleClass": { "type": ["string", "null"], "maxLength": 120 }, + "displayName": { "type": ["string", "null"], "maxLength": 120 }, + "functional": { "type": ["integer", "null"], "enum": [0, 1, null] }, + "existsInGame": { "type": ["integer", "null"], "enum": [0, 1, null] }, + "x": { "type": ["number", "null"] }, + "y": { "type": ["number", "null"] }, + "z": { "type": ["number", "null"] }, + "lastAccessTime": { "type": ["string", "null"], "maxLength": 64 }, + "observedAt": { "type": ["string", "null"], "maxLength": 64 } + } + } + } + } +} diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/players.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/players.sql new file mode 100644 index 0000000..3655395 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/players.sql @@ -0,0 +1,26 @@ +SELECT + account.id AS steamId, + COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName, + COALESCE(account.last_direct_connection_address, '') AS lastLoginIp, + CAST(member.squad_id AS TEXT) AS squadId, + entity.location_x AS x, + entity.location_y AS y, + entity.location_z AS z, + 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, + profile.last_login_time AS lastLoginTime, + CASE WHEN profile.last_login_time IS NOT NULL AND (profile.last_logout_time IS NULL OR profile.last_login_time > profile.last_logout_time) THEN 1 ELSE 0 END AS online, + CASE WHEN profile.last_login_time IS NOT NULL AND profile.last_login_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 seconds') THEN 1 ELSE 0 END AS freshLogin, + CAST(mount.vehicle_entity_id AS TEXT) AS riddenGameVehicleId +FROM user account +LEFT JOIN user_profile profile ON profile.user_id = account.id +LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id +LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id +LEFT JOIN entity ON entity.id = prisoner_entity.entity_id +LEFT JOIN squad_member member ON member.user_profile_id = profile.id +LEFT JOIN prisoner_vehicle_mountee_info mount ON mount.prisoner_id = prisoner.id +LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = profile.id +LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = bank.id +GROUP BY account.id +ORDER BY profile.last_login_time DESC +LIMIT :limit 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..02a63e7 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql @@ -0,0 +1,13 @@ +SELECT + CAST(spawner.vehicle_entity_id AS TEXT) AS gameVehicleId, + entity.class AS vehicleClass, + spawner.vehicle_alias AS displayName, + 1 AS existsInGame, + entity.location_x AS x, + entity.location_y AS y, + entity.location_z AS z, + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') AS observedAt +FROM vehicle_spawner spawner +JOIN entity ON entity.id = spawner.vehicle_entity_id +ORDER BY spawner.vehicle_last_access_time DESC +LIMIT :limit diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index d94914d..42e57b7 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -140,18 +140,58 @@ describe("plugin manifest validation", () => { it("removes raw SQL command surfaces", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates?: Array<{ key: string }> } }; + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { + gameClientBridge: { + commands: Array<{ type: string; payloadSchemaRef: string }>; + queryTemplates?: Array<{ key: string; sqlRef: string }>; + pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>; + }; + }; expect(manifest.gameClientBridge.commands.some((command) => command.type === "diagnostic.ping")).toBe(false); expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"])); - expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); + for (const template of manifest.gameClientBridge.queryTemplates ?? []) { + expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/[a-z-]+\.sql$/); + } + expect(manifest.gameClientBridge.pages.some((page) => (page.queryTemplateKeys ?? []).length > 0)).toBe(false); expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false); }); - it("does not declare SCUM direct database templates or bridge projections", () => { + it("declares bounded SCUM database read templates for platform-dispatched projections", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as { - gameClientBridge: { lifecycleProjections?: unknown[]; queryTemplates?: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: unknown[] }> }; + gameClientBridge: { + lifecycleProjections?: unknown[]; + queryTemplates?: Array<{ + key: string; + engine: string; + transportKey: string; + targetKey: string; + pollIntervalSeconds: number; + maxRows: number; + timeoutSeconds: number; + projections?: Array<{ collection: string; rowPath: string; upsertKeys: string[] }>; + }>; + }; }; - expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); + const templates = manifest.gameClientBridge.queryTemplates ?? []; + expect(templates.map((template) => template.key)).toEqual(["scum.database.players", "scum.database.vehicles"]); + for (const template of templates) { + expect(template.engine).toBe("sqlite"); + expect(template.transportKey).toBe("scum-database"); + expect(template.targetKey).toBe("scum-database"); + expect(template.pollIntervalSeconds).toBeGreaterThan(0); + expect(template.pollIntervalSeconds).toBeLessThanOrEqual(300); + expect(template.maxRows).toBeGreaterThan(0); + expect(template.maxRows).toBeLessThanOrEqual(500); + expect(template.timeoutSeconds).toBeGreaterThan(0); + expect(template.timeoutSeconds).toBeLessThanOrEqual(60); + expect((template.projections ?? []).length).toBe(1); + for (const projection of template.projections ?? []) { + expect(projection.rowPath).toBe("rows"); + expect(projection.upsertKeys.length).toBeGreaterThan(0); + } + } + expect(templates.find((template) => template.key === "scum.database.players")?.projections?.[0].collection).toBe("scum.users"); + expect(templates.find((template) => template.key === "scum.database.vehicles")?.projections?.[0].collection).toBe("scum.vehicles"); expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined(); }); @@ -257,7 +297,7 @@ describe("plugin manifest validation", () => { expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true); }); - it("declares management transports without SCUM database access", () => { + it("declares a read-only SCUM database transport beside the management transports", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { remoteAccess?: { databaseEngines?: string[]; runCapabilities?: string[] }; @@ -271,14 +311,18 @@ describe("plugin manifest validation", () => { expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.transportKeys).toContain("scum-management"); expect(local?.transportKeys).not.toContain("scum-database"); - expect(manifest.remoteAccess?.databaseEngines).toEqual([]); - expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); - expect(manifest.runtimeProfiles?.dataTargets).toEqual([]); + expect(manifest.remoteAccess?.databaseEngines).toEqual(["sqlite"]); + expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); + expect(manifest.remoteAccess?.runCapabilities).not.toContain("remote.run.db.sqlite.execute"); + expect(manifest.runtimeProfiles?.dataTargets).toEqual([ + expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot" }) + ]); expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), - expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) + expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }), + expect.objectContaining({ key: "scum-database", kind: "sqlite", targetKey: "scum-database", capabilities: ["remote.run.db.sqlite.query"] }) ])); - expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.key === "scum-database" || profile.kind === "sqlite")).toBe(false); + expect(manifest.runtimeProfiles?.transportProfiles?.flatMap((profile) => profile.capabilities ?? [])).not.toContain("remote.run.db.sqlite.execute"); }); it("covers the SCUM 4.1 bridge and lifecycle declarations", () => { @@ -313,7 +357,7 @@ describe("plugin manifest validation", () => { const serialized = JSON.stringify(manifest).toLowerCase(); expect(serialized).not.toContain("local-proof"); - expect(manifest.version).toBe("0.1.19"); + expect(manifest.version).toBe("0.1.22"); expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([ @@ -328,7 +372,7 @@ describe("plugin manifest validation", () => { expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"])); expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined(); expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined(); - expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); + expect((manifest.gameClientBridge.queryTemplates ?? []).map((template) => template.key)).toEqual(["scum.database.players", "scum.database.vehicles"]); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config"); expect(manifest.gameClientBridge.pages.some((page) => page.queryTemplateKeys?.length)).toBe(false); @@ -452,7 +496,7 @@ describe("plugin manifest validation", () => { expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"])); }); - it("keeps SCUM user and vehicle data off direct database query templates", () => { + it("keeps SCUM database reads platform-dispatched, read-only, and package-scoped", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { capabilities: string[]; @@ -468,15 +512,32 @@ describe("plugin manifest validation", () => { }; assetFiles?: Array<{ path: string }>; }; - expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); - expect(manifest.capabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); - expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); - expect(manifest.remoteAccess?.databaseEngines).toEqual([]); + const templates = manifest.gameClientBridge.queryTemplates ?? []; + expect(templates.length).toBe(2); + const assetPaths = (manifest.assetFiles ?? []).map((file) => file.path); + for (const template of templates) { + const sqlRef = String(template.sqlRef); + expect(sqlRef.startsWith("sql/scum-db-v57/")).toBe(true); + expect(assetPaths).toContain(sqlRef); + expect(fs.existsSync(path.join(pluginDir, sqlRef))).toBe(true); + } + expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); + expect(manifest.capabilities).not.toContain("remote.run.db.sqlite.execute"); + expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); + expect(manifest.remoteAccess?.runCapabilities).not.toContain("remote.run.db.sqlite.execute"); + expect(manifest.remoteAccess?.databaseEngines).toEqual(["sqlite"]); expect(manifest.remoteAccess?.rcon).toBe(true); - expect(manifest.runtimeProfiles?.dataTargets).toEqual([]); - expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.key === "scum-database")).toBe(false); - expect((manifest.assetFiles ?? []).map((file) => file.path).some((assetPath) => assetPath.startsWith("sql/") || assetPath.includes("SCUM_DB_CONTRACT"))).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "sql/scum-db-v57"))).toBe(false); + expect(manifest.runtimeProfiles?.dataTargets).toEqual([ + expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot" }) + ]); + expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" && profile.capabilities.includes("remote.run.db.sqlite.query"))).toBe(true); + expect(manifest.gameClientBridge.pages.some((page) => (page.queryTemplateKeys ?? []).length > 0)).toBe(false); + for (const template of templates) { + const schemaRefs = [String(template.parameterSchemaRef), String(template.resultSchemaRef)]; + for (const schemaRef of schemaRefs) { + expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true); + } + } 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");