Fill the SCUM user and vehicle tables from the plugin-declared database read path
The SCUM 用户管理 list stayed empty because the typed scum_user and scum_vehicle tables had no producer: the run ingest endpoint is signed-run only, and the previously registered plugin templates asked for a Run database capability the endpoint never advertises. The plugin now declares bounded, read-only SQLite projections for players and vehicles (sql/scum-db-v57/*.sql with query schemas), and the platform dispatches those templates as durable remote.run.db.sqlite.query jobs on run heartbeat and page open, then projects the returned rows into the typed SCUM tables through the same shared ingest path used by signed run facts.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1689,6 +1690,9 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques
|
|||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
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))
|
writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const (
|
|||||||
SCUMWelcomeMessageDelay = time.Minute
|
SCUMWelcomeMessageDelay = time.Minute
|
||||||
SCUMDefaultListLimit = 500
|
SCUMDefaultListLimit = 500
|
||||||
SCUMDefaultTrajectoryLimit = 500
|
SCUMDefaultTrajectoryLimit = 500
|
||||||
|
SCUMFactBatchLimit = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
type SCUMPosition struct {
|
type SCUMPosition struct {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ type Core interface {
|
|||||||
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
|
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
|
||||||
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
||||||
ReconcileGameClientBridgeCommands() error
|
ReconcileGameClientBridgeCommands() error
|
||||||
|
ReconcileSCUMQueryTemplatesForRunEndpoint(string) error
|
||||||
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
||||||
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||||
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -151,6 +152,9 @@ func (svc *CoreService) GetSCUMSurfaceForSession(sessionID, serverInstanceID str
|
|||||||
if _, err := svc.reconcileSCUMUserPresence(serverInstanceID, svc.now()); err != nil {
|
if _, err := svc.reconcileSCUMUserPresence(serverInstanceID, svc.now()); err != nil {
|
||||||
return domain.SCUMSurface{}, err
|
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})
|
users, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.SCUMSurface{}, err
|
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 {
|
if len(batch.Users) == 0 && len(batch.Vehicles) == 0 {
|
||||||
return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required")
|
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 {
|
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||||
return domain.SCUMFactIngestResult{}, err
|
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") {
|
if !strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") {
|
||||||
return domain.SCUMFactIngestResult{}, ErrForbidden
|
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 {
|
if _, err := svc.reconcileSCUMUserPresence(instance.ID, stamp); err != nil {
|
||||||
return domain.SCUMFactIngestResult{}, err
|
return domain.SCUMFactIngestResult{}, err
|
||||||
}
|
}
|
||||||
@@ -304,7 +322,11 @@ func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact
|
|||||||
previousActivity := user.LastActivityAt
|
previousActivity := user.LastActivityAt
|
||||||
freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter))
|
freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter))
|
||||||
moved := fact.Online && fact.Position != nil && userPositionMoved(user, fact.Position)
|
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.LastLoginAt = loginAt
|
||||||
}
|
}
|
||||||
user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName)
|
user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,11 @@ func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time
|
|||||||
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
|
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 nil
|
||||||
}
|
}
|
||||||
|
return svc.projectSCUMQueryTemplateResult(job, stamp)
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
||||||
if job.Capability == domain.JobCapabilityConfigWrite {
|
if job.Capability == domain.JobCapabilityConfigWrite {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"id": "game.scum",
|
"id": "game.scum",
|
||||||
"name": "SCUM Server",
|
"name": "SCUM Server",
|
||||||
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
|
"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",
|
"kind": "game-plugin",
|
||||||
"tags": [
|
"tags": [
|
||||||
"scum",
|
"scum",
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"remote.run.logs.transfer",
|
"remote.run.logs.transfer",
|
||||||
"remote.run.rcon.command",
|
"remote.run.rcon.command",
|
||||||
"remote.run.program.command",
|
"remote.run.program.command",
|
||||||
|
"remote.run.db.sqlite.query",
|
||||||
"artifacts.read",
|
"artifacts.read",
|
||||||
"artifacts.write",
|
"artifacts.write",
|
||||||
"ai.invoke"
|
"ai.invoke"
|
||||||
@@ -92,10 +93,13 @@
|
|||||||
"remote.run.process.stop",
|
"remote.run.process.stop",
|
||||||
"remote.run.logs.transfer",
|
"remote.run.logs.transfer",
|
||||||
"remote.run.rcon.command",
|
"remote.run.rcon.command",
|
||||||
"remote.run.program.command"
|
"remote.run.program.command",
|
||||||
|
"remote.run.db.sqlite.query"
|
||||||
],
|
],
|
||||||
"rcon": true,
|
"rcon": true,
|
||||||
"databaseEngines": [],
|
"databaseEngines": [
|
||||||
|
"sqlite"
|
||||||
|
],
|
||||||
"logTransfer": true
|
"logTransfer": true
|
||||||
},
|
},
|
||||||
"bridge": {
|
"bridge": {
|
||||||
@@ -324,6 +328,79 @@
|
|||||||
"player.intelligence"
|
"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": [
|
"permissions": [
|
||||||
@@ -857,6 +934,14 @@
|
|||||||
{
|
{
|
||||||
"path": "data-packs/scum-config-v57/map-geometry.json",
|
"path": "data-packs/scum-config-v57/map-geometry.json",
|
||||||
"mode": 384
|
"mode": 384
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "sql/scum-db-v57/players.sql",
|
||||||
|
"mode": 384
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "sql/scum-db-v57/vehicles.sql",
|
||||||
|
"mode": 384
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"productionLifecycle": {
|
"productionLifecycle": {
|
||||||
@@ -1148,7 +1233,21 @@
|
|||||||
"retentionDays": 30
|
"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": [
|
"transportProfiles": [
|
||||||
{
|
{
|
||||||
"key": "server-files",
|
"key": "server-files",
|
||||||
@@ -1177,6 +1276,14 @@
|
|||||||
"remote.rsync.write"
|
"remote.rsync.write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-database",
|
||||||
|
"kind": "sqlite",
|
||||||
|
"targetKey": "scum-database",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.db.sqlite.query"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "scum-management",
|
"key": "scum-management",
|
||||||
"kind": "rcon",
|
"kind": "rcon",
|
||||||
|
|||||||
+14
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -140,18 +140,58 @@ describe("plugin manifest validation", () => {
|
|||||||
|
|
||||||
it("removes raw SQL command surfaces", () => {
|
it("removes raw SQL command surfaces", () => {
|
||||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
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.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.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);
|
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 {
|
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();
|
expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,7 +297,7 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
|
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 manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||||
remoteAccess?: { databaseEngines?: string[]; runCapabilities?: string[] };
|
remoteAccess?: { databaseEngines?: string[]; runCapabilities?: string[] };
|
||||||
@@ -271,14 +311,18 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
||||||
expect(local?.transportKeys).toContain("scum-management");
|
expect(local?.transportKeys).toContain("scum-management");
|
||||||
expect(local?.transportKeys).not.toContain("scum-database");
|
expect(local?.transportKeys).not.toContain("scum-database");
|
||||||
expect(manifest.remoteAccess?.databaseEngines).toEqual([]);
|
expect(manifest.remoteAccess?.databaseEngines).toEqual(["sqlite"]);
|
||||||
expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
|
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||||
expect(manifest.runtimeProfiles?.dataTargets).toEqual([]);
|
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(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }),
|
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", () => {
|
it("covers the SCUM 4.1 bridge and lifecycle declarations", () => {
|
||||||
@@ -313,7 +357,7 @@ describe("plugin manifest validation", () => {
|
|||||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||||
|
|
||||||
expect(serialized).not.toContain("local-proof");
|
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(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.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([
|
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.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||||
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
|
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
|
||||||
expect(manifest.gameClientBridge.lifecycleProjections).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)).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.map((page) => page.pageKey)).not.toContain("files-config");
|
||||||
expect(manifest.gameClientBridge.pages.some((page) => page.queryTemplateKeys?.length)).toBe(false);
|
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"]));
|
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 pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
@@ -468,15 +512,32 @@ describe("plugin manifest validation", () => {
|
|||||||
};
|
};
|
||||||
assetFiles?: Array<{ path: string }>;
|
assetFiles?: Array<{ path: string }>;
|
||||||
};
|
};
|
||||||
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
|
const templates = manifest.gameClientBridge.queryTemplates ?? [];
|
||||||
expect(manifest.capabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
|
expect(templates.length).toBe(2);
|
||||||
expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
|
const assetPaths = (manifest.assetFiles ?? []).map((file) => file.path);
|
||||||
expect(manifest.remoteAccess?.databaseEngines).toEqual([]);
|
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.remoteAccess?.rcon).toBe(true);
|
||||||
expect(manifest.runtimeProfiles?.dataTargets).toEqual([]);
|
expect(manifest.runtimeProfiles?.dataTargets).toEqual([
|
||||||
expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.key === "scum-database")).toBe(false);
|
expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot" })
|
||||||
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?.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 playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||||
|
|||||||
Reference in New Issue
Block a user