Add SCUM log sessions and trajectory projections
@@ -44,6 +44,18 @@ type GameClientBridgeQueryTemplateDeclaration struct {
|
|||||||
MaxRows int
|
MaxRows int
|
||||||
TimeoutSeconds int
|
TimeoutSeconds int
|
||||||
PollIntervalSeconds 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 {
|
type GameClientBridgeLogProjectionStepDeclaration struct {
|
||||||
@@ -54,6 +66,7 @@ type GameClientBridgeLogProjectionTargetDeclaration struct {
|
|||||||
Collection string
|
Collection string
|
||||||
UpsertKeys []string
|
UpsertKeys []string
|
||||||
CaptureMappings map[string]string
|
CaptureMappings map[string]string
|
||||||
|
HashMappings map[string]string
|
||||||
FixedValues map[string]string
|
FixedValues map[string]string
|
||||||
ObservedAtField string
|
ObservedAtField string
|
||||||
}
|
}
|
||||||
@@ -64,6 +77,30 @@ type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
|||||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
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 {
|
type GameClientBridgeLogProjectionDeclaration struct {
|
||||||
Key string
|
Key string
|
||||||
StreamKeys []string
|
StreamKeys []string
|
||||||
@@ -115,15 +152,16 @@ type GameClientBridgeCompanionDeclaration struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeManifest struct {
|
type GameClientBridgeManifest struct {
|
||||||
Commands []GameClientBridgeCommandDeclaration
|
Commands []GameClientBridgeCommandDeclaration
|
||||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||||
LogProjections []GameClientBridgeLogProjectionDeclaration
|
LogProjections []GameClientBridgeLogProjectionDeclaration
|
||||||
DataPacks []GameClientBridgeDataPackDeclaration
|
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration
|
||||||
Retention GameClientBridgeRetention
|
DataPacks []GameClientBridgeDataPackDeclaration
|
||||||
Pages []GameClientBridgePageContract
|
Retention GameClientBridgeRetention
|
||||||
Features []GameClientBridgeFeatureDeclaration
|
Pages []GameClientBridgePageContract
|
||||||
Companion GameClientBridgeCompanionDeclaration
|
Features []GameClientBridgeFeatureDeclaration
|
||||||
|
Companion GameClientBridgeCompanionDeclaration
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeResultStatus string
|
type GameClientBridgeResultStatus string
|
||||||
@@ -394,10 +432,17 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
|||||||
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
||||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
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...)
|
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
|
||||||
for index := range value.LogProjections {
|
for index := range value.LogProjections {
|
||||||
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
|
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...)
|
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
|
||||||
for index := range value.DataPacks {
|
for index := range value.DataPacks {
|
||||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||||
@@ -419,6 +464,44 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
|||||||
return value
|
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 {
|
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
|
||||||
value.StreamKeys = CopyStringSlice(value.StreamKeys)
|
value.StreamKeys = CopyStringSlice(value.StreamKeys)
|
||||||
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
|
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
|
||||||
@@ -438,6 +521,7 @@ func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProje
|
|||||||
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
|
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
|
||||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||||
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
|
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
|
||||||
|
value.HashMappings = CopyStringMap(value.HashMappings)
|
||||||
value.FixedValues = CopyStringMap(value.FixedValues)
|
value.FixedValues = CopyStringMap(value.FixedValues)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,29 @@ import "testing"
|
|||||||
|
|
||||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||||
manifest := GameClientBridgeManifest{
|
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{{
|
LogProjections: []GameClientBridgeLogProjectionDeclaration{{
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\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"}}},
|
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"}}},
|
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"}}}}},
|
||||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
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 := CopyGameClientBridgeManifest(manifest)
|
||||||
manifestCopy.QueryTemplates[0].Key = "mutated"
|
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||||
|
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
||||||
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
|
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
|
||||||
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||||
|
manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated"
|
||||||
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "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].LogParserRefs[0] = "mutated"
|
||||||
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
||||||
manifestCopy.Pages[0].QueryTemplateKeys[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)
|
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,18 +279,30 @@ type GameClientBridgeSnapshotDeclarationBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
Engine string `json:"engine"`
|
Engine string `json:"engine"`
|
||||||
TransportKey string `json:"transportKey"`
|
TransportKey string `json:"transportKey"`
|
||||||
TargetKey string `json:"targetKey"`
|
TargetKey string `json:"targetKey"`
|
||||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||||
SQLRef string `json:"sqlRef,omitempty"`
|
SQLRef string `json:"sqlRef,omitempty"`
|
||||||
MaxRows int `json:"maxRows"`
|
MaxRows int `json:"maxRows"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
PollIntervalSeconds int `json:"pollIntervalSeconds"`
|
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 {
|
type GameClientBridgeLogProjectionStepDeclarationBody struct {
|
||||||
@@ -301,6 +313,7 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
|||||||
Collection string `json:"collection"`
|
Collection string `json:"collection"`
|
||||||
UpsertKeys []string `json:"upsertKeys"`
|
UpsertKeys []string `json:"upsertKeys"`
|
||||||
CaptureMappings map[string]string `json:"captureMappings"`
|
CaptureMappings map[string]string `json:"captureMappings"`
|
||||||
|
HashMappings map[string]string `json:"hashMappings,omitempty"`
|
||||||
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
||||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -311,6 +324,30 @@ type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
|||||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
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 {
|
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
StreamKeys []string `json:"streamKeys"`
|
StreamKeys []string `json:"streamKeys"`
|
||||||
@@ -362,16 +399,17 @@ type GameClientBridgeCompanionDeclarationBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeManifestBody struct {
|
type GameClientBridgeManifestBody struct {
|
||||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||||
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
||||||
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
|
||||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
||||||
MaxCommands int `json:"maxCommands"`
|
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
MaxCommands int `json:"maxCommands"`
|
||||||
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
||||||
|
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||||
}
|
}
|
||||||
type GamePluginManifestBody struct {
|
type GamePluginManifestBody struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -1242,12 +1280,16 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
|||||||
}
|
}
|
||||||
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
||||||
for index, template := range 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))
|
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
|
||||||
for index, projection := range body.LogProjections {
|
for index, projection := range body.LogProjections {
|
||||||
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
|
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))
|
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
|
||||||
for index, dataPack := range 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)}
|
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 {
|
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}
|
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 {
|
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 {
|
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 {
|
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||||
@@ -1710,12 +1779,16 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
|||||||
}
|
}
|
||||||
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
||||||
for index, template := range 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))
|
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
|
||||||
for index, projection := range value.LogProjections {
|
for index, projection := range value.LogProjections {
|
||||||
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
|
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))
|
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
|
||||||
for index, dataPack := range 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)}
|
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 != "" {
|
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}
|
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 {
|
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 {
|
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 {
|
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||||
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
|
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,
|
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{{
|
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\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"}}},
|
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"}}},
|
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||||
CommandRetentionSeconds: 86400,
|
CommandRetentionSeconds: 86400,
|
||||||
MaxCommands: 1000,
|
MaxCommands: 1000,
|
||||||
@@ -150,14 +152,24 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
domainManifest := body.ToDomain()
|
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)
|
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"
|
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||||
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
|
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
|
||||||
t.Fatal("log projection target aliases request DTO data")
|
t.Fatal("log projection target aliases request DTO data")
|
||||||
}
|
}
|
||||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId"
|
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"
|
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
t.Fatal("query template page keys alias request DTO data")
|
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"
|
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
|
||||||
|
|
||||||
response := gameClientBridgeManifestFromDomain(domainManifest)
|
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"
|
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
||||||
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
|
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
|
||||||
t.Fatal("log projection target aliases domain data")
|
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"
|
response.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
t.Fatal("query template page keys alias domain data")
|
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 {
|
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||||
t.Fatalf("decode safe query template projection: %v", err)
|
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) {
|
if len(projection) != len(expectedFields) {
|
||||||
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
|||||||
}
|
}
|
||||||
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||||
if !ok {
|
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()
|
leaseToken, err := randomToken()
|
||||||
@@ -342,6 +352,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
|||||||
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
||||||
return domain.RunJobResultResult{}, err
|
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
|
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,3 +156,41 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
|||||||
t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -18,7 +20,7 @@ type pluginLogSequenceState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
|
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 {
|
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)
|
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -191,7 +193,7 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
|
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)
|
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
|
||||||
if keyErr != nil {
|
if keyErr != nil {
|
||||||
return keyErr
|
return keyErr
|
||||||
@@ -203,11 +205,14 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
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.FixedValues)+1)
|
value := make(map[string]any, len(target.CaptureMappings)+len(target.HashMappings)+len(target.FixedValues)+1)
|
||||||
for destination, capture := range target.CaptureMappings {
|
for destination, capture := range target.CaptureMappings {
|
||||||
value[destination] = captures[capture]
|
value[destination] = captures[capture]
|
||||||
}
|
}
|
||||||
|
for destination, capture := range target.HashMappings {
|
||||||
|
value[destination] = logProjectionCorrelationHash(serverID, captures[capture])
|
||||||
|
}
|
||||||
for key, fixed := range target.FixedValues {
|
for key, fixed := range target.FixedValues {
|
||||||
value[key] = renderLogProjectionTemplate(fixed, captures)
|
value[key] = renderLogProjectionTemplate(fixed, captures)
|
||||||
}
|
}
|
||||||
@@ -217,6 +222,11 @@ func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetD
|
|||||||
return value
|
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 {
|
func renderLogProjectionTemplate(template string, captures map[string]string) string {
|
||||||
result := template
|
result := template
|
||||||
for key, value := range captures {
|
for key, value := range captures {
|
||||||
|
|||||||
@@ -71,6 +71,89 @@ func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *test
|
|||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
|
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) {
|
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
entries := make([]domain.LogEntry, len(lines))
|
entries := make([]domain.LogEntry, len(lines))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
|
|||||||
|
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
|
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
|
||||||
if lifecycleObservationIsStale(instance, report) {
|
staleObservation := lifecycleObservationIsStale(instance, report)
|
||||||
|
if !projected || staleObservation {
|
||||||
projected = false
|
projected = false
|
||||||
nextState = instance.State
|
nextState = instance.State
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,15 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
|
|||||||
}
|
}
|
||||||
svc.publishLogProcessState(instance)
|
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
|
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)
|
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
|
||||||
if !ok || job.ServerInstanceID == "" {
|
if !ok || job.ServerInstanceID == "" {
|
||||||
|
if job.State == domain.JobStateSucceeded && job.ServerInstanceID != "" {
|
||||||
|
return svc.projectPluginLifecycleStateForJob(job, stamp)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||||
@@ -112,9 +125,30 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc.publishLogProcessState(instance)
|
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
|
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 {
|
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
||||||
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest,
|
|||||||
|
|
||||||
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
|
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
|
||||||
bridge, profiles := validGameClientBridgeCompanionManifest()
|
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)
|
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) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
|
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
|
||||||
test.mutate(&candidateBridge, &candidateProfiles)
|
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) {
|
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
|||||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||||
}
|
}
|
||||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
|
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)
|
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 := profiles
|
||||||
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||||
test.mutate(&candidate, &candidateProfiles)
|
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) {
|
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error {
|
|||||||
violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID)
|
violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID)
|
||||||
violations = appendRequired(violations, "capability", report.Capability)
|
violations = appendRequired(violations, "capability", report.Capability)
|
||||||
if !validLifecycleReportCapability(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) {
|
if !validTerminalJobState(report.State) {
|
||||||
violations = append(violations, "state must be succeeded, failed, or cancelled")
|
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 {
|
func validLifecycleReportCapability(capability string) bool {
|
||||||
switch capability {
|
switch capability {
|
||||||
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus:
|
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", domain.LifecycleCapabilityStatus:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
|||||||
}
|
}
|
||||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
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, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
||||||
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
||||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
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, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
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, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
|
||||||
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
||||||
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.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)
|
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{})
|
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
|
return nil
|
||||||
}
|
}
|
||||||
var violations []string
|
var violations []string
|
||||||
@@ -610,6 +610,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) {
|
if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) {
|
||||||
violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset")
|
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]
|
transport, exists := transports[template.TransportKey]
|
||||||
if !exists {
|
if !exists {
|
||||||
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
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{}{}
|
logProjectionKeys[projection.Key] = struct{}{}
|
||||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
|
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{}{}
|
dataPackKeys := map[string]struct{}{}
|
||||||
for index, dataPack := range bridge.DataPacks {
|
for index, dataPack := range bridge.DataPacks {
|
||||||
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
|
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
|
||||||
@@ -753,6 +771,174 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
return violations
|
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 {
|
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||||
@@ -852,6 +1038,21 @@ func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.Ga
|
|||||||
}
|
}
|
||||||
projectedFields[destination] = struct{}{}
|
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 {
|
if len(target.FixedValues) > 64 {
|
||||||
violations = append(violations, prefix+".fixedValues contains too many fields")
|
violations = append(violations, prefix+".fixedValues contains too many fields")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,21 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="server-detail-page" aria-labelledby="server-detail-title">
|
<section className="server-detail-page" aria-labelledby="server-detail-title">
|
||||||
|
{instance.status !== "ready" && (
|
||||||
|
<header className="server-detail-header server-detail-pending-header">
|
||||||
|
<div className="server-detail-title-row">
|
||||||
|
<div>
|
||||||
|
<h1 id="server-detail-title">服务器详情</h1>
|
||||||
|
</div>
|
||||||
|
<div className="action-strip">
|
||||||
|
<button type="button" className="icon-command" onClick={() => onNavigate("servers")}>
|
||||||
|
<MoonStar size={16} />
|
||||||
|
<span>返回列表</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
{instance.status === "loading" && <LoadingState label="正在加载服务器详情…" />}
|
{instance.status === "loading" && <LoadingState label="正在加载服务器详情…" />}
|
||||||
{instance.status === "error" && (
|
{instance.status === "error" && (
|
||||||
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
|
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
|
||||||
|
|||||||
@@ -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>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 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}
|
.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-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,.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}
|
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -3,10 +3,13 @@ package companion
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// 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.
|
// execution log. The channel never accepts a file path or a raw log archive.
|
||||||
type ConsoleRecord struct {
|
type ConsoleRecord struct {
|
||||||
@@ -21,6 +24,7 @@ type SemanticEvent struct {
|
|||||||
Sequence uint64
|
Sequence uint64
|
||||||
Type string
|
Type string
|
||||||
PlayerID string
|
PlayerID string
|
||||||
|
DisplayName string
|
||||||
OccurredAt time.Time
|
OccurredAt time.Time
|
||||||
NetworkCorrelation string
|
NetworkCorrelation string
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,9 @@ func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSe
|
|||||||
return batch
|
return batch
|
||||||
}
|
}
|
||||||
func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
|
func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
|
||||||
|
if event, ok := parseLoginLogRecord(record, secret); ok {
|
||||||
|
return event, true
|
||||||
|
}
|
||||||
fields := strings.Fields(record.Text)
|
fields := strings.Fields(record.Text)
|
||||||
if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) {
|
if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) {
|
||||||
return SemanticEvent{}, false
|
return SemanticEvent{}, false
|
||||||
@@ -76,9 +83,26 @@ func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, boo
|
|||||||
}
|
}
|
||||||
return event, true
|
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 {
|
func networkCorrelation(serverID, value, secret string) string {
|
||||||
digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value))
|
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 {
|
func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic {
|
||||||
if len(existing) >= 32 {
|
if len(existing) >= 32 {
|
||||||
|
|||||||
@@ -17,4 +17,24 @@ func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T)
|
|||||||
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
|
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
|
||||||
t.Fatal("raw network value leaked")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,12 @@ export type SCUMSurfaceData = {
|
|||||||
mapSettings: RecordMap[];
|
mapSettings: RecordMap[];
|
||||||
vehicles: RecordMap[];
|
vehicles: RecordMap[];
|
||||||
flags: RecordMap[];
|
flags: RecordMap[];
|
||||||
|
trajectories: RecordMap[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptySCUMSurfaceData: SCUMSurfaceData = {
|
export const emptySCUMSurfaceData: SCUMSurfaceData = {
|
||||||
players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [],
|
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 = {
|
export const scumCollections = {
|
||||||
@@ -137,16 +138,17 @@ export const scumCollections = {
|
|||||||
mapRegions: "scum_map_regions",
|
mapRegions: "scum_map_regions",
|
||||||
mapSettings: "scum_map_settings",
|
mapSettings: "scum_map_settings",
|
||||||
vehicles: "scum_vehicles",
|
vehicles: "scum_vehicles",
|
||||||
flags: "scum_flags"
|
flags: "scum_flags",
|
||||||
|
trajectories: "scum_trajectories"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type SurfaceKey = keyof SCUMSurfaceData;
|
type SurfaceKey = keyof SCUMSurfaceData;
|
||||||
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
|
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
|
||||||
|
|
||||||
const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
||||||
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
|
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"],
|
||||||
squads: ["squads", "members", "flags"],
|
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"],
|
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
|
||||||
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"]
|
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);
|
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
|
||||||
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +190,25 @@ export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unkn
|
|||||||
return merged;
|
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<unknown> {
|
export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
|
||||||
const key = requiredKey(gift, "code", "礼包编号");
|
const key = requiredKey(gift, "code", "礼包编号");
|
||||||
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
|
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<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
|
function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
|
||||||
function addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
|
function addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
|
||||||
function findPlayer(index: Map<string, number>, 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 findPlayer(index: Map<string, number>, 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<string, number> { const result = new Map<string, number>(); vehicles.forEach((vehicle, index) => addVehicleToIndex(result, vehicle, index)); return result; }
|
||||||
|
function addVehicleToIndex(index: Map<string, number>, vehicle: RecordMap, vehicleIndex: number): void { vehicleIdentities(vehicle).forEach((identity) => index.set(identity, vehicleIndex)); }
|
||||||
|
function findVehicle(index: Map<string, number>, 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[] {
|
function playerIdentities(player: RecordMap): string[] {
|
||||||
const identities = new Set<string>();
|
const identities = new Set<string>();
|
||||||
for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); }
|
for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); }
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" |
|
|||||||
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
||||||
type AttributeDraft = { fieldKey: string; label: string; before: string; after: 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 scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
|
||||||
|
const scumMapSize = 256;
|
||||||
|
const rideDistanceThreshold = 50000;
|
||||||
|
const vehicleIconByClass: Record<string, string> = {
|
||||||
|
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 = {
|
export type ReactLike = {
|
||||||
createElement: (...args: any[]) => any;
|
createElement: (...args: any[]) => any;
|
||||||
@@ -122,7 +140,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
|||||||
if (react.useEffect) react.useEffect(() => {
|
if (react.useEffect) react.useEffect(() => {
|
||||||
if (playerPanel.kind === "closed") refresh();
|
if (playerPanel.kind === "closed") refresh();
|
||||||
if (playerPanel.kind !== "closed") return;
|
if (playerPanel.kind !== "closed") return;
|
||||||
const interval = setInterval(refresh, 10000);
|
const interval = setInterval(refresh, 3000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
|
}, [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: "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("div", { className: "console-record-meta scum-player-overview" },
|
||||||
e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`),
|
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, `Fame ${numField(player, "famePoints")}`),
|
||||||
e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`),
|
e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`),
|
||||||
e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)),
|
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) {
|
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()));
|
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) {
|
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
|
||||||
const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row)));
|
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" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 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" }, "没有该用户的真实轨迹记录。")));
|
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"))); }
|
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 search = view.mapSearch.trim().toLowerCase();
|
||||||
const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer"));
|
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 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" },
|
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" },
|
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)) })),
|
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)))
|
(["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("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: "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("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)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
|
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()];
|
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 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 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"; }
|
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<string, string> {
|
export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record<string, string> {
|
||||||
const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0);
|
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 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 mapX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2;
|
||||||
const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50;
|
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))}%` };
|
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<MapLayer, boolean>, 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<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
|
function runAction(setAction: StateSetter<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
|
||||||
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
||||||
function inputValue(event: InputEvent): string { return event.target?.value ?? ""; }
|
function inputValue(event: InputEvent): string { return event.target?.value ?? ""; }
|
||||||
|
|||||||
@@ -310,7 +310,45 @@
|
|||||||
"sqlRef": "sql/scum-db-v57/vehicles.sql",
|
"sqlRef": "sql/scum-db-v57/vehicles.sql",
|
||||||
"pollIntervalSeconds": 3,
|
"pollIntervalSeconds": 3,
|
||||||
"maxRows": 500,
|
"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",
|
"key": "scum.flags",
|
||||||
@@ -338,7 +376,69 @@
|
|||||||
"sqlRef": "sql/scum-db-v57/map-points.sql",
|
"sqlRef": "sql/scum-db-v57/map-points.sql",
|
||||||
"pollIntervalSeconds": 3,
|
"pollIntervalSeconds": 3,
|
||||||
"maxRows": 500,
|
"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",
|
"key": "scum.tasks",
|
||||||
@@ -437,6 +537,237 @@
|
|||||||
"observedAtField": "observedAt"
|
"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<ip>[0-9.]+) (?P<steamId>\\d{1,50}):(?P<displayName>[^']{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<ip>[0-9.]+) (?P<steamId>\\d{1,50}):(?P<displayName>[^']{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": [
|
"dataPacks": [
|
||||||
@@ -797,6 +1128,62 @@
|
|||||||
"path": "assets/map/scum-map-overview.jpg",
|
"path": "assets/map/scum-map-overview.jpg",
|
||||||
"mode": 384
|
"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",
|
"path": "sql/scum-db-v57/users.sql",
|
||||||
"mode": 384
|
"mode": 384
|
||||||
|
|||||||
@@ -259,6 +259,11 @@
|
|||||||
"items": { "$ref": "#/$defs/gameClientBridgeLogProjection" },
|
"items": { "$ref": "#/$defs/gameClientBridgeLogProjection" },
|
||||||
"maxItems": 128
|
"maxItems": 128
|
||||||
},
|
},
|
||||||
|
"lifecycleProjections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/gameClientBridgeLifecycleProjection" },
|
||||||
|
"maxItems": 64
|
||||||
|
},
|
||||||
"dataPacks": {
|
"dataPacks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
|
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
|
||||||
@@ -337,7 +342,23 @@
|
|||||||
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
|
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
|
||||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
||||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
|
"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": {
|
"gameClientBridgeLogProjection": {
|
||||||
@@ -370,6 +391,7 @@
|
|||||||
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
|
"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 },
|
"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}$" } },
|
"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 } },
|
"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}$" }
|
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }
|
||||||
}
|
}
|
||||||
@@ -384,6 +406,42 @@
|
|||||||
"activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }
|
"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": {
|
"gameClientBridgeDataPack": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
|
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const surfaceData: SCUMSurfaceData = {
|
|||||||
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
|
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
|
||||||
mapSettings: [],
|
mapSettings: [],
|
||||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
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" } }]
|
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();
|
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 }] } }] });
|
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");
|
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" });
|
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" }] } }] });
|
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);
|
expect(sameName).toHaveLength(2);
|
||||||
@@ -286,7 +290,7 @@ describe("SCUM plugin feature module", () => {
|
|||||||
expect(source).toContain("remote.access.request");
|
expect(source).toContain("remote.access.request");
|
||||||
expect(source).not.toContain("input.templateKey");
|
expect(source).not.toContain("input.templateKey");
|
||||||
expect(source).not.toContain("requestSCUMPageQueries");
|
expect(source).not.toContain("requestSCUMPageQueries");
|
||||||
expect(pageSource).toContain("setInterval(refresh, 10000)");
|
expect(pageSource).toContain("setInterval(refresh, 3000)");
|
||||||
expect(pageSource).toContain("clearInterval(interval)");
|
expect(pageSource).toContain("clearInterval(interval)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||