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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user