From 316efbe780d76f98e235d46098d47a7ca1e2d63b Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Thu, 27 Aug 2026 12:34:07 +0800 Subject: [PATCH] Add SCUM log sessions and trajectory projections --- platform/domain/game_client_bridge.go | 102 ++++- platform/domain/game_client_bridge_test.go | 15 +- platform/dto/resources.go | 156 +++++-- platform/dto/resources_test.go | 27 +- platform/service/job_channel.go | 15 +- platform/service/plugin_data_test.go | 38 ++ .../service/plugin_lifecycle_projection.go | 87 ++++ platform/service/plugin_log_projection.go | 20 +- .../service/plugin_log_projection_test.go | 83 ++++ platform/service/plugin_query_projection.go | 215 ++++++++++ .../service/server_lifecycle_projection.go | 36 +- .../game_client_bridge_companion_test.go | 4 +- .../game_client_bridge_log_projection_test.go | 4 +- platform/validator/job_channel.go | 4 +- platform/validator/resources.go | 209 +++++++++- platform_web/pages/ServerDetailPage.tsx | 15 + platform_web/theme/base.css | 2 +- .../assets/vehicles/vehicle-BPC_Barba.webp | Bin 0 -> 2952 bytes .../assets/vehicles/vehicle-BPC_CityBike.webp | Bin 0 -> 3366 bytes .../assets/vehicles/vehicle-BPC_Cruiser.webp | Bin 0 -> 4264 bytes .../assets/vehicles/vehicle-BPC_Dirtbike.webp | Bin 0 -> 3992 bytes .../vehicles/vehicle-BPC_Kinglet_Duster.webp | Bin 0 -> 4372 bytes .../vehicles/vehicle-BPC_Kinglet_Mariner.webp | Bin 0 -> 16508 bytes .../assets/vehicles/vehicle-BPC_Laika.webp | Bin 0 -> 4902 bytes .../vehicles/vehicle-BPC_MountainBike.webp | Bin 0 -> 4428 bytes .../assets/vehicles/vehicle-BPC_RIS.webp | Bin 0 -> 12074 bytes .../assets/vehicles/vehicle-BPC_Rager.webp | Bin 0 -> 5550 bytes .../assets/vehicles/vehicle-BPC_Tractor.webp | Bin 0 -> 8604 bytes .../vehicles/vehicle-BPC_WolfsWagen.webp | Bin 0 -> 5242 bytes .../vehicle-BP_WheelBarrow_Improvised.webp | Bin 0 -> 3566 bytes .../vehicle-BP_WheelBarrow_Metal.webp | Bin 0 -> 3110 bytes .../scum-server-plugin/companion/events.go | 26 +- .../companion/events_test.go | 20 + .../scum-server-plugin/features/page-data.ts | 37 +- .../scum-server-plugin/features/page.ts | 59 ++- .../examples/scum-server-plugin/manifest.json | 391 +++++++++++++++++- .../game-plugin.manifest.schema.json | 60 ++- plugins/tests/scum-feature-module.test.ts | 8 +- 38 files changed, 1549 insertions(+), 84 deletions(-) create mode 100644 platform/service/plugin_lifecycle_projection.go create mode 100644 platform/service/plugin_query_projection.go create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Barba.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_CityBike.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Cruiser.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Dirtbike.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Duster.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Laika.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_MountainBike.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_RIS.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Rager.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_Tractor.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BPC_WolfsWagen.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp create mode 100644 plugins/examples/scum-server-plugin/assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp 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 0000000000000000000000000000000000000000..e52a5d5bd7e3176450ddc903104715fe0ef08c33 GIT binary patch literal 2952 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07d87fafc78bed4ae9a4ea04c6491166fa405dbb GIT binary patch literal 3366 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ff7c4d502e26f3452a4af631fd336ad53b51a11 GIT binary patch literal 4264 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 KbEIt2*Z=^-+fw@g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6c8b690bb71e7045525af6d02eed3dc70a68228d GIT binary patch literal 3992 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 Y&U^!_I(daY)lFE?3)0RvsBi!P075XJ&;S4c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..75a5e344d316210ca39ed5ab87bc477310f8e427 GIT binary patch literal 4428 zcmV-S5wq@6Nk&FQ5dZ*JMM6+kP&gns5dZ+NUI3i|D(wK^06uLnlSU*XA)zf*>ZpJX ziDz!zUu$ckzYT@!8*wpx&+JC>f9ap(d6V|9Ae^3d_yBLTS2RhM3anQ=Bn`=4)m@sS?PO4ds zG{X}Jlp#w%5VS`Hl{HWoVWa>A0Il{fSsau_y)eUAf58~HGi(2$QdPBd7n zWB!|cGJpc}f?85?a%gL6JKdf{<{ZX<`=nkrwADH64cCn9bLybVzc2 zw`wbUHoKZL73qM*g)^&MwmbxTv^vDbQUDKT$A6IvPE{w%xo@JkJb)a`AiU@=k>04> zz}posYAT@f{Y6~;M?z0X3e;OCV(Wil>(t|$#_SAv)z+U1@70}g;xe#&Zc4Qxe& z!@3?2x{m*rHV_U&S&g6um-1osH za2=8i<0;9Nrcg_~0092`zZaNHChyQW+VI4s5=g&i-t;;7g%6nI!SarJ-hcfgv z$NsL?9o{oN1_R|Tpjp{gr>Dn?>k(Ca&_}!$=E&)Sl$AA##>9{(i6V5Cny&k5}X7k8PD%|6_L%I)CHGAb=K3;YiD&81s=VA4@C;b;{;eZC& zcd9TbEe|U#Y?$cT3mAKx5UOk2-M@v+Ygi;?wF2YQHMoXa^c&l_B=*IF%e@5hDAcBc zEJkzz8C{q;aZ4jJYkUS+dy;OEJW0#jp~<(D8b{>*PC(k|C!t{1s8yQTe{<9z)(c?q zyeG+cK;IQwUW5@EdjV3-n_;yZA+)Y_j`1U2({E577?F?|Q^t&XO?t}lfggca@EnHJJd zw)c2z57H<~RA_o%TLr+MOv}AQZ-@l!&%ze9 z`Pwof2a8B4P4U&2=QZUa74_e5A3PFCW62|r)H^u+Bb+q20zMue7}$7C%t!saj>my^ z4R%=))!W~{6LmpP5JO{|&D&wV^U_m9bzg&yB;ySDQ;8Tkq4O&m_oLOi!2FwAOcR5R z`cl?Fn~L)br2{?@zyDVroLsMFrZyF-?|Gq$C_@4>xc&`7na4a32q~jDZ?XTW0jEVE z5L%#k#;jxlweTppvypNkrsBK4vO(BGB?U>E7GKj}Hzfx$=ZHYBx@%~=>VKem{gqgU z2%le9(@HT2lcQOV?GG#hF+9ciLT~pJH%#i10}@)g%MX?LmadT|QmvjP-+$;l6={EV zVu@NL`--tI6x`RKm8C`Hsum75e_gk*^dt1XEnJ>d?z_Dn+pYg6BDgj}f{cYtaa(`o zma5XLqxAkQ7XVMQlRkXCHgunJX!{dK%q!@Lf2}fC|NG^S=-V@BllOZyyE^zv*DkC3 z!tE6qke9{%)UH4YRcJ{KdnvU9dSgL`jLPHIoDKJFI)m}}j(*gCm1ZHfHsUOX8?vzN zCCVGzn~$%u3y`L}idmNL5*mE>y@e*9iofQ5-mdrPCKAZ^BRoTi<&HFh;d5r#ZO{-S zy~-qm&jk|Y+>BCeBd-=JFINkC8Rm#BjuSylPX|^F@tR=I(5sXlOT-)l4qX44gg_`l zKPxMpgv4fbbEZ_R9aHinB&&7@1GUP+1`W7Gi+hGM67 z6u1GKPv}LcaDUR2G}Cj&Q+F1EOApqSqa-d{Qq#5vJyRICJL>6*{$hnZ*juN+q5cC1 z{dbnHk|%3=jKf0eCX^59slz14gW)M&)CWz<# z=Uje^lwtm$$EvXUn&zWU{on3<07<=W>Q4cPk77#ZxJjeUY5xG+aVfvEY60$6*?(%0 zU9wYGOp%)|2fkLRV4$I|Uh;Lq(>~$42-)_RI-T8zfuyO!163Yb)LToxG$`RS_Ey~K z`SV>W&QM&)V8^(MPizY+E!ZTE2j@&u|Ley++Tk{8&rg0D&3;HB$$i&Cqfzl3p4Zg3 z1koD(O;&bF_PWB=4VnYQE8IJe074tCNUE(@AVSepHYun=X{5Kp-=RiB(f}Z!THrP{ zrYulywd3_XYw3JDMG9t4v(5gvGD$__43w@?GBv|2+5tt zG#O8DIh1$O+3usVCZ*~g@X#rCvpum`c9Fl3g{iNl6gosEX?%GF*^JwE}a_jjF+Bzzi$0Jhkr+S>W29SVU$s<_G8#T=V?A=YdGJVCNFWC=3ur6TKWW zo5Eo{z7IsaI4j~pq>hD^(7^wtuQ(m^q>NKG`EL$G6ppvf3CE3c-g3FcZ;DS&R^C_h z9}nT;veI>oV?-on5b6!aew0y6n{?bp)&1=8483hxP%&r~ZOoMZM(h!UYy(j}GghTc z_usRK$~mNzM6Xjk*a7JOMG_>5#qhZQxQ<^iH)L8phdj0&|38V&OH5Nk2C<&(*~=6xxNwsKp8XrBFSY{D?>PWJef5E8}#tWJIJ-s+ylcjNE4OCpUq4 zB}y#TqULo^BR?ARpkfpN#*a!mb&M=&MQG*pD}%GJEQ1peu(_~6zv@qEWEpmjXzg?) z2`sK(F>))opqxxcu1w8OFCHKdUi7 zD1WUatVEjHDZ(BQaYC)x(zy`$#5GfiiPp7Ol3Err?P^pUD(U_Ux$8{EgFk|u9$N%kSX zOD*Hg)T6iPm^@i;k;z)bh!n1{MBlz)!BslbLA<)xt|hS1&d1Vv7gw*oLk4 zcG}1*e?nXP#=w@u0{kN3=eg8KH#!^sOgLrGB-n0)nVwO}!sQxR~K+Wr(#S z2S2vxA*9PM_H!cOc>PkfsLFlJLJOLjAC8+fZ7}ADCEBxYpK+BWq{%SBoU-t-==X%5 zc2)^ZWGA7=0Iei3P_nizX7#l^I5e<1ne;FIZ;|8CL>?erG0x|z?^C#-3HNZwj%;V+ zOMf(4t$f*q?A$L%5)mXbV5{-ir#J$_7#p^a4IVU>6VoFRHat_ASE!)PL<8#>GPXmP zMb3osLSzr12XbM4IH^p(RFAgmWa;)!S?(F`ez)JE)7`V4o&gvhGq|(j%NlNjQcT=s zFdugl3gpdVz(mcr^+uphIRcp)PHsbdOvc>KJgKx;vueFJpePD_cIF1|K*lfQHA#H6 zyL*2>(?}1JHVUf-SWIJ~GucL+bN;xvdWb0DB?T~23e0_Yx-*~`wxtz9iW_x{@QkfF z`|Dt+;ai-=fHp~;%Zu2{8rSg62V0^a+(s7I|7}zda2OJiGX9S4HS8Vc#X$sPI`3zO zjsct=4qRLtwSyW{kj=bs^O*O|7-g&=aR7y0l3}7fdS+G}DYZNvk0lPgGf$q@_jb-; zgPwvgMjMWjYFz}5bk)13jv}_FE75wD*$e*7aVpth?u49M~>CI|iezeQ?k-|{4#SY^4?z4kf7U?J)CLl8Gi%w-8H&+>utz6dX=XVTi z@^I5N2ORE3+ICO$h#V(@MqCLo6o3^u7mz??aK2@5j@#S%ripa1|qDtCqe literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..632dc17eb569dfb467f355918947a82cbb49ab96 GIT binary patch literal 12074 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7fb7c3de91d27c6f8f1b9b4e231dabf67afc26a3 GIT binary patch literal 5242 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 Au>b%7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a764c65871c65cba89ea9ef502807c5f8e3b6f78 GIT binary patch literal 3566 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7096a199b9bc55c2c19b6ffa744859febb6e3360 GIT binary patch literal 3110 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 Ao&W#< literal 0 HcmV?d00001 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)"); }); });