diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 797f451..f698d98 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -44,6 +44,18 @@ type GameClientBridgeQueryTemplateDeclaration struct { MaxRows int TimeoutSeconds int PollIntervalSeconds int + Projections []GameClientBridgeQueryProjectionDeclaration +} + +type GameClientBridgeQueryProjectionDeclaration struct { + Collection string + RowPath string + MatchField string + MatchValue string + UpsertKeys []string + FieldMappings map[string]string + FixedValues map[string]string + ObservedAtField string } type GameClientBridgeLogProjectionStepDeclaration struct { @@ -54,6 +66,7 @@ type GameClientBridgeLogProjectionTargetDeclaration struct { Collection string UpsertKeys []string CaptureMappings map[string]string + HashMappings map[string]string FixedValues map[string]string ObservedAtField string } @@ -64,6 +77,30 @@ type GameClientBridgeLogProjectionPresenceDeclaration struct { ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration } +type GameClientBridgeLifecycleProjectionDeclaration struct { + Key string + Capabilities []string + ProcessStates []string + Target GameClientBridgeBulkProjectionTargetDeclaration +} + +type GameClientBridgeBulkProjectionTargetDeclaration struct { + Collection string + MatchField string + MatchValue string + FixedValues map[string]string + ObservedAtField string + ActivityTarget *GameClientBridgeBulkActivityTargetDeclaration +} + +type GameClientBridgeBulkActivityTargetDeclaration struct { + Collection string + UpsertKeys []string + RowMappings map[string]string + FixedValues map[string]string + ObservedAtField string +} + type GameClientBridgeLogProjectionDeclaration struct { Key string StreamKeys []string @@ -115,15 +152,16 @@ type GameClientBridgeCompanionDeclaration struct { } type GameClientBridgeManifest struct { - Commands []GameClientBridgeCommandDeclaration - Snapshots []GameClientBridgeSnapshotDeclaration - QueryTemplates []GameClientBridgeQueryTemplateDeclaration - LogProjections []GameClientBridgeLogProjectionDeclaration - DataPacks []GameClientBridgeDataPackDeclaration - Retention GameClientBridgeRetention - Pages []GameClientBridgePageContract - Features []GameClientBridgeFeatureDeclaration - Companion GameClientBridgeCompanionDeclaration + Commands []GameClientBridgeCommandDeclaration + Snapshots []GameClientBridgeSnapshotDeclaration + QueryTemplates []GameClientBridgeQueryTemplateDeclaration + LogProjections []GameClientBridgeLogProjectionDeclaration + LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration + DataPacks []GameClientBridgeDataPackDeclaration + Retention GameClientBridgeRetention + Pages []GameClientBridgePageContract + Features []GameClientBridgeFeatureDeclaration + Companion GameClientBridgeCompanionDeclaration } type GameClientBridgeResultStatus string @@ -394,10 +432,17 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) + for index := range value.QueryTemplates { + value.QueryTemplates[index] = CopyGameClientBridgeQueryTemplateDeclaration(value.QueryTemplates[index]) + } value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...) for index := range value.LogProjections { value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index]) } + value.LifecycleProjections = append([]GameClientBridgeLifecycleProjectionDeclaration(nil), value.LifecycleProjections...) + for index := range value.LifecycleProjections { + value.LifecycleProjections[index] = CopyGameClientBridgeLifecycleProjectionDeclaration(value.LifecycleProjections[index]) + } value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...) for index := range value.DataPacks { value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs) @@ -419,6 +464,44 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid return value } +func CopyGameClientBridgeQueryTemplateDeclaration(value GameClientBridgeQueryTemplateDeclaration) GameClientBridgeQueryTemplateDeclaration { + value.Projections = append([]GameClientBridgeQueryProjectionDeclaration(nil), value.Projections...) + for index := range value.Projections { + value.Projections[index] = CopyGameClientBridgeQueryProjectionDeclaration(value.Projections[index]) + } + return value +} + +func CopyGameClientBridgeQueryProjectionDeclaration(value GameClientBridgeQueryProjectionDeclaration) GameClientBridgeQueryProjectionDeclaration { + value.UpsertKeys = CopyStringSlice(value.UpsertKeys) + value.FieldMappings = CopyStringMap(value.FieldMappings) + value.FixedValues = CopyStringMap(value.FixedValues) + return value +} + +func CopyGameClientBridgeLifecycleProjectionDeclaration(value GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclaration { + value.Capabilities = CopyStringSlice(value.Capabilities) + value.ProcessStates = CopyStringSlice(value.ProcessStates) + value.Target = CopyGameClientBridgeBulkProjectionTargetDeclaration(value.Target) + return value +} + +func CopyGameClientBridgeBulkProjectionTargetDeclaration(value GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetDeclaration { + value.FixedValues = CopyStringMap(value.FixedValues) + if value.ActivityTarget != nil { + activity := CopyGameClientBridgeBulkActivityTargetDeclaration(*value.ActivityTarget) + value.ActivityTarget = &activity + } + return value +} + +func CopyGameClientBridgeBulkActivityTargetDeclaration(value GameClientBridgeBulkActivityTargetDeclaration) GameClientBridgeBulkActivityTargetDeclaration { + value.UpsertKeys = CopyStringSlice(value.UpsertKeys) + value.RowMappings = CopyStringMap(value.RowMappings) + value.FixedValues = CopyStringMap(value.FixedValues) + return value +} + func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration { value.StreamKeys = CopyStringSlice(value.StreamKeys) value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...) @@ -438,6 +521,7 @@ func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProje func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration { value.UpsertKeys = CopyStringSlice(value.UpsertKeys) value.CaptureMappings = CopyStringMap(value.CaptureMappings) + value.HashMappings = CopyStringMap(value.HashMappings) value.FixedValues = CopyStringMap(value.FixedValues) return value } diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 1dffc81..795f94c 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,24 +4,29 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql"}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}}}}, LogProjections: []GameClientBridgeLogProjectionDeclaration{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, - Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, + Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}}, }}, - DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}}, - Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, + LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}}, + DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}}, + Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" + manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated" manifestCopy.LogProjections[0].StreamKeys[0] = "mutated" manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" + manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated" manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" + manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated" + manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 3db3a3e..2e2335f 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -279,18 +279,30 @@ type GameClientBridgeSnapshotDeclarationBody struct { } type GameClientBridgeQueryTemplateDeclarationBody struct { - Key string `json:"key"` - Title string `json:"title"` - Permission string `json:"permission"` - Engine string `json:"engine"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - ParameterSchemaRef string `json:"parameterSchemaRef"` - ResultSchemaRef string `json:"resultSchemaRef"` - SQLRef string `json:"sqlRef,omitempty"` - MaxRows int `json:"maxRows"` - TimeoutSeconds int `json:"timeoutSeconds"` - PollIntervalSeconds int `json:"pollIntervalSeconds"` + Key string `json:"key"` + Title string `json:"title"` + Permission string `json:"permission"` + Engine string `json:"engine"` + TransportKey string `json:"transportKey"` + TargetKey string `json:"targetKey"` + ParameterSchemaRef string `json:"parameterSchemaRef"` + ResultSchemaRef string `json:"resultSchemaRef"` + SQLRef string `json:"sqlRef,omitempty"` + MaxRows int `json:"maxRows"` + TimeoutSeconds int `json:"timeoutSeconds"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + Projections []GameClientBridgeQueryProjectionDeclarationBody `json:"projections,omitempty"` +} + +type GameClientBridgeQueryProjectionDeclarationBody struct { + Collection string `json:"collection"` + RowPath string `json:"rowPath"` + MatchField string `json:"matchField,omitempty"` + MatchValue string `json:"matchValue,omitempty"` + UpsertKeys []string `json:"upsertKeys"` + FieldMappings map[string]string `json:"fieldMappings,omitempty"` + FixedValues map[string]string `json:"fixedValues,omitempty"` + ObservedAtField string `json:"observedAtField,omitempty"` } type GameClientBridgeLogProjectionStepDeclarationBody struct { @@ -301,6 +313,7 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct { Collection string `json:"collection"` UpsertKeys []string `json:"upsertKeys"` CaptureMappings map[string]string `json:"captureMappings"` + HashMappings map[string]string `json:"hashMappings,omitempty"` FixedValues map[string]string `json:"fixedValues,omitempty"` ObservedAtField string `json:"observedAtField,omitempty"` } @@ -311,6 +324,30 @@ type GameClientBridgeLogProjectionPresenceDeclarationBody struct { ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"` } +type GameClientBridgeLifecycleProjectionDeclarationBody struct { + Key string `json:"key"` + Capabilities []string `json:"capabilities"` + ProcessStates []string `json:"processStates,omitempty"` + Target GameClientBridgeBulkProjectionTargetBody `json:"target"` +} + +type GameClientBridgeBulkProjectionTargetBody struct { + Collection string `json:"collection"` + MatchField string `json:"matchField"` + MatchValue string `json:"matchValue"` + FixedValues map[string]string `json:"fixedValues"` + ObservedAtField string `json:"observedAtField,omitempty"` + ActivityTarget *GameClientBridgeBulkActivityTargetBody `json:"activityTarget,omitempty"` +} + +type GameClientBridgeBulkActivityTargetBody struct { + Collection string `json:"collection"` + UpsertKeys []string `json:"upsertKeys"` + RowMappings map[string]string `json:"rowMappings"` + FixedValues map[string]string `json:"fixedValues,omitempty"` + ObservedAtField string `json:"observedAtField,omitempty"` +} + type GameClientBridgeLogProjectionDeclarationBody struct { Key string `json:"key"` StreamKeys []string `json:"streamKeys"` @@ -362,16 +399,17 @@ type GameClientBridgeCompanionDeclarationBody struct { } type GameClientBridgeManifestBody struct { - Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` - Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` - QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` - LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"` - DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` - CommandRetentionSeconds int `json:"commandRetentionSeconds"` - MaxCommands int `json:"maxCommands"` - Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` - Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` - Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` + Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` + Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` + QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` + LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"` + LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"` + DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` + CommandRetentionSeconds int `json:"commandRetentionSeconds"` + MaxCommands int `json:"maxCommands"` + Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` + Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` + Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` } type GamePluginManifestBody struct { ID string `json:"id"` @@ -1242,12 +1280,16 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif } queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates)) for index, template := range body.QueryTemplates { - queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds} + queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)} } logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections)) for index, projection := range body.LogProjections { logProjections[index] = gameClientBridgeLogProjectionToDomain(projection) } + lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections)) + for index, projection := range body.LifecycleProjections { + lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection) + } dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks)) for index, dataPack := range body.DataPacks { dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)} @@ -1264,7 +1306,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif if body.Companion != nil { companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} } - return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} } func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration { @@ -1291,8 +1333,35 @@ func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDe } } +func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration { + if values == nil { + return nil + } + out := make([]domain.GameClientBridgeQueryProjectionDeclaration, len(values)) + for index, value := range values { + out[index] = domain.GameClientBridgeQueryProjectionDeclaration{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + } + return out +} + +func gameClientBridgeLifecycleProjectionToDomain(value GameClientBridgeLifecycleProjectionDeclarationBody) domain.GameClientBridgeLifecycleProjectionDeclaration { + return domain.GameClientBridgeLifecycleProjectionDeclaration{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetToDomain(value.Target)} +} + +func gameClientBridgeBulkProjectionTargetToDomain(value GameClientBridgeBulkProjectionTargetBody) domain.GameClientBridgeBulkProjectionTargetDeclaration { + return domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetToDomainPointer(value.ActivityTarget)} +} + +func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBulkActivityTargetBody) *domain.GameClientBridgeBulkActivityTargetDeclaration { + if value == nil { + return nil + } + target := domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + return &target +} + func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration { - return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} } func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration { @@ -1710,12 +1779,16 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G } queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates)) for index, template := range value.QueryTemplates { - queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds} + queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)} } logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections)) for index, projection := range value.LogProjections { logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection) } + lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections)) + for index, projection := range value.LifecycleProjections { + lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection) + } dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks)) for index, dataPack := range value.DataPacks { dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)} @@ -1732,7 +1805,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G if value.Companion.ProfileKey != "" { companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} } - return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} } func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody { @@ -1759,8 +1832,35 @@ func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogPro } } +func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody { + if values == nil { + return nil + } + out := make([]GameClientBridgeQueryProjectionDeclarationBody, len(values)) + for index, value := range values { + out[index] = GameClientBridgeQueryProjectionDeclarationBody{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + } + return out +} + +func gameClientBridgeLifecycleProjectionFromDomain(value domain.GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclarationBody { + return GameClientBridgeLifecycleProjectionDeclarationBody{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetFromDomain(value.Target)} +} + +func gameClientBridgeBulkProjectionTargetFromDomain(value domain.GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetBody { + return GameClientBridgeBulkProjectionTargetBody{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetFromDomainPointer(value.ActivityTarget)} +} + +func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClientBridgeBulkActivityTargetDeclaration) *GameClientBridgeBulkActivityTargetBody { + if value == nil { + return nil + } + target := GameClientBridgeBulkActivityTargetBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + return &target +} + func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody { - return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} } func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 1833a3b..b893677 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -137,12 +137,14 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3, + Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}}, }}, LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, - Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, + Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}}, }}, + LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}}, DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}}, CommandRetentionSeconds: 86400, MaxCommands: 1000, @@ -150,14 +152,24 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } domainManifest := body.ToDomain() - if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } + domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated" + if body.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" { + t.Fatal("query projection target aliases request DTO data") + } + domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId" domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" { t.Fatal("log projection target aliases request DTO data") } domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId" + domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated" + if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" { + t.Fatal("lifecycle projection target aliases request DTO data") + } + domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "steamId" domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated" if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatal("query template page keys alias request DTO data") @@ -170,10 +182,19 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { domainManifest.DataPacks[0].DataRefs[0] = "data/items.json" response := gameClientBridgeManifestFromDomain(domainManifest) + response.QueryTemplates[0].Projections[0].FixedValues["source"] = "mutated" + if domainManifest.QueryTemplates[0].Projections[0].FixedValues["source"] != "sqlite" { + t.Fatal("query projection target aliases domain data") + } + response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite" response.LogProjections[0].Target.FixedValues["source"] = "mutated" if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" { t.Fatal("log projection target aliases domain data") } + response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated" + if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" { + t.Fatal("lifecycle projection target aliases domain data") + } response.Pages[0].QueryTemplateKeys[0] = "mutated" if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatal("query template page keys alias domain data") @@ -187,7 +208,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { if err := json.Unmarshal(encoded, &projection); err != nil { t.Fatalf("decode safe query template projection: %v", err) } - expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds"} + expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "projections"} if len(projection) != len(expectedFields) { t.Fatalf("query template projection contains unexpected fields: %s", encoded) } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 3b8789f..743a7e8 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai } job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp) if !ok { - return emptyJobClaim(claim.RunEndpointID, stamp), nil + if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil { + return domain.RunJobClaimResult{}, err + } + jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) + if err != nil { + return domain.RunJobClaimResult{}, err + } + job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp) + if !ok { + return emptyJobClaim(claim.RunEndpointID, stamp), nil + } } leaseToken, err := randomToken() @@ -342,6 +352,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } + if err := svc.projectPluginQueryJobResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil } diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index 6bf4126..c0e9d9b 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -156,3 +156,41 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) { t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err) } } + +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"}, + FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", + }} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("enable query projection polling: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("projection query was not scheduled: %+v err=%v", claim, err) + } + if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" { + t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs) + } + _, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}}) + 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"}) + 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) + } + second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || second.HasJob { + t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err) + } +} diff --git a/platform/service/plugin_lifecycle_projection.go b/platform/service/plugin_lifecycle_projection.go new file mode 100644 index 0000000..eccb407 --- /dev/null +++ b/platform/service/plugin_lifecycle_projection.go @@ -0,0 +1,87 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "browser.local/platform/domain" +) + +func (svc *CoreService) projectPluginLifecycleState(instance domain.ServerInstance, plugin domain.GamePlugin, capability string, result domain.JobExecutionResult, stamp time.Time) error { + if len(plugin.GameClientBridge.LifecycleProjections) == 0 { + return nil + } + for _, projection := range plugin.GameClientBridge.LifecycleProjections { + if !containsString(projection.Capabilities, capability) || len(projection.Target.FixedValues) == 0 { + continue + } + if len(projection.ProcessStates) > 0 && !containsString(projection.ProcessStates, strings.TrimSpace(result.ProcessState)) { + continue + } + if err := svc.applyPluginBulkProjection(instance, plugin, projection.Target, stamp); err != nil { + return err + } + } + return nil +} + +func (svc *CoreService) applyPluginBulkProjection(instance domain.ServerInstance, plugin domain.GamePlugin, target domain.GameClientBridgeBulkProjectionTargetDeclaration, stamp time.Time) error { + rows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.Collection}) + if err != nil { + return err + } + mutations := make([]domain.PluginDataMutation, 0, len(rows)) + activityMutations := []domain.PluginDataMutation{} + for _, row := range rows { + if strings.TrimSpace(fmt.Sprint(row.Value[target.MatchField])) != target.MatchValue { + continue + } + value := mergePluginDataValues(row.Value, pluginBulkProjectionValues(target.FixedValues, stamp, row.Value, target.ObservedAtField)) + mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: row.Key, Value: value}) + if target.ActivityTarget != nil { + activity := pluginBulkActivityValue(*target.ActivityTarget, row.Value, stamp) + activityKey, keyErr := pluginDataRowKey(activity, target.ActivityTarget.UpsertKeys) + if keyErr != nil { + return keyErr + } + activityMutations = append(activityMutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity}) + } + } + if len(mutations) > 0 { + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.Collection, Mutations: mutations}); err != nil { + return err + } + } + if len(activityMutations) > 0 && target.ActivityTarget != nil { + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.ActivityTarget.Collection, Mutations: activityMutations}); err != nil { + return err + } + } + return nil +} + +func pluginBulkProjectionValues(fixedValues map[string]string, stamp time.Time, row map[string]any, observedAtField string) map[string]any { + value := make(map[string]any, len(fixedValues)+1) + for key, fixed := range fixedValues { + value[key] = renderQueryProjectionTemplate(fixed, row) + } + if observedAtField != "" { + value[observedAtField] = stamp.UTC().Format(time.RFC3339Nano) + } + return value +} + +func pluginBulkActivityValue(target domain.GameClientBridgeBulkActivityTargetDeclaration, row map[string]any, stamp time.Time) map[string]any { + value := make(map[string]any, len(target.RowMappings)+len(target.FixedValues)+1) + for destination, source := range target.RowMappings { + value[destination] = row[source] + } + for key, fixed := range target.FixedValues { + value[key] = renderQueryProjectionTemplate(fixed, row) + } + if target.ObservedAtField != "" { + value[target.ObservedAtField] = stamp.UTC().Format(time.RFC3339Nano) + } + return value +} diff --git a/platform/service/plugin_log_projection.go b/platform/service/plugin_log_projection.go index b9f1de6..a7a11d3 100644 --- a/platform/service/plugin_log_projection.go +++ b/platform/service/plugin_log_projection.go @@ -1,6 +1,8 @@ package service import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "regexp" @@ -18,7 +20,7 @@ type pluginLogSequenceState struct { } func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error { - if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 { + if len(entries) == 0 || (stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram) { return nil } instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID) @@ -160,7 +162,7 @@ func logCorrelationKey(captures map[string]string, fields []string) string { } func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error { - value := pluginLogProjectionValue(projection.Target, captures, observedAt) + value := pluginLogProjectionValue(instance.ID, projection.Target, captures, observedAt) key, err := pluginDataRowKey(value, projection.Target.UpsertKeys) if err != nil { return err @@ -191,7 +193,7 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, return err } if projection.Presence != nil && projection.Presence.ActivityTarget != nil { - activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt) + activity := pluginLogProjectionValue(instance.ID, *projection.Presence.ActivityTarget, captures, observedAt) activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys) if keyErr != nil { return keyErr @@ -203,11 +205,14 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, return nil } -func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any { - value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1) +func pluginLogProjectionValue(serverID string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any { + value := make(map[string]any, len(target.CaptureMappings)+len(target.HashMappings)+len(target.FixedValues)+1) for destination, capture := range target.CaptureMappings { value[destination] = captures[capture] } + for destination, capture := range target.HashMappings { + value[destination] = logProjectionCorrelationHash(serverID, captures[capture]) + } for key, fixed := range target.FixedValues { value[key] = renderLogProjectionTemplate(fixed, captures) } @@ -217,6 +222,11 @@ func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetD return value } +func logProjectionCorrelationHash(serverID, value string) string { + digest := sha256.Sum256([]byte(serverID + "\x00" + value)) + return hex.EncodeToString(digest[:]) +} + func renderLogProjectionTemplate(template string, captures map[string]string) string { result := template for key, value := range captures { diff --git a/platform/service/plugin_log_projection_test.go b/platform/service/plugin_log_projection_test.go index 03f283c..918c874 100644 --- a/platform/service/plugin_log_projection_test.go +++ b/platform/service/plugin_log_projection_test.go @@ -71,6 +71,89 @@ func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *test assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0) } +func TestLifecycleProjectionMarksOnlineUsersOffline(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{ + Key: "server.stop", Capabilities: []string{domain.LifecycleCapabilityStop}, ProcessStates: []string{"stopped"}, + Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt", "eventType"}, RowMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "logout", "reason": "server-stop"}, ObservedAtField: "observedAt"}}, + }} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update lifecycle projection plugin: %v", err) + } + instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-lifecycle-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM lifecycle", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{ + {Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": "true"}}, + {Operation: domain.PluginDataMutationPut, Key: "steam-2", Value: map[string]any{"steamId": "steam-2", "displayName": "Lin", "online": "false"}}, + }}); err != nil { + t.Fatalf("seed plugin users: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityStop) + helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-projection" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + _, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStop, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}}) + if err != nil { + t.Fatalf("report lifecycle stop: %v", err) + } + users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) + if err != nil || len(users) != 2 { + t.Fatalf("list lifecycle users=%+v err=%v", users, err) + } + for _, user := range users { + if user.Key == "steam-1" && (user.Value["online"] != "false" || user.Value["logoutReason"] != "server-stop" || user.Value["lastLogoutAt"] == nil) { + t.Fatalf("online user was not logged out: %+v", user) + } + if user.Key == "steam-2" && user.Value["logoutReason"] != nil { + t.Fatalf("offline user should not receive duplicate logout: %+v", user) + } + } + activity, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_activity_events"}) + if err != nil || len(activity) != 1 || activity[0].Value["steamId"] != "steam-1" || activity[0].Value["eventType"] != "logout" { + t.Fatalf("lifecycle logout activity not projected: %+v err=%v", activity, err) + } +} + +func TestLifecycleRestartReportMarksOnlineUsersOffline(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{ + Key: "server.restart", Capabilities: []string{"process.restart"}, + Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt"}, + }} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update restart projection plugin: %v", err) + } + instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-restart-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM restart", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": true}}}}); err != nil { + t.Fatalf("seed plugin users: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.restart") + helloRequest.CapabilityReport.Fingerprint = "cap-restart-lifecycle-projection" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + report, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: "process.restart", State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}) + if err != nil || report.ProjectedState != domain.ServerInstanceStateRunning { + t.Fatalf("report lifecycle restart: report=%+v err=%v", report, err) + } + users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) + if err != nil || len(users) != 1 || users[0].Value["online"] != "false" || users[0].Value["logoutReason"] != "server-stop" || users[0].Value["lastLogoutAt"] == nil { + t.Fatalf("restart did not log out online users: %+v err=%v", users, err) + } +} + func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) { t.Helper() entries := make([]domain.LogEntry, len(lines)) diff --git a/platform/service/plugin_query_projection.go b/platform/service/plugin_query_projection.go new file mode 100644 index 0000000..4f3a26c --- /dev/null +++ b/platform/service/plugin_query_projection.go @@ -0,0 +1,215 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "browser.local/platform/domain" +) + +func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error { + if !containsString(claim.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + return nil + } + endpoint, err := svc.store.RunEndpoints().Get(claim.RunEndpointID) + if err != nil || !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + return err + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) + if err != nil { + return err + } + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: claim.RunEndpointID}) + if err != nil { + return err + } + for _, instance := range instances { + if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.PluginID) == "" { + continue + } + plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID) + if pluginErr != nil || !plugin.Permissions.RemoteAccess || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + continue + } + for _, template := range plugin.GameClientBridge.QueryTemplates { + if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 || !pluginQueryTemplateTransportReady(plugin, endpoint, template) { + continue + } + interval := time.Duration(template.PollIntervalSeconds) * time.Second + prefix := pluginQueryPollPrefix(instance.ID, plugin.ID, template.Key) + if pluginQueryPollActiveOrFresh(jobs, prefix, stamp, interval) { + continue + } + bucket := stamp.Unix() / int64(template.PollIntervalSeconds) + idempotencyKey := fmt.Sprintf("%s%d", prefix, bucket) + inputs := map[string]string{"templateKey": template.Key, "maxRows": fmt.Sprint(template.MaxRows)} + if template.SQLRef != "" { + inputs["sqlRef"] = template.SQLRef + } + job := domain.Job{ + ID: jobIDFromParts("job-plugin-query-poll", instance.ID, idempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, + TargetKey: template.TargetKey, + InputRef: fmt.Sprintf("input://plugin-query-poll/%s/%s", instance.ID, template.Key), + IdempotencyKey: idempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: "plugin query projection poll queued"}, + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 2}, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: string(domain.RemoteAdapterDatabase), TimeoutSeconds: template.TimeoutSeconds, PluginID: plugin.ID, Inputs: inputs}, + } + if _, createErr := svc.CreateJob(job); createErr != nil { + return createErr + } + } + } + return nil +} + +func pluginQueryTemplateTransportReady(plugin domain.GamePlugin, endpoint domain.RunEndpoint, template domain.GameClientBridgeQueryTemplateDeclaration) bool { + if template.Engine != "sqlite" || template.TransportKey == "" || template.TargetKey == "" { + return false + } + for _, profile := range plugin.RuntimeProfiles.TransportProfiles { + if profile.Key == template.TransportKey && profile.Kind == "sqlite" && profile.TargetKey == template.TargetKey && containsString(profile.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + return containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) + } + } + return false +} + +func pluginQueryPollPrefix(serverID, pluginID, templateKey string) string { + return fmt.Sprintf("plugin-query-poll:%s:%s:%s:", serverID, pluginID, templateKey) +} + +func pluginQueryPollActiveOrFresh(jobs []domain.Job, prefix string, stamp time.Time, interval time.Duration) bool { + for _, job := range jobs { + if !strings.HasPrefix(job.IdempotencyKey, prefix) { + continue + } + if !isTerminalJobState(job.State) { + return true + } + freshAt := job.TerminalAt + if freshAt.IsZero() { + freshAt = job.UpdatedAt + } + if !freshAt.IsZero() && stamp.Sub(freshAt) < interval { + return true + } + } + return false +} + +func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.Time) error { + if job.State != domain.JobStateSucceeded || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionResult.Kind != "sqlite.query" { + return nil + } + templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"]) + if templateKey == "" || strings.TrimSpace(job.ServerInstanceID) == "" { + return nil + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return err + } + template, ok := pluginQueryTemplateByKey(plugin, templateKey) + if !ok || len(template.Projections) == 0 { + return nil + } + rows, err := pluginQueryRows(job.ExecutionResult.Content) + if err != nil { + return err + } + mutationsByCollection := map[string]map[string]domain.PluginDataMutation{} + for _, projection := range template.Projections { + if projection.RowPath != "rows" { + continue + } + collectionMutations := mutationsByCollection[projection.Collection] + if collectionMutations == nil { + collectionMutations = map[string]domain.PluginDataMutation{} + mutationsByCollection[projection.Collection] = collectionMutations + } + for _, row := range rows { + if projection.MatchField != "" && strings.TrimSpace(fmt.Sprint(row[projection.MatchField])) != projection.MatchValue { + continue + } + value := pluginQueryProjectionValue(projection, row, stamp) + key, keyErr := pluginDataRowKey(value, projection.UpsertKeys) + if keyErr != nil { + return keyErr + } + collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value} + } + } + for collection, keyed := range mutationsByCollection { + mutations := make([]domain.PluginDataMutation, 0, len(keyed)) + for _, mutation := range keyed { + mutations = append(mutations, mutation) + } + if len(mutations) == 0 { + continue + } + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: collection, Mutations: mutations}); err != nil { + return err + } + } + return nil +} + +func pluginQueryTemplateByKey(plugin domain.GamePlugin, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) { + for _, template := range plugin.GameClientBridge.QueryTemplates { + if template.Key == templateKey { + return template, true + } + } + return domain.GameClientBridgeQueryTemplateDeclaration{}, false +} + +func pluginQueryRows(content string) ([]map[string]any, error) { + var payload struct { + Rows []map[string]any `json:"rows"` + } + decoder := json.NewDecoder(bytes.NewBufferString(content)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return nil, validationError("sqlite query result content is not a row payload") + } + return payload.Rows, nil +} + +func pluginQueryProjectionValue(projection domain.GameClientBridgeQueryProjectionDeclaration, row map[string]any, observedAt time.Time) map[string]any { + value := map[string]any{} + if len(projection.FieldMappings) == 0 { + for key, item := range row { + value[key] = item + } + } else { + for destination, source := range projection.FieldMappings { + value[destination] = row[source] + } + } + for key, fixed := range projection.FixedValues { + value[key] = renderQueryProjectionTemplate(fixed, row) + } + if projection.ObservedAtField != "" { + value[projection.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano) + } + return value +} + +func renderQueryProjectionTemplate(template string, row map[string]any) string { + result := template + for key, value := range row { + result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value)) + } + return result +} diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index 11d521e..298c2a1 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -29,7 +29,8 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do stamp := svc.now() nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult) - if lifecycleObservationIsStale(instance, report) { + staleObservation := lifecycleObservationIsStale(instance, report) + if !projected || staleObservation { projected = false nextState = instance.State } @@ -52,6 +53,15 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do } svc.publishLogProcessState(instance) } + if report.State == domain.JobStateSucceeded && !staleObservation { + plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID) + if pluginErr != nil { + return domain.RunLifecycleReportResult{}, pluginErr + } + if err := svc.projectPluginLifecycleState(instance, plugin, report.Capability, report.ExecutionResult, stamp); err != nil { + return domain.RunLifecycleReportResult{}, err + } + } return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil } @@ -97,6 +107,9 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim } nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult) if !ok || job.ServerInstanceID == "" { + if job.State == domain.JobStateSucceeded && job.ServerInstanceID != "" { + return svc.projectPluginLifecycleStateForJob(job, stamp) + } return nil } instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) @@ -112,9 +125,30 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim return err } svc.publishLogProcessState(instance) + if job.State == domain.JobStateSucceeded { + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return err + } + if err := svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp); err != nil { + return err + } + } return nil } +func (svc *CoreService) projectPluginLifecycleStateForJob(job domain.Job, stamp time.Time) error { + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return err + } + return svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp) +} + func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error { if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" { return nil diff --git a/platform/validator/game_client_bridge_companion_test.go b/platform/validator/game_client_bridge_companion_test.go index d5b3f96..aad5d47 100644 --- a/platform/validator/game_client_bridge_companion_test.go +++ b/platform/validator/game_client_bridge_companion_test.go @@ -36,7 +36,7 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) { bridge, profiles := validGameClientBridgeCompanionManifest() - if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 { + if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, profiles); len(violations) != 0 { t.Fatalf("expected valid companion declaration, got %v", violations) } @@ -74,7 +74,7 @@ func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) { t.Run(test.name, func(t *testing.T) { candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest() test.mutate(&candidateBridge, &candidateProfiles) - violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles) + violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, nil, candidateProfiles) if !strings.Contains(strings.Join(violations, "; "), test.expected) { t.Fatalf("expected %q violation, got %v", test.expected, violations) } diff --git a/platform/validator/game_client_bridge_log_projection_test.go b/platform/validator/game_client_bridge_log_projection_test.go index 5bafe37..0da47d2 100644 --- a/platform/validator/game_client_bridge_log_projection_test.go +++ b/platform/validator/game_client_bridge_log_projection_test.go @@ -24,7 +24,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, } profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}} - if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, profiles); len(violations) != 0 { + if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, nil, profiles); len(violations) != 0 { t.Fatalf("expected repeated named captures across steps to validate, got %v", violations) } @@ -52,7 +52,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { candidateProfiles := profiles candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...) test.mutate(&candidate, &candidateProfiles) - violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, candidateProfiles) + violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, nil, candidateProfiles) if !strings.Contains(strings.Join(violations, "; "), test.expected) { t.Fatalf("expected %q violation, got %v", test.expected, violations) } diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index ccee2da..dd12a9d 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -65,7 +65,7 @@ func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error { violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID) violations = appendRequired(violations, "capability", report.Capability) if !validLifecycleReportCapability(report.Capability) { - violations = append(violations, "capability must be process.install, process.start, process.stop, or process.status") + violations = append(violations, "capability must be process.install, process.start, process.stop, process.restart, or process.status") } if !validTerminalJobState(report.State) { violations = append(violations, "state must be succeeded, failed, or cancelled") @@ -210,7 +210,7 @@ func validTerminalJobState(state domain.JobState) bool { func validLifecycleReportCapability(capability string) bool { switch capability { - case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus: + case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", domain.LifecycleCapabilityStatus: return true default: return false diff --git a/platform/validator/resources.go b/platform/validator/resources.go index afa41d5..5899d6e 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -167,7 +167,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { } violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) - violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...) + violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...) violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) @@ -238,7 +238,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife } violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...) - violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...) + violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...) violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...) violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...) violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...) @@ -467,9 +467,9 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st return finish(violations) } -func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { +func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) - if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { + if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { return nil } var violations []string @@ -610,6 +610,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) { violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset") } + if len(template.Projections) > 4 { + violations = append(violations, prefix+".projections must not exceed 4 targets") + } + for projectionIndex, projection := range template.Projections { + violations = append(violations, validateGameClientBridgeQueryProjection(fmt.Sprintf("%s.projections[%d]", prefix, projectionIndex), projection)...) + } transport, exists := transports[template.TransportKey] if !exists { violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile") @@ -634,6 +640,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid logProjectionKeys[projection.Key] = struct{}{} violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...) } + lifecycleProjectionKeys := map[string]struct{}{} + for index, projection := range bridge.LifecycleProjections { + prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index) + if !clientManagerIdentifierPattern.MatchString(projection.Key) { + violations = append(violations, prefix+".key is invalid") + } + if _, exists := lifecycleProjectionKeys[projection.Key]; exists { + violations = append(violations, prefix+".key is duplicated") + } + lifecycleProjectionKeys[projection.Key] = struct{}{} + violations = append(violations, validateGameClientBridgeLifecycleProjection(prefix, projection, runCapabilities)...) + } dataPackKeys := map[string]struct{}{} for index, dataPack := range bridge.DataPacks { prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index) @@ -753,6 +771,174 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid return violations } +func validateGameClientBridgeQueryProjection(prefix string, projection domain.GameClientBridgeQueryProjectionDeclaration) []string { + var violations []string + if !gameClientBridgeCollectionPattern.MatchString(projection.Collection) { + violations = append(violations, prefix+".collection is invalid") + } + if projection.RowPath != "rows" { + violations = append(violations, prefix+".rowPath must be rows") + } + if projection.MatchField != "" && !gameClientBridgeFieldPattern.MatchString(projection.MatchField) { + violations = append(violations, prefix+".matchField is invalid") + } + if projection.MatchField == "" && projection.MatchValue != "" || projection.MatchField != "" && strings.TrimSpace(projection.MatchValue) == "" { + violations = append(violations, prefix+".matchField and matchValue must be declared together") + } + if len([]rune(projection.MatchValue)) > 120 { + violations = append(violations, prefix+".matchValue is too long") + } + if len(projection.UpsertKeys) < 1 || len(projection.UpsertKeys) > 8 { + violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields") + } + projectedFields := map[string]struct{}{} + for destination, source := range projection.FieldMappings { + if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) { + violations = append(violations, prefix+".fieldMappings contains an invalid field") + } + projectedFields[destination] = struct{}{} + } + if len(projection.FixedValues) > 64 { + violations = append(violations, prefix+".fixedValues contains too many fields") + } + for destination, value := range projection.FixedValues { + if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 { + violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value") + } + if _, exists := projectedFields[destination]; exists { + violations = append(violations, prefix+" declares field "+destination+" more than once") + } + projectedFields[destination] = struct{}{} + } + if projection.ObservedAtField != "" { + if !gameClientBridgeFieldPattern.MatchString(projection.ObservedAtField) { + violations = append(violations, prefix+".observedAtField is invalid") + } + if _, exists := projectedFields[projection.ObservedAtField]; exists { + violations = append(violations, prefix+" declares field "+projection.ObservedAtField+" more than once") + } + projectedFields[projection.ObservedAtField] = struct{}{} + } + for _, key := range projection.UpsertKeys { + if !gameClientBridgeFieldPattern.MatchString(key) { + violations = append(violations, prefix+".upsertKeys contains an invalid field") + } + if len(projection.FieldMappings) > 0 { + if _, exists := projectedFields[key]; !exists { + violations = append(violations, prefix+".upsertKeys field "+key+" is not projected") + } + } + } + violations = append(violations, duplicateViolations(prefix+".upsertKeys", projection.UpsertKeys)...) + return violations +} + +func validateGameClientBridgeLifecycleProjection(prefix string, projection domain.GameClientBridgeLifecycleProjectionDeclaration, pluginRunCapabilities []string) []string { + var violations []string + if len(projection.Capabilities) < 1 || len(projection.Capabilities) > 16 { + violations = append(violations, prefix+".capabilities must contain between 1 and 16 values") + } + for _, capability := range projection.Capabilities { + if !validPluginRunCapability(capability) { + violations = append(violations, prefix+".capabilities contains an invalid capability") + } + if !containsString(pluginRunCapabilities, capability) { + violations = append(violations, prefix+".capabilities must be declared by the plugin") + } + } + violations = append(violations, duplicateViolations(prefix+".capabilities", projection.Capabilities)...) + if len(projection.ProcessStates) > 8 { + violations = append(violations, prefix+".processStates must not exceed 8") + } + for _, state := range projection.ProcessStates { + if !oneOf(state, "running", "stopped", "not-started", "exited") { + violations = append(violations, prefix+".processStates contains an invalid process state") + } + } + violations = append(violations, duplicateViolations(prefix+".processStates", projection.ProcessStates)...) + violations = append(violations, validateGameClientBridgeBulkProjectionTarget(prefix+".target", projection.Target)...) + return violations +} + +func validateGameClientBridgeBulkProjectionTarget(prefix string, target domain.GameClientBridgeBulkProjectionTargetDeclaration) []string { + var violations []string + if !gameClientBridgeCollectionPattern.MatchString(target.Collection) { + violations = append(violations, prefix+".collection is invalid") + } + if !gameClientBridgeFieldPattern.MatchString(target.MatchField) { + violations = append(violations, prefix+".matchField is invalid") + } + if strings.TrimSpace(target.MatchValue) == "" || len([]rune(target.MatchValue)) > 120 { + violations = append(violations, prefix+".matchValue is invalid") + } + if len(target.FixedValues) < 1 || len(target.FixedValues) > 64 { + violations = append(violations, prefix+".fixedValues must contain between 1 and 64 fields") + } + for destination, value := range target.FixedValues { + if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 { + violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value") + } + } + if target.ObservedAtField != "" && !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) { + violations = append(violations, prefix+".observedAtField is invalid") + } + if target.ActivityTarget != nil { + violations = append(violations, validateGameClientBridgeBulkActivityTarget(prefix+".activityTarget", *target.ActivityTarget)...) + } + return violations +} + +func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.GameClientBridgeBulkActivityTargetDeclaration) []string { + var violations []string + if !gameClientBridgeCollectionPattern.MatchString(target.Collection) { + violations = append(violations, prefix+".collection is invalid") + } + if len(target.RowMappings) < 1 || len(target.RowMappings) > 64 { + violations = append(violations, prefix+".rowMappings must contain between 1 and 64 mappings") + } + projectedFields := map[string]struct{}{} + for destination, source := range target.RowMappings { + if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) { + violations = append(violations, prefix+".rowMappings contains an invalid field") + } + projectedFields[destination] = struct{}{} + } + if len(target.FixedValues) > 64 { + violations = append(violations, prefix+".fixedValues contains too many fields") + } + for destination, value := range target.FixedValues { + if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 { + violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value") + } + if _, exists := projectedFields[destination]; exists { + violations = append(violations, prefix+" declares field "+destination+" more than once") + } + projectedFields[destination] = struct{}{} + } + if target.ObservedAtField != "" { + if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) { + violations = append(violations, prefix+".observedAtField is invalid") + } + if _, exists := projectedFields[target.ObservedAtField]; exists { + violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once") + } + projectedFields[target.ObservedAtField] = struct{}{} + } + if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 { + violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields") + } + for _, key := range target.UpsertKeys { + if !gameClientBridgeFieldPattern.MatchString(key) { + violations = append(violations, prefix+".upsertKeys contains an invalid field") + } + if _, exists := projectedFields[key]; !exists { + violations = append(violations, prefix+".upsertKeys field "+key+" is not projected") + } + } + violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...) + return violations +} + func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string { var violations []string if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 { @@ -852,6 +1038,21 @@ func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.Ga } projectedFields[destination] = struct{}{} } + if len(target.HashMappings) > 64 { + violations = append(violations, prefix+".hashMappings contains too many fields") + } + for destination, capture := range target.HashMappings { + if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) { + violations = append(violations, prefix+".hashMappings contains an invalid field or capture") + } + if _, exists := captures[capture]; !exists { + violations = append(violations, prefix+".hashMappings references undeclared capture "+capture) + } + if _, exists := projectedFields[destination]; exists { + violations = append(violations, prefix+" declares field "+destination+" more than once") + } + projectedFields[destination] = struct{}{} + } if len(target.FixedValues) > 64 { violations = append(violations, prefix+".fixedValues contains too many fields") } diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index f969352..28e89ed 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -169,6 +169,21 @@ export function ServerDetailPage(props: PageComponentProps) { return (
+ {instance.status !== "ready" && ( +
+
+
+

服务器详情

+
+
+ +
+
+
+ )} {instance.status === "loading" && } {instance.status === "error" && ( void refresh()} /> diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index a54f4f4..1aeb949 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -778,7 +778,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))} .console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px} .console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850} -.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)} +.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-projection-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-projection-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-projection-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-projection-dot.map-layer-flags{background:var(--gold)}.map-projection-dot.map-layer-regions{background:var(--success)}.map-projection-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)} .console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left} .console-row-button,.operations-job-row{width:100%;cursor:pointer} diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Barba.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Barba.webp new file mode 100644 index 0000000..e52a5d5 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Barba.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_CityBike.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_CityBike.webp new file mode 100644 index 0000000..07d87fa Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_CityBike.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Cruiser.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Cruiser.webp new file mode 100644 index 0000000..8ff7c4d Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Cruiser.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Dirtbike.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Dirtbike.webp new file mode 100644 index 0000000..6c8b690 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Dirtbike.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Duster.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Duster.webp new file mode 100644 index 0000000..6f28471 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Duster.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp new file mode 100644 index 0000000..116dca4 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Laika.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Laika.webp new file mode 100644 index 0000000..039566d Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Laika.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_MountainBike.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_MountainBike.webp new file mode 100644 index 0000000..75a5e34 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_MountainBike.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_RIS.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_RIS.webp new file mode 100644 index 0000000..632dc17 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_RIS.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Rager.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Rager.webp new file mode 100644 index 0000000..112f34f Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Rager.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Tractor.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Tractor.webp new file mode 100644 index 0000000..eddeab5 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Tractor.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_WolfsWagen.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_WolfsWagen.webp new file mode 100644 index 0000000..7fb7c3d Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_WolfsWagen.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp new file mode 100644 index 0000000..a764c65 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp new file mode 100644 index 0000000..7096a19 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp differ diff --git a/plugins/examples/scum-server-plugin/companion/events.go b/plugins/examples/scum-server-plugin/companion/events.go index 7639154..f577674 100644 --- a/plugins/examples/scum-server-plugin/companion/events.go +++ b/plugins/examples/scum-server-plugin/companion/events.go @@ -3,10 +3,13 @@ package companion import ( "crypto/sha256" "encoding/hex" + "regexp" "strings" "time" ) +var scumLoginLogLine = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: '([0-9.]+) (\d{1,50}):([^']{1,80})\(\d+\)' logged (in|out)(?: .*)?$`) + // ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server // execution log. The channel never accepts a file path or a raw log archive. type ConsoleRecord struct { @@ -21,6 +24,7 @@ type SemanticEvent struct { Sequence uint64 Type string PlayerID string + DisplayName string OccurredAt time.Time NetworkCorrelation string } @@ -62,6 +66,9 @@ func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSe return batch } func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { + if event, ok := parseLoginLogRecord(record, secret); ok { + return event, true + } fields := strings.Fields(record.Text) if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) { return SemanticEvent{}, false @@ -76,9 +83,26 @@ func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, boo } return event, true } + +func parseLoginLogRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { + match := scumLoginLogLine.FindStringSubmatch(record.Text) + if match == nil || !steamID64(match[2]) { + return SemanticEvent{}, false + } + eventType := "scum.login" + if match[4] == "out" { + eventType = "scum.logout" + } + event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: match[2], DisplayName: match[3], OccurredAt: record.OccurredAt} + if secret != "" { + event.NetworkCorrelation = networkCorrelation(record.ServerID, match[1], secret) + } + return event, true +} + func networkCorrelation(serverID, value, secret string) string { digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value)) - return hex.EncodeToString(digest[:16]) + return hex.EncodeToString(digest[:]) } func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic { if len(existing) >= 32 { diff --git a/plugins/examples/scum-server-plugin/companion/events_test.go b/plugins/examples/scum-server-plugin/companion/events_test.go index 56e9058..c0527cb 100644 --- a/plugins/examples/scum-server-plugin/companion/events_test.go +++ b/plugins/examples/scum-server-plugin/companion/events_test.go @@ -17,4 +17,24 @@ func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T) if batch.Events[0].NetworkCorrelation == "10.0.0.1" { t.Fatal("raw network value leaked") } + if len(batch.Events[0].NetworkCorrelation) != 64 { + t.Fatalf("network correlation must be full sha256 hex, got %q", batch.Events[0].NetworkCorrelation) + } +} + +func TestConsoleSemanticEventProducerParsesScumLoginLog(t *testing.T) { + observedAt := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + batch := ParseConsoleRecords("server-1", []ConsoleRecord{ + {ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: observedAt, Text: "2026.08.27-12.00.00: '10.0.0.2 76561198000000002:Ada(42)' logged in at: X=1 Y=2 Z=3"}, + {ServerID: "server-1", Stream: "stdout", Sequence: 2, OccurredAt: observedAt.Add(time.Second), Text: "2026.08.27-12.00.01: '10.0.0.2 76561198000000002:Ada(42)' logged out"}, + }, "fixture-secret") + if len(batch.Events) != 2 || batch.Events[0].Type != "scum.login" || batch.Events[1].Type != "scum.logout" { + t.Fatalf("login log events not parsed: %+v", batch) + } + if batch.Events[0].PlayerID != "76561198000000002" || batch.Events[0].DisplayName != "Ada" || len(batch.Events[0].NetworkCorrelation) != 64 { + t.Fatalf("login event fields are incomplete: %+v", batch.Events[0]) + } + if batch.Events[0].NetworkCorrelation == "10.0.0.2" { + t.Fatal("raw login log IP leaked") + } } diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 3dae326..e30d88c 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -111,11 +111,12 @@ export type SCUMSurfaceData = { mapSettings: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; + trajectories: RecordMap[]; }; export const emptySCUMSurfaceData: SCUMSurfaceData = { players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], - gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [] + gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: [] }; export const scumCollections = { @@ -137,16 +138,17 @@ export const scumCollections = { mapRegions: "scum_map_regions", mapSettings: "scum_map_settings", vehicles: "scum_vehicles", - flags: "scum_flags" + flags: "scum_flags", + trajectories: "scum_trajectories" } as const; type SurfaceKey = keyof SCUMSurfaceData; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; const pageCollections: Record = { - players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"], + players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"], squads: ["squads", "members", "flags"], - "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], + "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] }; @@ -161,6 +163,10 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined); data.players = mergePlayerSnapshots(data.players, playersSnapshot); } + if (keys.includes("vehicles") && actions.gameClient) { + const vehiclesSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "vehicles", streamKey: "current", limit: 1 }).catch(() => undefined); + data.vehicles = mergeVehicleSnapshots(data.vehicles, vehiclesSnapshot); + } return data; } @@ -184,6 +190,25 @@ export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unkn return merged; } +export function mergeVehicleSnapshots(vehicles: RecordMap[], vehiclesResponse: unknown): RecordMap[] { + const vehicleSnapshot = latestSnapshotPayload(vehiclesResponse); + const merged = vehicles.map((vehicle) => ({ ...vehicle })); + const snapshotVehicles = Array.isArray(vehicleSnapshot?.vehicles) ? vehicleSnapshot.vehicles.filter(isRecord) : []; + if (!snapshotVehicles.length) return merged; + const byIdentity = vehicleIndex(merged); + for (const snapshotVehicle of snapshotVehicles) { + const match = findVehicle(byIdentity, snapshotVehicle); + const value = { ...(match ? merged[match.index] : {}), ...snapshotVehicle, vehicleObservedAt: textValue(vehicleSnapshot?.observedAt) }; + if (match) merged[match.index] = value; + else { + const created = { ...value, vehicleId: firstText(snapshotVehicle, "vehicleId", "entityId", "id") }; + merged.push(created); + addVehicleToIndex(byIdentity, created, merged.length - 1); + } + } + return merged; +} + export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise { const key = requiredKey(gift, "code", "礼包编号"); return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); @@ -342,6 +367,10 @@ function snapshotOrder(snapshot: RecordMap): number { const observed = Date.pars function playerIndex(players: RecordMap[]): Map { const result = new Map(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; } function addPlayerToIndex(index: Map, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); } function findPlayer(index: Map, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } +function vehicleIndex(vehicles: RecordMap[]): Map { const result = new Map(); vehicles.forEach((vehicle, index) => addVehicleToIndex(result, vehicle, index)); return result; } +function addVehicleToIndex(index: Map, vehicle: RecordMap, vehicleIndex: number): void { vehicleIdentities(vehicle).forEach((identity) => index.set(identity, vehicleIndex)); } +function findVehicle(index: Map, vehicle: RecordMap): { index: number } | undefined { for (const identity of vehicleIdentities(vehicle)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } +function vehicleIdentities(vehicle: RecordMap): string[] { return ["vehicleId", "entityId", "id"].map((key) => textValue(vehicle[key])).filter(Boolean).map((value) => `vehicle:${value}`); } function playerIdentities(player: RecordMap): string[] { const identities = new Set(); for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 899357b..5786870 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -31,6 +31,24 @@ type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; type AttributeDraft = { fieldKey: string; label: string; before: string; after: string }; const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href; +const scumMapSize = 256; +const rideDistanceThreshold = 50000; +const vehicleIconByClass: Record = { + BPC_Barba: new URL("../assets/vehicles/vehicle-BPC_Barba.webp", import.meta.url).href, + BPC_CityBike: new URL("../assets/vehicles/vehicle-BPC_CityBike.webp", import.meta.url).href, + BPC_Cruiser: new URL("../assets/vehicles/vehicle-BPC_Cruiser.webp", import.meta.url).href, + BPC_Dirtbike: new URL("../assets/vehicles/vehicle-BPC_Dirtbike.webp", import.meta.url).href, + BPC_Kinglet_Duster: new URL("../assets/vehicles/vehicle-BPC_Kinglet_Duster.webp", import.meta.url).href, + BPC_Kinglet_Mariner: new URL("../assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp", import.meta.url).href, + BPC_Laika: new URL("../assets/vehicles/vehicle-BPC_Laika.webp", import.meta.url).href, + BPC_MountainBike: new URL("../assets/vehicles/vehicle-BPC_MountainBike.webp", import.meta.url).href, + BPC_Rager: new URL("../assets/vehicles/vehicle-BPC_Rager.webp", import.meta.url).href, + BPC_RIS: new URL("../assets/vehicles/vehicle-BPC_RIS.webp", import.meta.url).href, + BPC_Tractor: new URL("../assets/vehicles/vehicle-BPC_Tractor.webp", import.meta.url).href, + BPC_WolfsWagen: new URL("../assets/vehicles/vehicle-BPC_WolfsWagen.webp", import.meta.url).href, + BP_WheelBarrow_Improvised: new URL("../assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp", import.meta.url).href, + BP_WheelBarrow_Metal: new URL("../assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp", import.meta.url).href +}; export type ReactLike = { createElement: (...args: any[]) => any; @@ -122,7 +140,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) if (react.useEffect) react.useEffect(() => { if (playerPanel.kind === "closed") refresh(); if (playerPanel.kind !== "closed") return; - const interval = setInterval(refresh, 10000); + const interval = setInterval(refresh, 3000); return () => clearInterval(interval); }, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]); @@ -236,7 +254,7 @@ function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SC e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), e("div", { className: "console-record-meta scum-player-overview" }, e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`), - e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"}`), + e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`), e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`), e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)), @@ -286,12 +304,12 @@ function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) { function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase())); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件日志同步事件;网络信息按插件声明字段展示。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "loginIp", "ipAddress", "lastIp") || "IP 未同步"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影;网络字段只显示不可逆相关性哈希。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); } function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { - const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row))); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 SCUM.db 外部猜测。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, textField(row, "source") || "plugin log"))) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); + const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); } function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } @@ -513,8 +531,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: const search = view.mapSearch.trim().toLowerCase(); const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer")); const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; + const trails = visibleTrajectoryPoints(data.trajectories, view.mapLayers, search).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 180).reverse(); + const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; return e("div", { className: "console-record-list" }, - statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]), + statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["轨迹采样", data.trajectories.length]]), e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })), (["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))) @@ -529,8 +549,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围")) ), e("div", { className: "overview-two-col" }, - e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, visible.map((point, index) => e("button", { key: idOf(point, `point-${index}`), type: "button", className: "map-projection-dot", title: `${pointTitle(point)} ${coords(point)}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, ""))), - e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) + e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-projection-dot map-layer-${layerOf(point)}${ride ? " map-projection-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })), + e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "run.sqlite")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) ) ); } @@ -557,7 +577,7 @@ export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] { return [...uniquePoints.values()]; } -function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; } +function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...row, ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; } function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; } function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); } function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; } @@ -567,11 +587,28 @@ function mapPointIdentity(point: RecordMap): string { const subject = textField( export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record { const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0); const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY")); - const left = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? 100 - (x - minX) / (maxX - minX) * 100 : 50; - const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50; + const mapX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2; + const mapY = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? scumMapSize - (y - minY) * scumMapSize / (maxY - minY) : scumMapSize / 2; + const left = mapX / scumMapSize * 100; + const top = mapY / scumMapSize * 100; return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` }; } +function mapGridOverlay(e: ReactLike["createElement"]) { + const rows = ["D", "C", "B", "A", "Z"]; const cols = ["4", "3", "2", "1", "0"]; const breaks = [20, 40, 60, 80]; + return e("div", { className: "map-grid-overlay", "aria-hidden": "true" }, breaks.map((value) => e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label))); +} + +function visibleTrajectoryPoints(rows: RecordMap[], layers: Record, search: string): RecordMap[] { return rows.filter((row) => (layerOf(row) === "players" || layerOf(row) === "vehicles") && layers[layerOf(row)] && hasCoordinates(positionOf(row)) && matchesText(row, search, "displayName", "label", "subjectId", "steamId", "vehicleId", "subjectType")); } +function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "subjectId"), textField(point, "steamId"), textField(point, "gamePlayerId"), textField(point, "vehicleId"), textField(point, "id")].filter(Boolean)); const layer = layerOf(point); return rows.filter((row) => layerOf(row) === layer && trajectoryIdentity(row).some((identity) => ids.has(identity))); } +function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, "subjectId"), textField(row, "steamId"), textField(row, "gamePlayerId"), textField(row, "vehicleId"), textField(row, "id")].filter(Boolean); } +function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; } +function nearbyVehicle(point: RecordMap, vehicles: RecordMap[]): RecordMap | undefined { if (!hasCoordinates(positionOf(point))) return undefined; let best: { vehicle: RecordMap; distance: number } | undefined; for (const vehicle of vehicles) { if (!hasCoordinates(positionOf(vehicle))) continue; const distance = distance2D(positionOf(point)!, positionOf(vehicle)!); if (distance <= rideDistanceThreshold && (!best || distance < best.distance)) best = { vehicle, distance }; } return best?.vehicle; } +function distance2D(left: RecordMap, right: RecordMap): number { const dx = Number(field(left, "x", "locationX")) - Number(field(right, "x", "locationX")); const dy = Number(field(left, "y", "locationY")) - Number(field(right, "y", "locationY")); return Math.sqrt(dx * dx + dy * dy); } +function vehicleIconFor(point: RecordMap): string { return vehicleIconByClass[normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass"))] ?? ""; } +function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); } +function shortHash(value: string): string { return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : ""; } + function runAction(setAction: StateSetter, pending: string, task: () => Promise) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); } function usePluginState(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(initial) : [initial, () => undefined]; } function inputValue(event: InputEvent): string { return event.target?.value ?? ""; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index c4dcef0..6bed6ed 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -310,7 +310,45 @@ "sqlRef": "sql/scum-db-v57/vehicles.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15 + "timeoutSeconds": 15, + "projections": [ + { + "collection": "scum_vehicles", + "rowPath": "rows", + "upsertKeys": [ + "vehicleId" + ], + "fixedValues": { + "source": "run.sqlite.scum.vehicles" + }, + "observedAtField": "sampledAt" + }, + { + "collection": "scum_trajectories", + "rowPath": "rows", + "upsertKeys": [ + "subjectType", + "subjectId", + "sampledAt" + ], + "fieldMappings": { + "subjectId": "vehicleId", + "vehicleId": "vehicleId", + "entityId": "entityId", + "className": "className", + "label": "label", + "x": "x", + "y": "y", + "z": "z", + "lastAccessTime": "lastAccessTime" + }, + "fixedValues": { + "subjectType": "vehicle", + "source": "run.sqlite.scum.vehicles" + }, + "observedAtField": "sampledAt" + } + ] }, { "key": "scum.flags", @@ -338,7 +376,69 @@ "sqlRef": "sql/scum-db-v57/map-points.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15 + "timeoutSeconds": 15, + "projections": [ + { + "collection": "scum_users", + "rowPath": "rows", + "matchField": "subjectType", + "matchValue": "player", + "upsertKeys": [ + "steamId" + ], + "fieldMappings": { + "steamId": "subjectId", + "userProfileId": "userProfileId", + "gamePlayerId": "gamePlayerId", + "x": "x", + "y": "y", + "z": "z", + "lastPositionObservedAt": "observedAt" + }, + "fixedValues": { + "source": "run.sqlite.scum.positions" + }, + "observedAtField": "positionSampledAt" + }, + { + "collection": "scum_map_points", + "rowPath": "rows", + "upsertKeys": [ + "subjectType", + "subjectId" + ], + "fixedValues": { + "source": "run.sqlite.scum.positions" + }, + "observedAtField": "sampledAt" + }, + { + "collection": "scum_trajectories", + "rowPath": "rows", + "matchField": "subjectType", + "matchValue": "player", + "upsertKeys": [ + "subjectType", + "subjectId", + "sampledAt" + ], + "fieldMappings": { + "subjectType": "subjectType", + "subjectId": "subjectId", + "steamId": "subjectId", + "userProfileId": "userProfileId", + "gamePlayerId": "gamePlayerId", + "x": "x", + "y": "y", + "z": "z", + "observedAt": "observedAt" + }, + "fixedValues": { + "source": "run.sqlite.scum.positions" + }, + "observedAtField": "sampledAt" + } + ] }, { "key": "scum.tasks", @@ -437,6 +537,237 @@ "observedAtField": "observedAt" } } + }, + { + "key": "scum.login-log.login", + "streamKeys": [ + "scum.login" + ], + "steps": [ + { + "pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: '(?P[0-9.]+) (?P\\d{1,50}):(?P[^']{1,80})\\(\\d+\\)' logged in(?: at: X=.*)?$" + } + ], + "correlationFields": [ + "steamId" + ], + "maxInterveningLines": 0, + "target": { + "collection": "scum_users", + "upsertKeys": [ + "steamId" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "hashMappings": { + "networkCorrelation": "ip" + }, + "fixedValues": { + "online": "true", + "status": "online", + "source": "scum.login" + }, + "observedAtField": "lastLoginObservedAt" + }, + "presence": { + "timestampField": "lastLoginObservedAt", + "activeWindowSeconds": 1, + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": [ + "steamId", + "observedAt" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "hashMappings": { + "networkCorrelation": "ip" + }, + "fixedValues": { + "eventType": "login", + "source": "scum.login" + }, + "observedAtField": "observedAt" + } + } + }, + { + "key": "scum.login-log.logout", + "streamKeys": [ + "scum.login" + ], + "steps": [ + { + "pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: '(?P[0-9.]+) (?P\\d{1,50}):(?P[^']{1,80})\\(\\d+\\)' logged out.*$" + } + ], + "correlationFields": [ + "steamId" + ], + "maxInterveningLines": 0, + "target": { + "collection": "scum_users", + "upsertKeys": [ + "steamId" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "hashMappings": { + "networkCorrelation": "ip" + }, + "fixedValues": { + "online": "false", + "status": "offline", + "logoutReason": "disconnect", + "source": "scum.login" + }, + "observedAtField": "lastLogoutObservedAt" + }, + "presence": { + "timestampField": "lastLogoutObservedAt", + "activeWindowSeconds": 1, + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": [ + "steamId", + "observedAt" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "hashMappings": { + "networkCorrelation": "ip" + }, + "fixedValues": { + "eventType": "logout", + "reason": "disconnect", + "source": "scum.login" + }, + "observedAtField": "observedAt" + } + } + } + ], + "lifecycleProjections": [ + { + "key": "scum.lifecycle.stop-logout", + "capabilities": [ + "process.stop" + ], + "target": { + "collection": "scum_users", + "matchField": "online", + "matchValue": "true", + "fixedValues": { + "online": "false", + "status": "offline", + "logoutReason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "lastLogoutObservedAt", + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": [ + "steamId", + "observedAt", + "eventType" + ], + "rowMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "fixedValues": { + "eventType": "logout", + "reason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "observedAt" + } + } + }, + { + "key": "scum.lifecycle.restart-logout", + "capabilities": [ + "process.restart" + ], + "target": { + "collection": "scum_users", + "matchField": "online", + "matchValue": "true", + "fixedValues": { + "online": "false", + "status": "offline", + "logoutReason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "lastLogoutObservedAt", + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": [ + "steamId", + "observedAt", + "eventType" + ], + "rowMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "fixedValues": { + "eventType": "logout", + "reason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "observedAt" + } + } + }, + { + "key": "scum.lifecycle.status-logout", + "capabilities": [ + "process.status" + ], + "processStates": [ + "stopped", + "not-started", + "exited" + ], + "target": { + "collection": "scum_users", + "matchField": "online", + "matchValue": "true", + "fixedValues": { + "online": "false", + "status": "offline", + "logoutReason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "lastLogoutObservedAt", + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": [ + "steamId", + "observedAt", + "eventType" + ], + "rowMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "fixedValues": { + "eventType": "logout", + "reason": "server-stop", + "source": "run.lifecycle" + }, + "observedAtField": "observedAt" + } + } } ], "dataPacks": [ @@ -797,6 +1128,62 @@ "path": "assets/map/scum-map-overview.jpg", "mode": 384 }, + { + "path": "assets/vehicles/vehicle-BPC_Barba.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_CityBike.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Cruiser.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Dirtbike.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Kinglet_Duster.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Laika.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_MountainBike.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_RIS.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Rager.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_Tractor.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BPC_WolfsWagen.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp", + "mode": 384 + }, + { + "path": "assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp", + "mode": 384 + }, { "path": "sql/scum-db-v57/users.sql", "mode": 384 diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index c0f35d4..447e97d 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -259,6 +259,11 @@ "items": { "$ref": "#/$defs/gameClientBridgeLogProjection" }, "maxItems": 128 }, + "lifecycleProjections": { + "type": "array", + "items": { "$ref": "#/$defs/gameClientBridgeLifecycleProjection" }, + "maxItems": 64 + }, "dataPacks": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeDataPack" }, @@ -337,7 +342,23 @@ "sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }, - "pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 } + "pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }, + "projections": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeQueryProjection" }, "uniqueItems": true, "maxItems": 4 } + } + }, + "gameClientBridgeQueryProjection": { + "type": "object", + "required": ["collection", "rowPath", "upsertKeys"], + "additionalProperties": false, + "properties": { + "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, + "rowPath": { "const": "rows" }, + "matchField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "matchValue": { "type": "string", "minLength": 1, "maxLength": 120 }, + "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, + "fieldMappings": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } }, + "fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } }, + "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } } }, "gameClientBridgeLogProjection": { @@ -370,6 +391,7 @@ "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, "captureMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" } }, + "hashMappings": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" } }, "fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } }, "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } } @@ -384,6 +406,42 @@ "activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" } } }, + "gameClientBridgeLifecycleProjection": { + "type": "object", + "required": ["key", "capabilities", "target"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, + "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 }, + "processStates": { "type": "array", "items": { "enum": ["running", "stopped", "not-started", "exited"] }, "uniqueItems": true, "maxItems": 8 }, + "target": { "$ref": "#/$defs/gameClientBridgeBulkProjectionTarget" } + } + }, + "gameClientBridgeBulkProjectionTarget": { + "type": "object", + "required": ["collection", "matchField", "matchValue", "fixedValues"], + "additionalProperties": false, + "properties": { + "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, + "matchField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "matchValue": { "type": "string", "minLength": 1, "maxLength": 120 }, + "fixedValues": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } }, + "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "activityTarget": { "$ref": "#/$defs/gameClientBridgeBulkActivityTarget" } + } + }, + "gameClientBridgeBulkActivityTarget": { + "type": "object", + "required": ["collection", "upsertKeys", "rowMappings"], + "additionalProperties": false, + "properties": { + "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, + "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, + "rowMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } }, + "fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } }, + "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } + } + }, "gameClientBridgeDataPack": { "type": "object", "required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"], diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index c37d9d2..7b4a55d 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -33,6 +33,10 @@ const surfaceData: SCUMSurfaceData = { mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }], mapSettings: [], vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], + trajectories: [ + { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" }, + { subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" } + ], flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }] }; @@ -89,7 +93,7 @@ describe("SCUM plugin feature module", () => { const gameClient = gameClientActions(); gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] }); const data = await loadSCUMSurface({ pluginData, gameClient }, "players"); - expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players"]); + expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "vehicles"]); expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" }); const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] }); expect(sameName).toHaveLength(2); @@ -286,7 +290,7 @@ describe("SCUM plugin feature module", () => { expect(source).toContain("remote.access.request"); expect(source).not.toContain("input.templateKey"); expect(source).not.toContain("requestSCUMPageQueries"); - expect(pageSource).toContain("setInterval(refresh, 10000)"); + expect(pageSource).toContain("setInterval(refresh, 3000)"); expect(pageSource).toContain("clearInterval(interval)"); }); });