Remove SCUM sqlite user projections
This commit is contained in:
@@ -102,15 +102,15 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
}
|
||||
plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources,
|
||||
domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "game.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
)
|
||||
plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{
|
||||
Key: "scum-database",
|
||||
Key: "game-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "scum-database",
|
||||
TransportKey: "game-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: "SCUM/Saved/SaveFiles/SCUM.db",
|
||||
WorkspaceKey: "databases/scum-database",
|
||||
SourcePath: "Game/Saved/SaveFiles/state.db",
|
||||
WorkspaceKey: "databases/game-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"linux"},
|
||||
@@ -118,7 +118,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
instance.Deployment.ServerRoot = "/srv/scum"
|
||||
instance.Deployment.ServerRoot = "/srv/game"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("seed instance deployment root: %v", err)
|
||||
}
|
||||
@@ -167,10 +167,10 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
|
||||
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
|
||||
}
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "scum-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "SCUM/Saved/SaveFiles/SCUM.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "game-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "Game/Saved/SaveFiles/state.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") {
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "game.server") {
|
||||
t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources)
|
||||
}
|
||||
var seededPlan domain.RunAutonomousLifecyclePlan
|
||||
|
||||
@@ -11,18 +11,18 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
ownerID := "plugin-data-owner"
|
||||
sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "SCUM"}); err != nil {
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "Game Server"}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}})
|
||||
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}})
|
||||
if err != nil || stored.Value["futureField"] != true {
|
||||
t.Fatalf("put plugin data=%+v err=%v", stored, err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users"})
|
||||
items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users"})
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["steamId"] != "steam-1" {
|
||||
t.Fatalf("list plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "scum_users"}); err != ErrForbidden {
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "plugin_users"}); err != ErrForbidden {
|
||||
t.Fatalf("expected plugin isolation error, got %v", err)
|
||||
}
|
||||
otherOwner, err := svc.CreateUser(domain.User{ID: "other-plugin-data-owner", DisplayName: "Other plugin data owner", Email: "other-plugin-data@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
@@ -32,13 +32,13 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-2", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: otherOwner.ID, Name: "Other server"}); err != nil {
|
||||
t.Fatalf("create other server: %v", err)
|
||||
}
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "scum_users"}); err != ErrForbidden {
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "plugin_users"}); err != ErrForbidden {
|
||||
t.Fatalf("expected server isolation error, got %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "settings", Key: "steam-1", Value: map[string]any{"enabled": true}}); err != nil {
|
||||
t.Fatalf("put second collection: %v", err)
|
||||
}
|
||||
items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "scum_users"})
|
||||
items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "plugin_users"})
|
||||
if err != nil || len(items) != 1 || items[0].Value["futureField"] != true {
|
||||
t.Fatalf("collection isolation values=%+v err=%v", items, err)
|
||||
}
|
||||
@@ -100,11 +100,11 @@ func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing.
|
||||
|
||||
func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil {
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil {
|
||||
t.Fatalf("seed plugin data: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-no-projection-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id",
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "game-db-read", "targetKey": "game-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
@@ -124,7 +124,7 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("complete query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"})
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-keep" || items[0].Value["format"] != "plugin-local" {
|
||||
t.Fatalf("declared query mutated plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
|
||||
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
|
||||
Collection: "plugin_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
|
||||
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
|
||||
}, {
|
||||
Collection: "scum_trade_goods", RowPath: "rows", MatchField: "kind", MatchValue: "vehicle", UpsertKeys: []string{"code"},
|
||||
@@ -192,7 +192,7 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("complete projection query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"})
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil {
|
||||
t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}})
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "game-db.player-lookup", Capabilities: []string{capability}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs := map[string]string{"playerId": "steam-123", "limit": "25"}
|
||||
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs})
|
||||
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "game-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://game-db/player-lookup/lookup-1", Inputs: inputs})
|
||||
if err != nil {
|
||||
t.Fatalf("request remote adapter: %v", err)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
if job.InputRef != "input://game-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not propagated: %#v", job)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
|
||||
@@ -181,6 +181,12 @@ type Core interface {
|
||||
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
||||
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
|
||||
ListSCUMUsersForSession(string, domain.SCUMUserFilter) ([]domain.SCUMUser, error)
|
||||
ListSCUMUserTrajectoriesForSession(string, domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error)
|
||||
ListSCUMVehiclesForSession(string, domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error)
|
||||
ListSCUMVehicleTrajectoriesForSession(string, domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error)
|
||||
ListSCUMVehicleLocksForSession(string, domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error)
|
||||
IngestSCUMFacts(domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error)
|
||||
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
|
||||
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
|
||||
DeletePluginDataForSession(string, string, string, string, string) error
|
||||
|
||||
@@ -1766,8 +1766,8 @@ func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"declarationKey": "game-db-read",
|
||||
"targetKey": "game-db.player-lookup",
|
||||
"idempotencyKey": "query-template-dispatch-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.playerId": "steam-123",
|
||||
@@ -1799,9 +1799,9 @@ func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *
|
||||
declarationKey string
|
||||
targetKey string
|
||||
}{
|
||||
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
|
||||
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "game-db-read", targetKey: "game-db.player-lookup"},
|
||||
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "game-db.player-lookup"},
|
||||
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "game-db-read", targetKey: "game-db.other"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -1874,11 +1874,11 @@ func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"declarationKey": "game-db-read",
|
||||
"targetKey": "game-db.player-lookup",
|
||||
"idempotencyKey": "sqlite-execute-1",
|
||||
"input.mode": "execute",
|
||||
"input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
|
||||
"input.sqlText": "UPDATE players SET score = 855 WHERE id = 'player-123';",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2155,9 +2155,9 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
DatabaseEngines: []string{"sqlite"},
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||
Key: "scum-db-read",
|
||||
Key: "game-db-read",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
TargetKey: "game-db.player-lookup",
|
||||
Capabilities: []string{capability, executeCapability},
|
||||
})
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
@@ -2167,8 +2167,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
Title: "Player lookup",
|
||||
Permission: "server.remote.access",
|
||||
Engine: "sqlite",
|
||||
TransportKey: "scum-db-read",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
TransportKey: "game-db-read",
|
||||
TargetKey: "game-db.player-lookup",
|
||||
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
||||
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
||||
SQLRef: "sql/players.by-id.sql",
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) ListSCUMUsersForSession(sessionID string, filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) {
|
||||
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
||||
return nil, validationError("serverInstanceId is required")
|
||||
}
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := svc.store.SCUMUsers().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
if !users[i].LastActivityAt.Equal(users[j].LastActivityAt) {
|
||||
return users[i].LastActivityAt.After(users[j].LastActivityAt)
|
||||
}
|
||||
return users[i].SteamID < users[j].SteamID
|
||||
})
|
||||
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit)
|
||||
if len(users) > limit {
|
||||
users = users[:limit]
|
||||
}
|
||||
stamp := svc.now()
|
||||
for index := range users {
|
||||
if !users[index].LastLoginAt.IsZero() {
|
||||
users[index].LastLoginAge = stamp.Sub(users[index].LastLoginAt)
|
||||
}
|
||||
}
|
||||
return domain.CopySCUMUsers(users), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMUserTrajectoriesForSession(sessionID string, filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) {
|
||||
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
||||
return nil, validationError("serverInstanceId is required")
|
||||
}
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := svc.store.SCUMUserTrajectories().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if !items[i].ObservedAt.Equal(items[j].ObservedAt) {
|
||||
return items[i].ObservedAt.After(items[j].ObservedAt)
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return domain.CopySCUMUserTrajectories(items), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) {
|
||||
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
||||
return nil, validationError("serverInstanceId is required")
|
||||
}
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vehicles, err := svc.store.SCUMVehicles().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(vehicles, func(i, j int) bool {
|
||||
if !vehicles[i].LastObservedAt.Equal(vehicles[j].LastObservedAt) {
|
||||
return vehicles[i].LastObservedAt.After(vehicles[j].LastObservedAt)
|
||||
}
|
||||
return vehicles[i].GameVehicleID < vehicles[j].GameVehicleID
|
||||
})
|
||||
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit)
|
||||
if len(vehicles) > limit {
|
||||
vehicles = vehicles[:limit]
|
||||
}
|
||||
return domain.CopySCUMVehicles(vehicles), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMVehicleTrajectoriesForSession(sessionID string, filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) {
|
||||
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
||||
return nil, validationError("serverInstanceId is required")
|
||||
}
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := svc.store.SCUMVehicleTrajectories().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if !items[i].ObservedAt.Equal(items[j].ObservedAt) {
|
||||
return items[i].ObservedAt.After(items[j].ObservedAt)
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return domain.CopySCUMVehicleTrajectories(items), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMVehicleLocksForSession(sessionID string, filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) {
|
||||
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
||||
return nil, validationError("serverInstanceId is required")
|
||||
}
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := svc.store.SCUMVehicleLocks().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if !items[i].LockedAt.Equal(items[j].LockedAt) {
|
||||
return items[i].LockedAt.After(items[j].LockedAt)
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return domain.CopySCUMVehicleLocks(items), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) {
|
||||
batch = domain.CopySCUMFactIngest(batch)
|
||||
if strings.TrimSpace(batch.ServerInstanceID) == "" {
|
||||
return domain.SCUMFactIngestResult{}, validationError("serverInstanceId is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.SCUMFactIngestResult{}, err
|
||||
}
|
||||
if instance.RunEndpointID != batch.RunEndpointID {
|
||||
return domain.SCUMFactIngestResult{}, ErrForbidden
|
||||
}
|
||||
stamp := svc.now()
|
||||
welcomeCount := 0
|
||||
for _, fact := range batch.Users {
|
||||
if err := svc.ingestSCUMUserFact(instance, fact, stamp, &welcomeCount); err != nil {
|
||||
return domain.SCUMFactIngestResult{}, err
|
||||
}
|
||||
}
|
||||
for _, fact := range batch.Vehicles {
|
||||
if err := svc.ingestSCUMVehicleFact(instance, fact, stamp); err != nil {
|
||||
return domain.SCUMFactIngestResult{}, err
|
||||
}
|
||||
}
|
||||
return domain.SCUMFactIngestResult{Accepted: true, AcceptedUserCount: len(batch.Users), AcceptedVehicleCount: len(batch.Vehicles), WelcomeQueuedCount: welcomeCount, ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact domain.SCUMUserFact, stamp time.Time, welcomeCount *int) error {
|
||||
steamID := strings.TrimSpace(fact.SteamID)
|
||||
if steamID == "" {
|
||||
return validationError("SCUM user steamId is required")
|
||||
}
|
||||
observedAt := nonZeroTime(fact.ObservedAt, stamp)
|
||||
loginAt := nonZeroTime(fact.LoginObservedAt, observedAt)
|
||||
userID := scumUserID(instance.ID, steamID)
|
||||
user, err := svc.store.SCUMUsers().Get(userID)
|
||||
created := false
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
created = true
|
||||
user = domain.SCUMUser{ID: userID, ServerInstanceID: instance.ID, SteamID: steamID, RegisteredAt: loginAt, CreatedAt: stamp}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
user.LastLoginAt = loginAt
|
||||
}
|
||||
user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName)
|
||||
if fact.SquadID != nil {
|
||||
user.SquadID = *fact.SquadID
|
||||
}
|
||||
user.LastLoginIP = firstNonBlank(fact.LoginIP, user.LastLoginIP)
|
||||
user.Online = fact.Online
|
||||
user.LastActivityAt = latestTime(user.LastActivityAt, observedAt)
|
||||
if fact.Position != nil {
|
||||
user.X, user.Y, user.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
|
||||
}
|
||||
if fact.BankBalance != nil {
|
||||
user.BankBalance = int64Ptr(*fact.BankBalance)
|
||||
}
|
||||
if fact.GoldBars != nil {
|
||||
user.GoldBars = int64Ptr(*fact.GoldBars)
|
||||
}
|
||||
user.GameVehicleID = strings.TrimSpace(fact.RiddenGameVehicleID)
|
||||
if user.GameVehicleID != "" {
|
||||
user.RiddenVehicleID = scumVehicleID(instance.ID, user.GameVehicleID)
|
||||
} else {
|
||||
user.RiddenVehicleID = ""
|
||||
}
|
||||
user.UpdatedAt = stamp
|
||||
if created {
|
||||
if err := svc.store.SCUMUsers().Create(user); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := svc.store.SCUMUsers().Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if moved {
|
||||
track := domain.SCUMUserTrajectory{ID: scumTrajectoryID("scum-user-track", instance.ID, steamID, observedAt), ServerInstanceID: instance.ID, SCUMUserID: user.ID, SteamID: steamID, DisplayName: user.DisplayName, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, RiddenVehicleID: user.RiddenVehicleID, GameVehicleID: user.GameVehicleID, ObservedAt: observedAt, CreatedAt: stamp}
|
||||
if err := createUnlessDuplicate(svc.store.SCUMUserTrajectories(), track); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if freshLogin {
|
||||
queued, queueErr := svc.queueSCUMWelcomeMessage(instance, user, stamp)
|
||||
if queueErr != nil {
|
||||
user.WelcomeLastError = queueErr.Error()
|
||||
} else if queued {
|
||||
user.WelcomeQueuedAt = stamp.Add(domain.SCUMWelcomeMessageDelay)
|
||||
*welcomeCount = *welcomeCount + 1
|
||||
}
|
||||
user.UpdatedAt = stamp
|
||||
if err := svc.store.SCUMUsers().Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fact domain.SCUMVehicleFact, stamp time.Time) error {
|
||||
gameVehicleID := strings.TrimSpace(fact.GameVehicleID)
|
||||
if gameVehicleID == "" {
|
||||
return validationError("SCUM vehicle gameVehicleId is required")
|
||||
}
|
||||
observedAt := nonZeroTime(fact.ObservedAt, stamp)
|
||||
vehicleID := scumVehicleID(instance.ID, gameVehicleID)
|
||||
vehicle, err := svc.store.SCUMVehicles().Get(vehicleID)
|
||||
created := false
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
created = true
|
||||
vehicle = domain.SCUMVehicle{ID: vehicleID, ServerInstanceID: instance.ID, GameVehicleID: gameVehicleID, Exists: true, CreatedAt: stamp}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
moved := fact.Position != nil && vehiclePositionMoved(vehicle, fact.Position)
|
||||
vehicle.VehicleClass = firstNonBlank(fact.VehicleClass, vehicle.VehicleClass)
|
||||
vehicle.DisplayName = firstNonBlank(fact.DisplayName, vehicle.DisplayName)
|
||||
if fact.Exists != nil {
|
||||
vehicle.Exists = *fact.Exists
|
||||
}
|
||||
if fact.Locked != nil {
|
||||
vehicle.Locked = *fact.Locked
|
||||
}
|
||||
if fact.Position != nil {
|
||||
vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
|
||||
}
|
||||
vehicle.LastObservedAt = latestTime(vehicle.LastObservedAt, observedAt)
|
||||
vehicle.UpdatedAt = stamp
|
||||
if created {
|
||||
if err := svc.store.SCUMVehicles().Create(vehicle); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := svc.store.SCUMVehicles().Update(vehicle); err != nil {
|
||||
return err
|
||||
}
|
||||
if moved {
|
||||
track := domain.SCUMVehicleTrajectory{ID: scumTrajectoryID("scum-vehicle-track", instance.ID, gameVehicleID, observedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, VehicleClass: vehicle.VehicleClass, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, ObservedAt: observedAt, CreatedAt: stamp}
|
||||
if err := createUnlessDuplicate(svc.store.SCUMVehicleTrajectories(), track); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(fact.LockedBySteamID) != "" && !fact.LockedAt.IsZero() {
|
||||
lockUserID := scumUserID(instance.ID, strings.TrimSpace(fact.LockedBySteamID))
|
||||
lock := domain.SCUMVehicleLock{ID: scumTrajectoryID("scum-vehicle-lock", instance.ID, gameVehicleID+":"+fact.LockedBySteamID, fact.LockedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, SCUMUserID: lockUserID, SteamID: strings.TrimSpace(fact.LockedBySteamID), LockedAt: fact.LockedAt, CreatedAt: stamp}
|
||||
if err := createUnlessDuplicate(svc.store.SCUMVehicleLocks(), lock); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) queueSCUMWelcomeMessage(instance domain.ServerInstance, user domain.SCUMUser, stamp time.Time) (bool, error) {
|
||||
if instance.State != domain.ServerInstanceStateRunning {
|
||||
return false, nil
|
||||
}
|
||||
resolution, err := svc.resolveSourceRCONDispatch(instance)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
message := fmt.Sprintf("欢迎 %s 回到服务器", firstNonBlank(user.DisplayName, user.SteamID))
|
||||
request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: message, IdempotencyKey: scumWelcomeIdempotencyKey(instance.ID, user.SteamID, user.LastLoginAt)}
|
||||
if err := validator.ValidateSourceRCONCommandRequest(request); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if existing, err := svc.store.Jobs().GetByIdempotency(instance.RunEndpointID, request.IdempotencyKey); err == nil {
|
||||
return existing.QueueEligibleAt.Equal(stamp.Add(domain.SCUMWelcomeMessageDelay)) || !existing.QueueEligibleAt.IsZero(), nil
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return false, err
|
||||
}
|
||||
command := sourceRCONCommandText(request)
|
||||
jobID := jobIDFromParts("job-source-rcon", instance.ID, request.IdempotencyKey)
|
||||
if err := svc.sourceRCONCommands.Put(jobID, command); err != nil {
|
||||
return false, err
|
||||
}
|
||||
job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: resolution.transport.TargetKey, InputRef: "input://source-rcon/" + jobID, IdempotencyKey: request.IdempotencyKey, State: domain.JobStateQueued, Progress: domain.JobProgress{Percent: 0, Message: "SCUM welcome message queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, QueueEligibleAt: stamp.Add(domain.SCUMWelcomeMessageDelay), ExecutionInput: domain.JobExecutionInput{WorkspaceScope: resolution.binding.ProfileKey, RemoteAdapterKey: resolution.transport.Key, RemoteAdapterKind: string(domain.RemoteAdapterRCON), TimeoutSeconds: sourceRCONTimeoutSeconds, PluginID: resolution.plugin.ID, SourceRCON: resolution.plan}}
|
||||
created, err := svc.CreateJob(job)
|
||||
if err != nil {
|
||||
svc.sourceRCONCommands.Delete(jobID)
|
||||
return false, err
|
||||
}
|
||||
return created.ID == jobID, nil
|
||||
}
|
||||
|
||||
func userPositionMoved(user domain.SCUMUser, position *domain.SCUMPosition) bool {
|
||||
if position == nil || user.X == nil || user.Y == nil || user.Z == nil {
|
||||
return true
|
||||
}
|
||||
return *user.X != position.X || *user.Y != position.Y || *user.Z != position.Z
|
||||
}
|
||||
|
||||
func vehiclePositionMoved(vehicle domain.SCUMVehicle, position *domain.SCUMPosition) bool {
|
||||
if position == nil || vehicle.X == nil || vehicle.Y == nil || vehicle.Z == nil {
|
||||
return true
|
||||
}
|
||||
return *vehicle.X != position.X || *vehicle.Y != position.Y || *vehicle.Z != position.Z
|
||||
}
|
||||
|
||||
func boundedSCUMLimit(requested int, fallback int) int {
|
||||
if requested <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if requested > 1000 {
|
||||
return 1000
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func scumUserID(serverID string, steamID string) string {
|
||||
return "scum-user-" + fingerprintID(serverID, strings.TrimSpace(steamID))
|
||||
}
|
||||
|
||||
func scumVehicleID(serverID string, gameVehicleID string) string {
|
||||
return "scum-vehicle-" + fingerprintID(serverID, strings.TrimSpace(gameVehicleID))
|
||||
}
|
||||
|
||||
func scumTrajectoryID(prefix string, serverID string, subjectID string, observedAt time.Time) string {
|
||||
return prefix + "-" + fingerprintID(serverID, strings.TrimSpace(subjectID)+"\x00"+observedAt.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func scumWelcomeIdempotencyKey(serverID string, steamID string, loginAt time.Time) string {
|
||||
return "scum-welcome:" + fingerprintID(serverID, strings.TrimSpace(steamID)+"\x00"+loginAt.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func nonZeroTime(value time.Time, fallback time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func latestTime(left time.Time, right time.Time) time.Time {
|
||||
if left.IsZero() || right.After(left) {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 { return &value }
|
||||
func int64Ptr(value int64) *int64 { return &value }
|
||||
|
||||
type scumCreateOnlyRepository[T any] interface {
|
||||
Create(T) error
|
||||
}
|
||||
|
||||
func createUnlessDuplicate[T any](repository scumCreateOnlyRepository[T], value T) error {
|
||||
if err := repository.Create(value); err != nil && !errors.Is(err, repo.ErrDuplicate) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
|
||||
svc, session, runSession, instance := newSourceRCONFixture(t)
|
||||
squadID := int64(42)
|
||||
bank := int64(1200)
|
||||
gold := int64(3)
|
||||
exists := true
|
||||
locked := true
|
||||
observedAt := fixedTime.Add(-1 * time.Minute)
|
||||
|
||||
result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
SessionToken: runSession,
|
||||
ServerInstanceID: instance.ID,
|
||||
Users: []domain.SCUMUserFact{{
|
||||
SteamID: "76561198000000001", DisplayName: "Mira", SquadID: &squadID, LoginIP: "203.0.113.7", Online: true, Login: true,
|
||||
ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}, BankBalance: &bank, GoldBars: &gold, RiddenGameVehicleID: "game-veh-1",
|
||||
}},
|
||||
Vehicles: []domain.SCUMVehicleFact{{
|
||||
GameVehicleID: "game-veh-1", VehicleClass: "BPC_Laika_C", DisplayName: "Laika", Exists: &exists, Locked: &locked, LockedBySteamID: "76561198000000001", LockedAt: observedAt, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ingest SCUM facts: %v", err)
|
||||
}
|
||||
if !result.Accepted || result.AcceptedUserCount != 1 || result.AcceptedVehicleCount != 1 || result.WelcomeQueuedCount != 1 {
|
||||
t.Fatalf("unexpected ingest result: %+v", result)
|
||||
}
|
||||
|
||||
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(users) != 1 {
|
||||
t.Fatalf("list SCUM users: users=%+v err=%v", users, err)
|
||||
}
|
||||
user := users[0]
|
||||
if user.SteamID != "76561198000000001" || user.SquadID != squadID || user.LastLoginIP != "203.0.113.7" || !user.Online || user.BankBalance == nil || *user.BankBalance != bank || user.GoldBars == nil || *user.GoldBars != gold || user.RiddenVehicleID == "" || user.GameVehicleID != "game-veh-1" {
|
||||
t.Fatalf("unexpected SCUM user: %+v", user)
|
||||
}
|
||||
if user.LastLoginAge != time.Minute {
|
||||
t.Fatalf("expected one-minute login age, got %s", user.LastLoginAge)
|
||||
}
|
||||
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("expected one queued welcome job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
if jobs[0].Capability != domain.JobCapabilityRemoteRunRCONCommand || !jobs[0].QueueEligibleAt.Equal(fixedTime.Add(domain.SCUMWelcomeMessageDelay)) {
|
||||
t.Fatalf("unexpected welcome job: %+v", jobs[0])
|
||||
}
|
||||
|
||||
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(userTracks) != 1 || userTracks[0].RiddenVehicleID != user.RiddenVehicleID {
|
||||
t.Fatalf("unexpected user trajectory rows: rows=%+v err=%v", userTracks, err)
|
||||
}
|
||||
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(vehicles) != 1 || vehicles[0].GameVehicleID != "game-veh-1" || !vehicles[0].Exists || !vehicles[0].Locked {
|
||||
t.Fatalf("unexpected SCUM vehicles: vehicles=%+v err=%v", vehicles, err)
|
||||
}
|
||||
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(vehicleTracks) != 1 || vehicleTracks[0].SCUMVehicleID != vehicles[0].ID {
|
||||
t.Fatalf("unexpected vehicle trajectory rows: rows=%+v err=%v", vehicleTracks, err)
|
||||
}
|
||||
locks, err := svc.ListSCUMVehicleLocksForSession(session, domain.SCUMVehicleLockFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(locks) != 1 || locks[0].SCUMUserID != user.ID || locks[0].SCUMVehicleID != vehicles[0].ID {
|
||||
t.Fatalf("unexpected vehicle lock history: locks=%+v err=%v", locks, err)
|
||||
}
|
||||
|
||||
_, err = svc.IngestSCUMFacts(domain.SCUMFactIngest{
|
||||
RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID,
|
||||
Users: []domain.SCUMUserFact{{SteamID: "76561198000000001", Online: true, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}}},
|
||||
Vehicles: []domain.SCUMVehicleFact{{GameVehicleID: "game-veh-1", ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("re-ingest unchanged SCUM facts: %v", err)
|
||||
}
|
||||
userTracks, _ = svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||
vehicleTracks, _ = svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||
if len(userTracks) != 1 || len(vehicleTracks) != 1 {
|
||||
t.Fatalf("unchanged positions must not create trajectory rows, users=%+v vehicles=%+v", userTracks, vehicleTracks)
|
||||
}
|
||||
|
||||
_, err = svc.IngestSCUMFacts(domain.SCUMFactIngest{
|
||||
RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID,
|
||||
Users: []domain.SCUMUserFact{{SteamID: "76561198000000001", Online: false, ObservedAt: fixedTime, Position: &domain.SCUMPosition{X: 11, Y: 21, Z: 4}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ingest offline SCUM user fact: %v", err)
|
||||
}
|
||||
userTracks, _ = svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||
if len(userTracks) != 1 {
|
||||
t.Fatalf("offline users must not create trajectory rows, rows=%+v", userTracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMFactIngestQueuesFreshWelcomeAfterActivityGap(t *testing.T) {
|
||||
svc, session, runSession, instance := newSourceRCONFixture(t)
|
||||
firstLogin := fixedTime.Add(-20 * time.Minute)
|
||||
secondLogin := fixedTime.Add(-4 * time.Minute)
|
||||
|
||||
for _, loginAt := range []time.Time{firstLogin, secondLogin} {
|
||||
result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID, Users: []domain.SCUMUserFact{{SteamID: "76561198000000002", DisplayName: "Noah", Online: true, Login: true, ObservedAt: loginAt, LoginObservedAt: loginAt}}})
|
||||
if err != nil {
|
||||
t.Fatalf("ingest login at %s: %v", loginAt, err)
|
||||
}
|
||||
if result.WelcomeQueuedCount != 1 {
|
||||
t.Fatalf("expected fresh welcome for login at %s, got %+v", loginAt, result)
|
||||
}
|
||||
}
|
||||
|
||||
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID, SteamID: "76561198000000002"})
|
||||
if err != nil || len(users) != 1 {
|
||||
t.Fatalf("list gap user: users=%+v err=%v", users, err)
|
||||
}
|
||||
if users[0].LastLoginAge != 4*time.Minute {
|
||||
t.Fatalf("expected last login age to show the second login, got %s", users[0].LastLoginAge)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 2 {
|
||||
t.Fatalf("expected two welcome jobs after activity gap, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user